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)}` : ""}${o.tap ? "button" : "div"}>`;
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),