From f4c135a98bc184354c54a064edfff09217d1c500 Mon Sep 17 00:00:00 2001 From: AnyBot Dev Date: Mon, 9 Nov 2026 17:58:56 +0800 Subject: [PATCH] Audit timeline: what this turn did, on the phone (S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oplog gets wall-clock times on tool records (serde-default: old logs read fine), and OpLog::timeline turns the machine log into a story: per turn — the prompt, model calls (retries counted into one line), every tool with the arguments it ran with and its status (等核准/進行中/完成/被拒絕/失敗), messages typed mid-turn, stop, outcome - GET /api/bots//audit (owner/admin; members' allow-list untouched) - 對話詳細資訊 gains a 稽核 tab: newest turn first, running tool shows 進行中…, an approval gate shows as 等核准 — the phone can answer the question 'what is my bot doing right now, exactly', step by step - the log stays the machine format recovery reads; the timeline is derived --- mobile/index.html | 28 +++++++- src/agent.rs | 4 +- src/oplog.rs | 164 +++++++++++++++++++++++++++++++++++++++++++--- src/remote.rs | 5 ++ 4 files changed, 187 insertions(+), 14 deletions(-) diff --git a/mobile/index.html b/mobile/index.html index 934efaf..0f1e0a5 100644 --- a/mobile/index.html +++ b/mobile/index.html @@ -579,7 +579,7 @@ applyTheme(); const S = { relay: false, token: localStorage.getItem("anybot.token") || "", host: localStorage.getItem("anybot.host") || "", version: 0, bots: [], approvals: [], pages: [], models: [], hostName: "", mac: false, box: "", boxOn: false, role: "owner", - open: 0, msgs: [], pending: [], quote: null, unfold: {}, openSteps: {}, detail: {}, settings: null, polling: false, + open: 0, msgs: [], pending: [], quote: null, unfold: {}, openSteps: {}, detail: {}, audit: {}, settings: null, polling: false, search: "", size: [0, 0], right: false, }; const owner = () => S.role !== "member"; @@ -1282,6 +1282,11 @@ async function loadDetail(id) { } const item = (o) => `<${o.tap ? "button" : "div"} class="item ${o.danger ? "danger" : ""}" ${o.data || ""}>${o.icon ? ic(o.icon, 19) : ""}
${esc(o.k)}
${o.v ? `
${esc(o.v)}
` : ""}
${o.r != null ? `
${esc(o.r)}
` : ""}${o.sw != null ? `` : ""}${o.tap && o.chev !== false ? `${ic("chevron-right", 16)}` : ""}`; const group = (title, inner, extra = "") => `${title ? `
${esc(title)}${extra}
` : ""}
${inner}
`; +async function loadAudit(id) { + try { S.audit[id] = await api(base() + `api/bots/${id}/audit`); } catch (e) { S.audit[id] = {turns: []}; } + if (cur().v === "details" && S.tab === "audit") renderDetails(id, "audit", true); +} + async function renderDetails(id, tab, quiet) { const b = bot(id); if (!b) { back(); return; } S.tab = tab || S.tab || "activity"; @@ -1289,7 +1294,7 @@ async function renderDetails(id, tab, quiet) { if (cur().v !== "details") return; $("b-botset").style.display = isOwner() ? "" : "none"; const d = S.detail[id] || {}; - const tabs = [["activity", "動態"], ["routines", "例行任務"], ["library", "資料庫"], ["computer", "電腦"]]; + const tabs = [["activity", "動態"], ["routines", "例行任務"], ["library", "資料庫"], ["computer", "電腦"], ["audit", "稽核"]]; if (b.members) tabs.push(["members", "成員"]); let h = `
${face(b, "lg")}
${esc(b.name)}
${b.description ? `
${esc(b.description)}
` : ""}
`; h += `
${tabs.map(([k, n]) => ``).join("")}
`; @@ -1310,6 +1315,25 @@ function tabBody(b, d) { if (owner()) h += group("這個 Bot", item({icon: "sparkles", k: "模型", r: b.model || "", tap: 1, data: 'data-d="model"'}) + item({icon: "file-text", k: "指示", v: d.instructions ? d.instructions.slice(0, 120) + (d.instructions.length > 120 ? "…" : "") : "還沒有寫", tap: isOwner() ? 1 : 0, data: isOwner() ? 'data-d="instr"' : ""}) + item({icon: "history", k: "過去的對話", r: String((d.threads || []).length), tap: 1, data: 'data-d="threads"'})); return h; } + if (S.tab === "audit") { + const a = S.audit[b.id]; + if (a === undefined) { loadAudit(b.id); return `
載入中…
`; } + if (!a.turns.length) return `
現在沒有進行中的工作。稽核時間軸記的是**這回合**做了什麼:每次問模型、每個工具的參數與結果、你插入的話、停止。回合結束後只留最近的,聊天記錄本身才是長久的。
`; + const OUT = {completed: "已結束", aborted: "被你停止", failed: "失敗"}; + const ST = {done: "完成", running: "進行中…", waiting: "等核准", denied: "被拒絕", failed: "失敗"}; + const TI = {computer: "zap", menu_click: "zap", fill_form: "zap", wait_for: "clock", run_cmd: "zap", web_fetch: "globe", browser: "globe"}; + let h = ""; + for (const t of [...a.turns].reverse()) { + const steps = t.steps.map((x) => { + if (x.type === "model") return item({icon: "sparkles", k: x.step === "assistant" ? "問模型" : x.step === "compaction" ? "整理長對話" : x.step, v: x.attempts > 1 ? `試了 ${x.attempts} 次(有重試)` : ""}); + if (x.type === "tool") return item({icon: TI[x.name] || "zap", k: x.name, v: (x.args || "").slice(0, 200), r: ST[x.status] || x.status}); + if (x.type === "queue") return item({icon: "message", k: "你插進來的話", v: x.text, r: x.state === "waiting" ? "排隊中" : x.state === "consumed" ? "已送達" : "已取消"}); + return item({icon: "stop", k: "你按了停止"}); + }).join(""); + h += group(`〈${(t.prompt || "(無訊息的回合)").slice(0, 30)}〉 ${ago(t.at)} · ${OUT[t.outcome] || "進行中…"}`, steps); // (group() escapes the title itself) + } + return h; + } if (S.tab === "routines") { const rs = d.routines || []; const list = rs.map((r) => item({icon: r.paused ? "pause" : "alarm-clock", k: r.title, v: (r.trigger ? "事件觸發:" + r.trigger : r.repeat) + " · " + r.when + (r.status ? "\n" + r.status : ""), tap: 1, data: `data-rt="${r.id}"`})).join(""); diff --git a/src/agent.rs b/src/agent.rs index 31b3a3f..df0fbd3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1167,7 +1167,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if matches!(status, ToolStatus::Failed | ToolStatus::Denied) || !repeat_note.is_empty() || output.contains(crate::computer::NOOP_TAG) { trouble.set(true); } - job.note(crate::oplog::Record::ToolFinished { op: job.op.clone(), call_id: call.id.clone(), status: format!("{status:?}").to_lowercase() }); + job.note(crate::oplog::Record::ToolFinished { op: job.op.clone(), call_id: call.id.clone(), status: format!("{status:?}").to_lowercase(), at: crate::model::now_ms() }); send(StreamEvent::ToolStatus(call.id.clone(), status)); let mut result = Message::tool_result(&call.id, &call.name, &wrap_untrusted(&call.name, format!("{repeat_note}{}", noted(&moved, &call.id, output)))); result.images.extend(image); @@ -1177,7 +1177,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // About to run, every check passed: the work log hears it first, with the // arguments it runs with and whether it may run again after a crash. let started = || { - job.note(crate::oplog::Record::ToolStarted { op: job.op.clone(), call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), replay: crate::oplog::replay_of(&call.name) }); + job.note(crate::oplog::Record::ToolStarted { op: job.op.clone(), call_id: call.id.clone(), name: call.name.clone(), args: call.args.clone(), replay: crate::oplog::replay_of(&call.name), at: crate::model::now_ms() }); send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); }; if call.name == REPLAY_TOOL { diff --git a/src/oplog.rs b/src/oplog.rs index 393a58d..fae6478 100644 --- a/src/oplog.rs +++ b/src/oplog.rs @@ -59,8 +59,23 @@ pub enum Record { /// A model call is about to be made (`step`: assistant | compaction), the n-th for this step. StepAttempt { op: String, step: String, attempt: u32 }, /// A step passed its checks and is about to run, with the arguments it runs with. - ToolStarted { op: String, call_id: String, name: String, args: String, replay: Replay }, - ToolFinished { op: String, call_id: String, status: String }, + ToolStarted { + op: String, + call_id: String, + name: String, + args: String, + replay: Replay, + /// Wall time, so the audit timeline can show when each step ran (old logs: 0). + #[serde(default)] + at: i64, + }, + ToolFinished { + op: String, + call_id: String, + status: String, + #[serde(default)] + at: i64, + }, /// The person typed this while the bot worked; it waits for the bot's next step. QueueEnqueued { #[serde(default)] @@ -135,6 +150,103 @@ impl OpLog { std::fs::read_to_string(&self.path).map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect()).unwrap_or_default() } + /// A person-readable timeline: turns (newest last), each with its prompt, outcome and + /// steps — model attempts merged, tools with the arguments they ran with and their status, + /// messages typed while the bot worked. This is what the phone's 稽核時間軸 shows; the + /// log itself stays the machine format the recovery code reads. + pub fn timeline(recs: &[Record]) -> serde_json::Value { + #[derive(Default)] + struct Turn { + op: String, + at: i64, + prompt: String, + outcome: Option<&'static str>, + steps: Vec, + } + // (A plain fn, not a closure: a closure returning a borrow of its argument can't tie + // the two lifetimes together.) + fn find<'a>(op: &str, turns: &'a mut [Turn]) -> Option<&'a mut Turn> { + turns.iter_mut().rev().find(|t| t.op == op) + } + let mut turns: Vec = Vec::new(); + for r in recs { + match r { + Record::OperationStarted { op, at, prompt, .. } => turns.push(Turn { + op: op.clone(), + at: *at, + prompt: prompt.as_ref().map(|p| p.text.clone()).unwrap_or_default(), + ..Default::default() + }), + Record::StepAttempt { op, step, .. } => { + if let Some(t) = find(op, &mut turns) { + // Retry the same step? One line, with the attempts counted. + match t.steps.last_mut().filter(|s| s["type"] == "model" && s["step"] == *step) { + Some(last) => { + let n = last["attempts"].as_u64().unwrap_or(1) + 1; + last["attempts"] = serde_json::json!(n); + } + None => t.steps.push(serde_json::json!({"type": "model", "step": step, "attempts": 1})), + } + } + } + Record::ToolStarted { op, call_id, name, args, replay, at } => { + if let Some(t) = find(op, &mut turns) { + let short: String = args.chars().take(240).collect(); + t.steps.push(serde_json::json!({"type": "tool", "call_id": call_id, "name": name, "args": short, "replay": replay, "status": "running", "at": at})); + } + } + Record::ToolFinished { op, call_id, status, at } => { + if let Some(t) = find(op, &mut turns) { + if let Some(s) = t.steps.iter_mut().rev().find(|s| s["type"] == "tool" && s["call_id"] == *call_id) { + s["status"] = serde_json::json!(status); + s["end_at"] = serde_json::json!(at); + } + } + } + Record::QueueEnqueued { op, id, text, at } => { + // The turn it was typed into; a log without that turn (cut short) lands + // in the newest one — the phone only needs to show it somewhere in order. + let i = op.as_ref().and_then(|o| turns.iter().rposition(|t| t.op == *o)).or_else(|| turns.len().checked_sub(1)); + if let Some(i) = i { + turns[i].steps.push(serde_json::json!({"type": "queue", "id": id, "text": text, "at": at, "state": "waiting"})); + } + } + Record::QueueConsumed { id } | Record::QueueCancelled { id } => { + let state = if matches!(r, Record::QueueConsumed { .. }) { "consumed" } else { "cancelled" }; + for t in turns.iter_mut().rev() { + if let Some(s) = t.steps.iter_mut().rev().find(|s| s["id"] == *id) { + s["state"] = serde_json::json!(state); + break; + } + } + } + Record::AbortRequested { op } => { + if let Some(t) = find(op, &mut turns) { + t.steps.push(serde_json::json!({"type": "abort"})); + } + } + Record::OperationFinished { op, outcome } => { + if let Some(t) = find(op, &mut turns) { + t.outcome = Some(match outcome { + Outcome::Completed => "completed", + Outcome::Aborted => "aborted", + Outcome::Failed => "failed", + }); + } + } + } + } + let steps = |t: &Turn| -> Vec { t.steps.iter().map(|s| { let mut v = s.clone(); v.as_object_mut().map(|o| o.remove("call_id")); v }).collect() }; + let keep = turns.len().min(20); + let turns: Vec = turns[turns.len() - keep..] + .iter() + .map(|t| { + serde_json::json!({"op": t.op, "at": t.at, "prompt": t.prompt, "outcome": t.outcome, "steps": steps(t)}) + }) + .collect(); + serde_json::json!({"turns": turns}) + } + /// A turn ended: its last line, then the file keeps only the typed messages still waiting. pub fn finish(&self, op: &str, outcome: Outcome) { let _g = WRITE.lock().unwrap_or_else(|e| e.into_inner()); @@ -223,7 +335,7 @@ pub fn reduce(recs: &[Record]) -> Reduced { let mine = |o: &Option, op: &str| o.as_ref().map(|o| o.op == op).unwrap_or(false); match r { Record::OperationStarted { op, prompt, .. } => open = Some(OpenOp { op: op.clone(), prompt: prompt.clone(), aborted: false, started: vec![] }), - Record::ToolStarted { op, call_id, name, args, replay } if mine(&open, op) => { + Record::ToolStarted { op, call_id, name, args, replay, .. } if mine(&open, op) => { if let Some(o) = open.as_mut() { o.started.retain(|s| &s.call_id != call_id); o.started.push(Started { call_id: call_id.clone(), name: name.clone(), args: args.clone(), replay: *replay, finished: false }); @@ -494,6 +606,38 @@ mod tests { OpLog::at(p) } + #[test] + fn the_timeline_reads_like_a_story() { + use serde_json::json; + let recs = vec![ + Record::OperationStarted { op: "t1".into(), at: 100, source_leaf: 0, prompt: Some(Prompt { at: 99, text: "幫我叫車".into() }) }, + Record::StepAttempt { op: "t1".into(), step: "assistant".into(), attempt: 1 }, + Record::StepAttempt { op: "t1".into(), step: "assistant".into(), attempt: 2 }, + Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "computer".into(), args: "{\"action\":\"click\"}".into(), replay: Replay::Never, at: 110 }, + Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "快一點".into(), at: 111 }, + Record::ToolFinished { op: "t1".into(), call_id: "c1".into(), status: "done".into(), at: 120 }, + Record::QueueConsumed { id: "q1".into() }, + Record::OperationFinished { op: "t1".into(), outcome: Outcome::Completed }, + // A second turn, running: no finish; its tool has no end. + Record::OperationStarted { op: "t2".into(), at: 200, source_leaf: 100, prompt: None }, + Record::ToolStarted { op: "t2".into(), call_id: "c9".into(), name: "run_cmd".into(), args: "{}".into(), replay: Replay::Safe, at: 210 }, + ]; + let v = OpLog::timeline(&recs); + assert_eq!(v["turns"].as_array().unwrap().len(), 2); + let t1 = &v["turns"][0]; + assert_eq!(t1["prompt"], "幫我叫車"); + assert_eq!(t1["outcome"], "completed"); + // (Two model attempts are one line, counted.) + assert_eq!(t1["steps"][0], json!({"type": "model", "step": "assistant", "attempts": 2})); + assert_eq!(t1["steps"][1]["name"], "computer"); + assert_eq!(t1["steps"][1]["status"], "done", "the finished tool shows its status"); + assert_eq!(t1["steps"][1].get("call_id"), None, "call ids are for matching, not for people"); + assert_eq!(t1["steps"][2]["state"], "consumed"); + let t2 = &v["turns"][1]; + assert_eq!(t2["outcome"], json!(null), "a turn without its last line has no outcome"); + assert_eq!(t2["steps"][0]["status"], "running", "what is running now reads as running"); + } + #[test] fn a_finished_turn_leaves_only_waiting_messages() { let log = scratch("finish"); @@ -524,14 +668,14 @@ mod tests { let recs = vec![ Record::OperationStarted { op: "t0".into(), at: 1, source_leaf: 1, prompt: None }, Record::OperationStarted { op: "t1".into(), at: 2, source_leaf: 2, prompt: Some(Prompt { at: 2, text: "傳簡訊".into() }) }, - Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "list_dir".into(), args: "{}".into(), replay: Replay::Safe }, - Record::ToolFinished { op: "t1".into(), call_id: "c1".into(), status: "done".into() }, - Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: "{}".into(), replay: Replay::Never }, + Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "list_dir".into(), args: "{}".into(), replay: Replay::Safe, at: 0 }, + Record::ToolFinished { op: "t1".into(), call_id: "c1".into(), status: "done".into(), at: 0 }, + Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: "{}".into(), replay: Replay::Never, at: 0 }, Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "等等".into(), at: 5 }, Record::QueueEnqueued { op: Some("t1".into()), id: "q2".into(), text: "算了".into(), at: 6 }, Record::QueueCancelled { id: "q2".into() }, // A stale line for a turn that isn't the open one changes nothing. - Record::ToolFinished { op: "t0".into(), call_id: "c2".into(), status: "done".into() }, + Record::ToolFinished { op: "t0".into(), call_id: "c2".into(), status: "done".into(), at: 0 }, ]; let r = reduce(&recs); let o = r.open.unwrap(); @@ -586,8 +730,8 @@ mod tests { b.repair_interrupted(); let log = OpLog::in_dir(&d, 77); log.append(&Record::OperationStarted { op: "t1".into(), at: 10, source_leaf: 10, prompt: Some(Prompt { at: 10, text: "先看資料夾再傳簡訊給媽媽".into() }) }); - log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "list_dir".into(), args: "{}".into(), replay: Replay::Safe }); - log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), replay: Replay::Never }); + log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c1".into(), name: "list_dir".into(), args: "{}".into(), replay: Replay::Safe, at: 0 }); + log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), replay: Replay::Never, at: 0 }); log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "記得說謝謝".into(), at: 12 }); // And a log for a bot that was deleted. OpLog::in_dir(&d, 999).append(&Record::OperationStarted { op: "x".into(), at: 1, source_leaf: 1, prompt: None }); @@ -619,7 +763,7 @@ mod tests { let before = st.bots[0].messages.len(); let log = OpLog::in_dir(&d, 77); log.append(&Record::OperationStarted { op: "t1".into(), at: 10, source_leaf: 10, prompt: Some(Prompt { at: 10, text: "先看資料夾再傳簡訊給媽媽".into() }) }); - log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), replay: Replay::Never }); + log.append(&Record::ToolStarted { op: "t1".into(), call_id: "c2".into(), name: "send_sms".into(), args: r#"{"to":"媽媽","text":"晚點到"}"#.into(), replay: Replay::Never, at: 0 }); log.append(&Record::QueueEnqueued { op: Some("t1".into()), id: "q1".into(), text: "記得說謝謝".into(), at: 12 }); let again = recover_in(&d, &mut st); assert!(!again.changed); diff --git a/src/remote.rs b/src/remote.rs index 2694e13..8896692 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -432,6 +432,11 @@ pub fn handle(req: Req, via_relay: bool) -> Resp { // What the desktop shows beside the chat and in 設定, made when asked (never keys, tokens // or the vault's secrets), and everything else it can do (api/act/). ("GET", ["api", "bots", id, "detail"]) => phone_act("detail", json!({"bot": id.parse::().unwrap_or(0)}), &role), + // The audit timeline of this bot's work log (owner/admin; the phone's 稽核時間軸). + ("GET", ["api", "bots", id, "audit"]) => { + let recs = crate::oplog::OpLog::for_bot(id.parse().unwrap_or(0)).read(); + Resp::json(&crate::oplog::OpLog::timeline(&recs)) + } ("GET", ["api", "settings"]) => phone_act("settings", json!({}), &role), ("GET", ["api", "box", "thumb"]) => box_thumb(), ("POST", ["api", "act", action]) => phone_act(action, req.json(), &role),