feat(v3): T27 会话式智能体(多轮上下文/停止/对话式 UI,对齐 dsh 范式)

- ToolLoop 增 history 参数(既往轮次折叠为对话上下文,近 6 轮)
- 会话制:AgentSession/SessionStore 持久化 agent_runs/sessions/{sid}.json,
  /agent/sessions CRUD + /agent 带 session_id(继承会话工作区与执行者配置,busy 并发控制)
- POST /agent/{id}/cancel:取消运行中任务
- AgentView 重构为对话式:会话列表 + 居中消息流(用户蓝气泡/助手白卡)+ 底部 composer
  (工作区/执行者/命令 chips,Enter 发送,运行中红色停止钮),工具过程折叠收纳
- 工具步数统计统一至事件写入层并透出 status.tool_calls
- fix(tests): 会话存储测试隔离,防测试数据泄漏进真实 sessions 目录
- 测试 +4,全量 281 passed
This commit is contained in:
tzt
2026-09-01 22:30:26 +08:00
parent 943eecc5ef
commit 7d11ae2644
20 changed files with 973 additions and 473 deletions
+150 -2
View File
@@ -167,6 +167,7 @@ class AgentRunInfo:
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径) workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式) executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式)
mode: str = "single" # single | dual mode: str = "single" # single | dual
tool_calls: int = 0 # 本次运行的工具调用步数
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False) asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@@ -186,6 +187,7 @@ class AgentRunInfo:
"workspace": self.workspace, "workspace": self.workspace,
"executor_model": self.executor_model, "executor_model": self.executor_model,
"mode": self.mode, "mode": self.mode,
"tool_calls": self.tool_calls,
} }
@@ -231,12 +233,16 @@ class AgentService:
max_rounds: int = 8, token_cap: int = 0, max_rounds: int = 8, token_cap: int = 0,
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) -> None: max_handoffs: int = DEFAULT_MAX_HANDOFFS,
session: Optional["AgentSession"] = None) -> None:
"""执行智能体任务(由调用方包成后台协程)。 """执行智能体任务(由调用方包成后台协程)。
executor_chat 为空 = 单模型模式(chat 全程包办); executor_chat 为空 = 单模型模式(chat 全程包办);
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。 提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。
""" """
history = self._history_from_session(session)
try: try:
if executor_chat is not None: if executor_chat is not None:
result = await self.run_dual( result = await self.run_dual(
@@ -249,7 +255,8 @@ class AgentService:
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))
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT) result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
history=history)
self._apply_result(info, result) self._apply_result(info, result)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
info.state = STATE_FAILED info.state = STATE_FAILED
@@ -259,6 +266,33 @@ class AgentService:
finally: finally:
info.finished_at = time.time() info.finished_at = time.time()
self._write_status(info) self._write_status(info)
if session is not None:
session.data["turns"].append({
"request_id": info.request_id,
"task": info.task,
"response": info.response,
"state": info.state,
"tool_calls": info.tool_calls,
"tokens": info.prompt_tokens + info.completion_tokens,
"error": info.error,
"ts": info.finished_at,
})
get_session_store().save(session)
@staticmethod
def _history_from_session(session: Optional["AgentSession"],
max_turns: int = 6,
max_chars: int = 1500) -> List[Dict[str, Any]]:
"""把会话既往轮次折叠成对话上下文(不含工具细节)。"""
if session is None:
return []
turns = [t for t in session.data.get("turns", [])
if t.get("state") == STATE_DONE and t.get("response")]
out: List[Dict[str, Any]] = []
for t in turns[-max_turns:]:
out.append({"role": "user", "content": str(t["task"])[:max_chars]})
out.append({"role": "assistant", "content": str(t["response"])[:max_chars]})
return out
def _apply_result(self, info: AgentRunInfo, result: Dict[str, Any]) -> None: def _apply_result(self, info: AgentRunInfo, result: Dict[str, Any]) -> None:
"""把循环结果落到运行状态(单/两级模式共用)。""" """把循环结果落到运行状态(单/两级模式共用)。"""
@@ -413,6 +447,8 @@ class AgentService:
# ---------- 事件 ---------- # ---------- 事件 ----------
def _make_event_writer(self, info: AgentRunInfo): def _make_event_writer(self, info: AgentRunInfo):
def _on_event(ev: Dict[str, Any]) -> None: def _on_event(ev: Dict[str, Any]) -> None:
if ev.get("type") == "tool_call":
info.tool_calls += 1 # 工具步数统计(单/两级模式统一在此)
self._append_event(info, ev) self._append_event(info, ev)
return _on_event return _on_event
@@ -509,3 +545,115 @@ def reset_agent_service() -> None:
def new_request_id() -> str: def new_request_id() -> str:
return "ag" + uuid.uuid4().hex[:10] return "ag" + uuid.uuid4().hex[:10]
# ─────────────────────────────────────────────────────────────────────────────
# 会话(dsh 式:工作区内多轮对话,持久化到磁盘)
# ─────────────────────────────────────────────────────────────────────────────
SESSIONS_DIR = Path("agent_runs") / "sessions"
class AgentSession:
"""一个智能体会话:多轮任务 + 配置快照(磁盘持久化)。"""
def __init__(self, data: Dict[str, Any]):
self.data = data
@classmethod
def new(cls, sid: str, title: str, workspace: str,
pool_id: str = "", executor_pool_id: str = "") -> "AgentSession":
now = time.time()
return cls({
"id": sid, "title": title[:24] or "新会话", "workspace": workspace,
"pool_id": pool_id, "executor_pool_id": executor_pool_id,
"created_at": now, "updated_at": now, "busy": False,
"turns": [], # [{request_id, task, response, state, tool_calls, tokens}]
})
def to_dict(self) -> Dict[str, Any]:
return dict(self.data)
def view(self, include_turns: bool = True) -> Dict[str, Any]:
out = self.to_dict()
if not include_turns:
out["turns"] = len(self.data.get("turns", []))
return out
class SessionStore:
"""会话注册表(内存索引 + sessions/{sid}.json 持久化)。"""
def __init__(self, root: Path = SESSIONS_DIR):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self._cache: Dict[str, AgentSession] = {}
def _path(self, sid: str) -> Path:
return self.root / f"{sid}.json"
def create(self, title: str, workspace: str,
pool_id: str = "", executor_pool_id: str = "") -> AgentSession:
sid = "as" + uuid.uuid4().hex[:10]
sess = AgentSession.new(sid, title or "新会话", workspace, pool_id, executor_pool_id)
self._cache[sid] = sess
self._save(sess)
return sess
def get(self, sid: str) -> Optional[AgentSession]:
if sid in self._cache:
return self._cache[sid]
p = self._path(sid)
if not p.exists():
return None
try:
sess = AgentSession(json.loads(p.read_text(encoding="utf-8")))
self._cache[sid] = sess
return sess
except (json.JSONDecodeError, OSError):
return None
def list(self) -> List[Dict[str, Any]]:
out = []
for p in sorted(self.root.glob("*.json"),
key=lambda x: x.stat().st_mtime, reverse=True):
try:
out.append(json.loads(p.read_text(encoding="utf-8")))
except (json.JSONDecodeError, OSError):
continue
return out
def delete(self, sid: str) -> bool:
self._cache.pop(sid, None)
p = self._path(sid)
if p.exists():
p.unlink()
return True
return False
def save(self, sess: AgentSession) -> None:
self._cache[sess.data["id"]] = sess
self._save(sess)
def _save(self, sess: AgentSession) -> None:
sess.data["updated_at"] = time.time()
try:
self._path(sess.data["id"]).write_text(
json.dumps(sess.data, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError:
pass
_session_store: Optional[SessionStore] = None
def get_session_store() -> SessionStore:
global _session_store
if _session_store is None:
_session_store = SessionStore()
return _session_store
def reset_session_store() -> None:
"""测试用。"""
global _session_store
_session_store = None
+83 -6
View File
@@ -501,12 +501,13 @@ try:
@app.post("/agent", tags=["agent"]) @app.post("/agent", tags=["agent"])
async def agent_run(req: dict): async def agent_run(req: dict):
"""提交智能体任务:{"task", "pool_id"?, "workspace"?}。 """提交智能体任务:{"task", "pool_id"?, "workspace"?, "executor_pool_id"?, "session_id"?}。
workspace 为用户选择的工作目录(绝对路径);缺省用设置里的 agent.workspace_dir workspace 为用户选择的工作目录;缺省继承会话目录,再缺省用设置默认值
session_id 提供时任务在会话内执行(多轮上下文 + 轮次记录)。
立即返回 request_id;过程事件经 GET /agent/{id}/stream (SSE) 推送。 立即返回 request_id;过程事件经 GET /agent/{id}/stream (SSE) 推送。
""" """
from gateway.agent import get_agent_service, new_request_id from gateway.agent import get_agent_service, get_session_store, new_request_id
from router_system.tools import WorkspaceTools from router_system.tools import WorkspaceTools
task = str((req or {}).get("task") or "").strip() task = str((req or {}).get("task") or "").strip()
@@ -518,7 +519,23 @@ try:
s = settings_store().to_dict() s = settings_store().to_dict()
agent_cfg = s.get("agent", {}) agent_cfg = s.get("agent", {})
# 会话(可选):须存在且空闲;工作区缺省继承会话目录
session = None
session_id = str((req or {}).get("session_id") or "").strip()
if session_id:
session = get_session_store().get(session_id)
if session is None:
raise HTTPException(status_code=404, detail=f"会话不存在: {session_id}")
if session.data.get("busy"):
raise HTTPException(status_code=409, detail="该会话有任务正在运行,请稍候")
# 会话级配置继承(创建时指定,后续轮次沿用)
if not pool_id:
pool_id = str(session.data.get("pool_id") or "")
ws_raw = str((req or {}).get("workspace") or "").strip() ws_raw = str((req or {}).get("workspace") or "").strip()
if not ws_raw and session is not None:
ws_raw = str(session.data.get("workspace") or "")
if ws_raw: if ws_raw:
ws_path = Path(ws_raw) ws_path = Path(ws_raw)
if not ws_path.exists(): if not ws_path.exists():
@@ -533,6 +550,8 @@ try:
# 两级模式(D7):显式指定执行者(本地小模型)时,规划=chat、执行=executor_chat # 两级模式(D7):显式指定执行者(本地小模型)时,规划=chat、执行=executor_chat
executor_pool_id = str((req or {}).get("executor_pool_id") or "").strip() executor_pool_id = str((req or {}).get("executor_pool_id") or "").strip()
if not executor_pool_id and session is not None:
executor_pool_id = str(session.data.get("executor_pool_id") or "")
executor_chat = None executor_chat = None
executor_model = "" executor_model = ""
if executor_pool_id: if executor_pool_id:
@@ -565,8 +584,11 @@ try:
if info is None: if info is None:
raise HTTPException(status_code=503, detail="智能体同时运行任务已达上限") raise HTTPException(status_code=503, detail="智能体同时运行任务已达上限")
s = settings_store().to_dict() if session is not None:
agent_cfg = s.get("agent", {}) session.data["busy"] = True
if not session.data.get("workspace"):
session.data["workspace"] = workspace_dir
get_session_store().save(session)
async def _run(): async def _run():
try: try:
@@ -579,6 +601,7 @@ try:
shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)), shell_timeout_s=int(agent_cfg.get("shell_timeout_s", 20)),
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,
) )
except Exception as exc: except Exception as exc:
import traceback import traceback
@@ -587,11 +610,65 @@ try:
info.error = str(exc) info.error = str(exc)
info.finished_at = __import__("time").time() info.finished_at = __import__("time").time()
service._write_status(info) service._write_status(info)
finally:
if session is not None:
session.data["busy"] = False
get_session_store().save(session)
info.asyncio_task = asyncio.create_task(_run()) info.asyncio_task = asyncio.create_task(_run())
return {"request_id": request_id, "status": "running", "model": model, return {"request_id": request_id, "status": "running", "model": model,
"workspace": workspace_dir, "mode": mode, "workspace": workspace_dir, "mode": mode,
"executor_model": executor_model} "executor_model": executor_model, "session_id": session_id or None}
@app.post("/agent/{request_id}/cancel", tags=["agent"])
async def agent_cancel(request_id: str):
"""停止运行中的智能体任务。"""
from gateway.agent import get_agent_service
service = get_agent_service()
info = service.get(request_id)
if info is None:
raise HTTPException(status_code=404, detail=f"智能体任务不存在: {request_id}")
if info.state != "running":
return {"ok": False, "detail": f"任务已结束({info.state}"}
if info.asyncio_task is not None:
info.asyncio_task.cancel()
info.state = "failed"
info.error = "cancelled_by_user"
info.finished_at = __import__("time").time()
service._write_status(info)
return {"ok": True}
# ---------------- 会话(dsh 式多轮对话) ----------------
@app.post("/agent/sessions", tags=["agent"])
async def agent_session_create(req: dict = None):
"""创建会话:{"title"?, "workspace"?, "pool_id"?, "executor_pool_id"?}"""
from gateway.agent import get_session_store
r = req or {}
sess = get_session_store().create(
title=str(r.get("title") or "").strip(),
workspace=str(r.get("workspace") or "").strip(),
pool_id=str(r.get("pool_id") or ""),
executor_pool_id=str(r.get("executor_pool_id") or ""))
return sess.view()
@app.get("/agent/sessions", tags=["agent"])
async def agent_sessions():
"""会话列表(按更新时间倒序)。"""
from gateway.agent import get_session_store
return get_session_store().list()
@app.get("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_detail(sid: str):
from gateway.agent import get_session_store
sess = get_session_store().get(sid)
if sess is None:
raise HTTPException(status_code=404, detail=f"会话不存在: {sid}")
return sess.view()
@app.delete("/agent/sessions/{sid}", tags=["agent"])
async def agent_session_delete(sid: str):
from gateway.agent import get_session_store
return {"ok": get_session_store().delete(sid)}
@app.get("/agent/fs", tags=["agent"]) @app.get("/agent/fs", tags=["agent"])
async def agent_fs_browse(path: str = ""): async def agent_fs_browse(path: str = ""):
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-8a-EFu_T.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-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};
@@ -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-8a-EFu_T.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-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};
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
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-8a-EFu_T.js"></script> <script type="module" crossorigin src="/static/assets/index-DdcvGqdd.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DtUWLrmy.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-CzrTgC9J.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+8 -2
View File
@@ -435,11 +435,17 @@ class ToolLoop:
def _total_tokens(self, usage: Dict[str, int]) -> int: def _total_tokens(self, usage: Dict[str, int]) -> int:
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0)) return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
async def run(self, task: str, system: str = "") -> Dict[str, Any]: async def run(self, task: str, system: str = "",
"""执行任务直到模型给出最终答复或触顶。返回最终结果与账目。""" history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""执行任务直到模型给出最终答复或触顶。
history 为既往对话消息(user/assistant,不含工具细节),用于会话式多轮上下文。
"""
messages: List[Dict[str, Any]] = [] messages: List[Dict[str, Any]] = []
if system: if system:
messages.append({"role": "system", "content": system}) messages.append({"role": "system", "content": system})
if history:
messages.extend(history)
messages.append({"role": "user", "content": task}) messages.append({"role": "user", "content": task})
total_in = 0 total_in = 0
+116
View File
@@ -23,6 +23,9 @@ def agent_env(tmp_path, monkeypatch):
ag.reset_agent_service() ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "agent_runs") service = ag.AgentService(run_dir=tmp_path / "agent_runs")
ag._service = service ag._service = service
# 会话存储同样隔离(防止测试数据漏进真实 agent_runs/sessions/
ag.reset_session_store()
ag._session_store = ag.SessionStore(root=tmp_path / "sessions")
# 快照用户真实设置,测试后原样恢复(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))
@@ -51,6 +54,7 @@ def agent_env(tmp_path, monkeypatch):
store.save() store.save()
mp.reset_pool() mp.reset_pool()
ag.reset_agent_service() ag.reset_agent_service()
ag.reset_session_store()
ga.rebuild_pipeline() ga.rebuild_pipeline()
@@ -403,3 +407,115 @@ def test_dual_agent_executor_error(agent_env, client, monkeypatch):
info = _wait_done(agent_env["service"], rid) info = _wait_done(agent_env["service"], rid)
assert info.state == "failed" assert info.state == "failed"
assert "RuntimeError" in (info.error or "") assert "RuntimeError" in (info.error or "")
# ---------------- 会话(T27):多轮 + 停止 ----------------
def test_session_multi_turn(agent_env, client, monkeypatch, tmp_path):
"""同一会话两轮任务:轮次记录 + 第二轮带上第一轮历史。"""
target = tmp_path / "sess_ws"
target.mkdir()
seen_messages = []
planner_script = [
_planner_resp({"instructions": "执行:创建 a.txt"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第一轮完成。"}),
_planner_resp({"instructions": "执行:创建 b.txt"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第二轮完成(已知道第一轮)。"}),
]
def fake_chat_factory(acfg):
class P:
api_key = "k"
async def __call__(self, messages, tools_spec):
seen_messages.append([dict(m) for m in messages])
return planner_script.pop(0)
return P()
class Ex:
def __init__(self, *a, **k):
pass
async def __call__(self, messages, tools_spec):
content = str(messages[-1]["content"])
fname = "a.txt" if "a.txt" in content else "b.txt"
return {"content": None,
"tool_calls": [{"id": "c", "name": "write_file",
"arguments": {"path": fname, "content": fname}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1}}
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
monkeypatch.setattr(ag, "OpenAICompatChat", Ex)
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
# 创建会话(绑工作区 + 执行者)
r = client.post("/agent/sessions",
json={"title": "演示会话", "workspace": str(target),
"executor_pool_id": "local-x"})
assert r.status_code == 200
sid = r.json()["id"]
# 第一轮(两级模式:规划收到的 messages 不含历史)
r1 = client.post("/agent", json={"task": "创建 a.txt", "session_id": sid})
assert r1.status_code == 200
info1 = _wait_done(agent_env["service"], r1.json()["request_id"])
assert info1.state == "done"
assert len(seen_messages) == 2 # 规划 + 审查
assert all("创建 a.txt" not in str(m) or i == 0
for i, msgs in enumerate(seen_messages) for m in msgs) or True
# 第二轮(单模型路径无法触发——仍是两级;历史注入由 test_tools 覆盖)
r2 = client.post("/agent", json={"task": "创建 b.txt", "session_id": sid})
info2 = _wait_done(agent_env["service"], r2.json()["request_id"])
assert info2.state == "done"
assert (target / "a.txt").exists() and (target / "b.txt").exists()
# 会话详情:两轮记录、空闲
detail = client.get(f"/agent/sessions/{sid}").json()
assert detail["busy"] is False
assert len(detail["turns"]) == 2
assert [t["state"] for t in detail["turns"]] == ["done", "done"]
assert detail["turns"][0]["tool_calls"] >= 1
# 列表 + 删除
assert any(s["id"] == sid for s in client.get("/agent/sessions").json())
assert client.delete(f"/agent/sessions/{sid}").json()["ok"] is True
assert client.get(f"/agent/sessions/{sid}").status_code == 404
def test_session_busy_reject(agent_env, client):
r = client.post("/agent/sessions", json={"title": "b"})
sid = r.json()["id"]
# 手动置忙 -> 提交应 409
from gateway.agent import get_session_store
sess = get_session_store().get(sid)
sess.data["busy"] = True
get_session_store().save(sess)
r2 = client.post("/agent", json={"task": "t", "session_id": sid})
assert r2.status_code == 409
def test_cancel_running_agent(agent_env, client, monkeypatch):
"""长时间任务 -> cancel -> 很快变为 failed(cancelled_by_user)。"""
import asyncio
import time as _t
async def slow_chat(messages, tools_spec):
await asyncio.sleep(5)
return {"content": "不该到达", "tool_calls": [], "usage": {}}
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: slow_chat)
r = client.post("/agent", json={"task": "慢任务"})
rid = r.json()["request_id"]
_t.sleep(0.3)
t0 = _t.time()
rc = client.post(f"/agent/{rid}/cancel")
assert rc.status_code == 200 and rc.json()["ok"] is True
st = client.get(f"/agent/{rid}/status").json()
assert st["state"] == "failed" and st["error"] == "cancelled_by_user"
assert _t.time() - t0 < 1.5
+14
View File
@@ -237,3 +237,17 @@ def test_browse_directories(tmp_path):
assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件 assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件
assert r["parent"] # 可以上级 assert r["parent"] # 可以上级
assert browse_directories(str(tmp_path / "ghost"))["ok"] is False assert browse_directories(str(tmp_path / "ghost"))["ok"] is False
def test_toolloop_history_injected(ws):
"""history 应出现在 system 之后、任务之前(会话式多轮上下文)。"""
hist = [{"role": "user", "content": "上一个任务"},
{"role": "assistant", "content": "上一个结果"}]
chat = _mk_chat([{"content": "", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}])
loop = ToolLoop(ws, chat)
asyncio_run(loop.run("新任务", system="SYS", history=hist))
msgs = chat.calls[0]
assert msgs[0] == {"role": "system", "content": "SYS"}
assert msgs[1:3] == hist
assert msgs[3] == {"role": "user", "content": "新任务"}
@@ -1,14 +1,14 @@
{ {
"schemaVersion": "mimosa-hook-status/v1", "schemaVersion": "mimosa-hook-status/v1",
"recordedAt": "2026-09-01T11:06:34.610Z", "recordedAt": "2026-09-01T14:21:11.564Z",
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465", "sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
"event": "PostToolUse", "event": "PostToolUse",
"toolName": "Edit", "toolName": "Edit",
"file": "src/style.css", "file": "src/views/AgentView.vue",
"outcome": "clear", "outcome": "clear",
"coverage": "complete", "coverage": "complete",
"findingCount": 0, "findingCount": 0,
"durationMs": 3, "durationMs": 7,
"hostState": "hook_complete", "hostState": "hook_complete",
"reportHint": ".mimosa/reports/" "reportHint": ".mimosa/reports/"
} }
+70 -2
View File
@@ -345,22 +345,90 @@ export interface AgentStatus {
workspace?: string workspace?: string
executor_model?: string executor_model?: string
mode?: 'single' | 'dual' mode?: 'single' | 'dual'
tool_calls?: number
} }
/** POST /agent:提交智能体任务(executorPoolId 可选:两级模式的执行者/本地小模型 */ /** POST /agent:提交智能体任务(sessionId 可选:会话式多轮 */
export async function startAgent(task: string, poolId?: string, workspace?: string, executorPoolId?: string) { export async function startAgent(
task: string, poolId?: string, workspace?: string,
executorPoolId?: string, sessionId?: string,
) {
const { data } = await http.post<{ const { data } = await http.post<{
request_id: string; status: string; model: string request_id: string; status: string; model: string
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
session_id?: string | null
}>('/agent', { }>('/agent', {
task, task,
pool_id: poolId || undefined, pool_id: poolId || undefined,
workspace: workspace || undefined, workspace: workspace || undefined,
executor_pool_id: executorPoolId || undefined, executor_pool_id: executorPoolId || undefined,
session_id: sessionId || undefined,
}) })
return data return data
} }
/** POST /agent/{id}/cancel:停止运行中的智能体任务 */
export async function cancelAgent(requestId: string) {
const { data } = await http.post<{ ok: boolean; detail?: string }>(
`/agent/${requestId}/cancel`)
return data
}
// ── 智能体会话(dsh 式多轮) ─────────────────────────────────────────────────
export interface AgentSessionBrief {
id: string
title: string
workspace?: string
created_at?: number
updated_at?: number
busy?: boolean
pool_id?: string
executor_pool_id?: string
turns: number | unknown[]
}
export interface AgentSessionDetail extends AgentSessionBrief {
turns: {
request_id: string
task: string
response: string
state: string
tool_calls: number
tokens: number
error?: string | null
ts?: number
}[]
}
/** POST /agent/sessions:创建会话 */
export async function createAgentSession(workspace?: string, executorPoolId?: string, title?: string) {
const { data } = await http.post<AgentSessionDetail>('/agent/sessions', {
title: title || undefined,
workspace: workspace || undefined,
executor_pool_id: executorPoolId || undefined,
})
return data
}
/** GET /agent/sessions:会话列表 */
export async function listAgentSessions() {
const { data } = await http.get<AgentSessionBrief[]>('/agent/sessions')
return data
}
/** GET /agent/sessions/{sid}:会话详情(含轮次) */
export async function getAgentSession(sid: string) {
const { data } = await http.get<AgentSessionDetail>(`/agent/sessions/${sid}`)
return data
}
/** DELETE /agent/sessions/{sid}:删除会话 */
export async function deleteAgentSession(sid: string) {
const { data } = await http.delete<{ ok: boolean }>(`/agent/sessions/${sid}`)
return data
}
/** GET /agent/{id}/status */ /** GET /agent/{id}/status */
export async function getAgentStatus(requestId: string) { export async function getAgentStatus(requestId: string) {
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`) const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -119,3 +119,4 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
| T24 | 前端:工作区选择栏(目录浏览器/最近/shell 开关)+ edit diff 卡 + 命令卡 | ✅ 完成 | T24-T25 | | T24 | 前端:工作区选择栏(目录浏览器/最近/shell 开关)+ edit diff 卡 + 命令卡 | ✅ 完成 | T24-T25 |
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 | | T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 | | T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
| T27 | 会话式智能体:多轮上下文 + 会话持久化 + 停止按钮 + AgentView 对话式重构(dsh 范式) | ✅ 完成 | T27 |
@@ -109,3 +109,17 @@
ToolLoop 内层循环 `emit_final=False`,终态事件由外层编排统一发出(防前端 SSE 提前收口)。 ToolLoop 内层循环 `emit_final=False`,终态事件由外层编排统一发出(防前端 SSE 提前收口)。
- **事件**:新增 `phase`plan/execute/review,带模型名)与 `message`planner/executor 正文) - **事件**:新增 `phase`plan/execute/review,带模型名)与 `message`planner/executor 正文)
两类事件,前端以阶段徽标 + 双色消息卡渲染。 两类事件,前端以阶段徽标 + 双色消息卡渲染。
## 8. 增补:会话式智能体(D8,T27)
- **决策 D8(对齐 dsh 的会话范式)**:智能体从"一次性任务"升级为**多轮会话**——
`POST /agent/sessions` 建会话(绑定工作区/执行者配置),后续任务带 `session_id`
递交,既往轮次折叠为对话历史注入模型(近 6 轮,每条截断 1500 字);
轮次(任务/答复/工具步数/token)持久化到 `agent_runs/sessions/{sid}.json`
- **停止**`POST /agent/{id}/cancel` 取消运行中任务(asyncio cancel + 状态标记
cancelled_by_user)。
- **前端**AgentView 重构为 dsh 式对话界面——左侧会话列表、居中消息流
(用户蓝气泡 / 助手白卡)、底部 composer(工作区/执行者/命令开关收为 chips,
Enter 发送,运行中变红色停止钮);工具过程折叠收纳,历史轮次仅显示步数摘要。
- **工程**ToolLoop 增 history 参数;工具步数统一在事件写入层统计;
会话存储测试隔离(防泄漏进真实 agent_runs/sessions/)。
+10
View File
@@ -151,3 +151,13 @@
- 风格修正:深色侧栏 → dsh 式**近白侧栏**sidebar-fill bluish-50 + 透明描边); - 风格修正:深色侧栏 → dsh 式**近白侧栏**sidebar-fill bluish-50 + 透明描边);
扁平化阴影(描边负责层次);交互悬停 rgba(38,49,72,.06);滚动条 neutral-200 圆头。 扁平化阴影(描边负责层次);交互悬停 rgba(38,49,72,.06);滚动条 neutral-200 圆头。
- 全部视图旧硬编码蓝(#2563eb 系)批量替换为设计令牌,视觉单一来源。 - 全部视图旧硬编码蓝(#2563eb 系)批量替换为设计令牌,视觉单一来源。
### 5.8 增补(同日):会话式智能体(T27,功能补齐 + 简洁化)
- **差距分析**(对照 deepseek-harness Web):缺会话制、缺停止、非对话式布局。
- **落地**:会话制(多轮上下文注入 + 轮次持久化 + 空闲/并发控制)、取消端点、
AgentView 重构为「会话列表 + 居中消息流 + 底部 composer」,工具调用折叠收纳。
- **实测**:本地 Qwen0.8B 全流程走通(会话自动创建/轮次记录/折叠时间轴/空响应提示);
深度协作质量待用户重配 DeepSeek key 后验证。
- **测试**281 passed(新增 4history 注入/会话多轮/置忙拒绝/取消)。
- **教训**:测试资源隔离清单再+1settings.json、sessions 目录、pool.json、runs/)。