Audit timeline: what this turn did, on the phone (S5)

- 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/<id>/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
This commit is contained in:
AnyBot Dev 2026-11-09 17:58:56 +08:00
parent f2bf838e6d
commit f4c135a98b
4 changed files with 187 additions and 14 deletions

View File

@ -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) : ""}<div class="mid"><div class="k">${esc(o.k)}</div>${o.v ? `<div class="v">${esc(o.v)}</div>` : ""}</div>${o.r != null ? `<div class="r">${esc(o.r)}</div>` : ""}${o.sw != null ? `<label class="sw"><input type="checkbox" ${o.sw ? "checked" : ""} ${o.swdata || ""} ${o.disabled ? "disabled" : ""}><i></i></label>` : ""}${o.tap && o.chev !== false ? `<span class="chev">${ic("chevron-right", 16)}</span>` : ""}</${o.tap ? "button" : "div"}>`;
const group = (title, inner, extra = "") => `${title ? `<div class="gt">${esc(title)}${extra}</div>` : ""}<div class="group">${inner}</div>`;
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 = `<div class="hero">${face(b, "lg")}<div class="n">${esc(b.name)}</div>${b.description ? `<div class="d">${esc(b.description)}</div>` : ""}</div>`;
h += `<div class="tabs">${tabs.map(([k, n]) => `<button data-tab="${k}" class="${S.tab === k ? "on" : ""}">${n}</button>`).join("")}</div>`;
@ -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 `<div class="note" style="padding:14px 16px">載入中…</div>`; }
if (!a.turns.length) return `<div class="empty">現在沒有進行中的工作。稽核時間軸記的是**這回合**做了什麼:每次問模型、每個工具的參數與結果、你插入的話、停止。回合結束後只留最近的,聊天記錄本身才是長久的。</div>`;
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("");

View File

@ -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 {

View File

@ -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<serde_json::Value>,
}
// (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<Turn> = 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<serde_json::Value> { 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<serde_json::Value> = 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<OpenOp>, 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);

View File

@ -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/<what>).
("GET", ["api", "bots", id, "detail"]) => phone_act("detail", json!({"bot": id.parse::<u64>().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),