feat: add sub-agent activity WebUI with streamed run transcripts

Restructure the WebUI around sub-agent activity: move definition management into Settings, turn the agents page into a live activity monitor with a read-only detail view, and slim the tasks page to scheduled jobs only. Persist incrementally streamed per-run transcripts in a new agent_run_messages table (schema v9, version-gated drops preserve v8 run history) and expose them via GET /api/agent-runs/{id}.
This commit is contained in:
xiaoxixi 2026-08-13 18:07:56 +08:00
parent b558a0a99b
commit b2574dc7af
12 changed files with 1090 additions and 408 deletions

View File

@ -344,6 +344,10 @@ pub struct AgentLoop {
context_window: usize, context_window: usize,
input_types: Vec<String>, input_types: Vec<String>,
media_registry: MediaHandlerRegistry, media_registry: MediaHandlerRegistry,
/// Optional sink receiving a clone of every message appended to
/// `emitted_messages` during `process_inner`. Sub-agent runs use it to
/// persist an incremental transcript; ordinary Turns leave it `None`.
transcript_sink: Option<tokio::sync::mpsc::UnboundedSender<ChatMessage>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AgentProcessResult { pub struct AgentProcessResult {
@ -402,6 +406,7 @@ impl AgentLoop {
model_name, model_name,
input_types, input_types,
media_registry: MediaHandlerRegistry::with_defaults(), media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
}) })
} }
@ -427,6 +432,7 @@ impl AgentLoop {
model_name, model_name,
input_types, input_types,
media_registry: MediaHandlerRegistry::with_defaults(), media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
}) })
} }
@ -448,6 +454,7 @@ impl AgentLoop {
model_name, model_name,
input_types, input_types,
media_registry: MediaHandlerRegistry::with_defaults(), media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
} }
} }
@ -470,6 +477,7 @@ impl AgentLoop {
model_name, model_name,
input_types, input_types,
media_registry: MediaHandlerRegistry::with_defaults(), media_registry: MediaHandlerRegistry::with_defaults(),
transcript_sink: None,
} }
} }
@ -491,6 +499,26 @@ impl AgentLoop {
self self
} }
/// Attach a transcript sink that receives a clone of every message this
/// loop appends to `emitted_messages`. Used by sub-agent runs to persist
/// an incremental transcript; ordinary Turns leave it unset.
pub fn with_transcript_sink(
mut self,
sink: tokio::sync::mpsc::UnboundedSender<ChatMessage>,
) -> Self {
self.transcript_sink = Some(sink);
self
}
/// Forward a message to the transcript sink, if one is installed. The
/// sink is unbounded and the receiver outlives this loop, so send failures
/// are impossible in practice; ignore them defensively.
fn forward_to_transcript_sink(&self, message: &ChatMessage) {
if let Some(sink) = &self.transcript_sink {
let _ = sink.send(message.clone());
}
}
/// Preemptive trim: truncate old tool results in-place when history is /// Preemptive trim: truncate old tool results in-place when history is
/// approaching the context window limit. Old results (outside of `keep_recent` /// approaching the context window limit. Old results (outside of `keep_recent`
/// zone) are replaced with a short placeholder; recent results are truncated /// zone) are replaced with a short placeholder; recent results are truncated
@ -676,6 +704,7 @@ impl AgentLoop {
/// user messages: the client renders the durable Signal projection, not /// user messages: the client renders the durable Signal projection, not
/// a user bubble, while the model still sees the envelope. /// a user bubble, while the model still sees the envelope.
fn append_steering_messages( fn append_steering_messages(
&self,
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
emitted_messages: &mut Vec<ChatMessage>, emitted_messages: &mut Vec<ChatMessage>,
consumed_steering: &mut Vec<TurnInput>, consumed_steering: &mut Vec<TurnInput>,
@ -689,7 +718,8 @@ impl AgentLoop {
.into_chat_message(turn.turn_id.clone(), iteration); .into_chat_message(turn.turn_id.clone(), iteration);
consumed_steering.push(input); consumed_steering.push(input);
messages.push(message.clone()); messages.push(message.clone());
emitted_messages.push(message); emitted_messages.push(message.clone());
self.forward_to_transcript_sink(&message);
} }
} }
@ -953,8 +983,9 @@ impl AgentLoop {
}; };
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false); Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
messages.push(assistant_message.clone()); messages.push(assistant_message.clone());
emitted_messages.push(assistant_message); emitted_messages.push(assistant_message.clone());
Self::append_steering_messages( self.forward_to_transcript_sink(&assistant_message);
self.append_steering_messages(
&mut messages, &mut messages,
&mut emitted_messages, &mut emitted_messages,
&mut consumed_steering, &mut consumed_steering,
@ -968,6 +999,7 @@ impl AgentLoop {
attach_reply_media(&mut assistant_message, &reply_media_refs); attach_reply_media(&mut assistant_message, &reply_media_refs);
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true); Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
crate::observability::metrics::global_metrics().record_turn( crate::observability::metrics::global_metrics().record_turn(
Some(&accumulated_usage), Some(&accumulated_usage),
turn_start.elapsed().as_millis() as u64, turn_start.elapsed().as_millis() as u64,
@ -1014,7 +1046,8 @@ impl AgentLoop {
assistant_message.provider_state = response.provider_state; assistant_message.provider_state = response.provider_state;
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false); Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
messages.push(assistant_message.clone()); messages.push(assistant_message.clone());
emitted_messages.push(assistant_message); emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
// Execute tools and add results to messages // Execute tools and add results to messages
let tool_results = match self let tool_results = match self
@ -1069,7 +1102,8 @@ impl AgentLoop {
); );
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false); Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message); emitted_messages.push(tool_message.clone());
self.forward_to_transcript_sink(&tool_message);
} }
LoopDetectionResult::Ok => { LoopDetectionResult::Ok => {
let mut tool_message = ChatMessage::tool_with_media( let mut tool_message = ChatMessage::tool_with_media(
@ -1080,7 +1114,8 @@ impl AgentLoop {
); );
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false); Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message); emitted_messages.push(tool_message.clone());
self.forward_to_transcript_sink(&tool_message);
} }
} }
} }
@ -1098,7 +1133,7 @@ impl AgentLoop {
let Some(turn_context) = turn.as_ref() else { let Some(turn_context) = turn.as_ref() else {
unreachable!("steering messages require a turn context"); unreachable!("steering messages require a turn context");
}; };
Self::append_steering_messages( self.append_steering_messages(
&mut messages, &mut messages,
&mut emitted_messages, &mut emitted_messages,
&mut consumed_steering, &mut consumed_steering,
@ -1172,6 +1207,7 @@ impl AgentLoop {
true, true,
); );
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
self.forward_to_transcript_sink(&assistant_message);
crate::observability::metrics::global_metrics().record_turn( crate::observability::metrics::global_metrics().record_turn(
Some(&accumulated_usage), Some(&accumulated_usage),
turn_start.elapsed().as_millis() as u64, turn_start.elapsed().as_millis() as u64,
@ -1211,6 +1247,7 @@ impl AgentLoop {
attach_reply_media(&mut final_message, &reply_media_refs); attach_reply_media(&mut final_message, &reply_media_refs);
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true); Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
emitted_messages.push(final_message.clone()); emitted_messages.push(final_message.clone());
self.forward_to_transcript_sink(&final_message);
let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage); let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
crate::observability::metrics::global_metrics() crate::observability::metrics::global_metrics()
.record_turn(turn_usage, turn_start.elapsed().as_millis() as u64); .record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);

View File

@ -431,9 +431,12 @@ impl SubAgentManager {
let mut effective_config = config.clone(); let mut effective_config = config.clone();
effective_config.max_iterations = Some(resolved.max_iterations); effective_config.max_iterations = Some(resolved.max_iterations);
let max_result_chars = resolved.max_result_chars; let max_result_chars = resolved.max_result_chars;
let (transcript_tx, transcript_rx) = tokio::sync::mpsc::unbounded_channel();
let agent = self let agent = self
.build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config) .build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config)
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?; .map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?
.with_transcript_sink(transcript_tx);
let history = vec![ let history = vec![
ChatMessage::system(system_prompt), ChatMessage::system(system_prompt),
@ -443,29 +446,32 @@ impl SubAgentManager {
let start = Instant::now(); let start = Instant::now();
let tool_context = resolved.tool_context; let tool_context = resolved.tool_context;
let result = tokio::select! { let writer = self.spawn_transcript_writer(task_id, transcript_rx);
let outcome = tokio::select! {
result = tokio::time::timeout( result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs), std::time::Duration::from_secs(timeout_secs),
agent.process_with_context(history, tool_context.clone()), agent.process_with_context(history, tool_context.clone()),
) => result, ) => match result {
_ = tool_context.cancellation.cancelled() => { Ok(inner) => ExecutionOutcome::Finished(Box::new(inner)),
return Ok(SubAgentResult { Err(_elapsed) => ExecutionOutcome::TimedOut,
task_id: task_id.to_string(), },
content: String::new(), _ = tool_context.cancellation.cancelled() => ExecutionOutcome::Cancelled,
content_truncated: false,
full_content: String::new(),
status: TaskStatus::Cancelled,
tool_calls_count: 0,
iterations: 0,
duration_ms: start.elapsed().as_millis() as u64,
});
}
}; };
let duration_ms = start.elapsed().as_millis() as u64; let duration_ms = start.elapsed().as_millis() as u64;
Ok(match result { // Drop the agent (which owns the transcript sender) so the writer can
Ok(Ok(agent_result)) => { // drain, then await the writer before the caller's terminal commit so
// the persisted transcript is complete first.
drop(agent);
if let Err(error) = writer.await {
tracing::warn!(run_id = task_id, error = %error, "transcript writer failed");
}
Ok(match outcome {
ExecutionOutcome::Finished(result) => match *result {
Ok(agent_result) => {
let (content, truncated) = truncate_sub_agent_result_at( let (content, truncated) = truncate_sub_agent_result_at(
&agent_result.final_response.content, &agent_result.final_response.content,
max_result_chars, max_result_chars,
@ -491,7 +497,7 @@ impl SubAgentManager {
duration_ms, duration_ms,
} }
} }
Ok(Err(error)) => SubAgentResult { Err(error) => SubAgentResult {
task_id: task_id.to_string(), task_id: task_id.to_string(),
content: String::new(), content: String::new(),
content_truncated: false, content_truncated: false,
@ -501,7 +507,8 @@ impl SubAgentManager {
iterations: 0, iterations: 0,
duration_ms, duration_ms,
}, },
Err(_elapsed) => SubAgentResult { },
ExecutionOutcome::TimedOut => SubAgentResult {
task_id: task_id.to_string(), task_id: task_id.to_string(),
content: String::new(), content: String::new(),
content_truncated: false, content_truncated: false,
@ -511,8 +518,64 @@ impl SubAgentManager {
iterations: 0, iterations: 0,
duration_ms, duration_ms,
}, },
ExecutionOutcome::Cancelled => SubAgentResult {
task_id: task_id.to_string(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: TaskStatus::Cancelled,
tool_calls_count: 0,
iterations: 0,
duration_ms,
},
}) })
} }
/// Spawn a task that drains the transcript channel into
/// `agent_run_messages`, assigning a monotonically increasing `seq` and
/// stripping `provider_state` (which must never be persisted or exposed).
/// With no storage the writer becomes a drain-and-discard no-op.
fn spawn_transcript_writer(
&self,
run_id: &str,
receiver: tokio::sync::mpsc::UnboundedReceiver<ChatMessage>,
) -> tokio::task::JoinHandle<()> {
let storage = self.storage.clone();
let run_id = run_id.to_string();
tokio::spawn(async move {
let Some(storage) = storage else {
let mut receiver = receiver;
while receiver.recv().await.is_some() {}
return;
};
let mut seq = 0i64;
let mut receiver = receiver;
while let Some(mut message) = receiver.recv().await {
message.provider_state = None;
let now = chrono::Utc::now().timestamp_millis();
if let Err(error) = storage
.append_agent_run_message(&run_id, seq, &message, now)
.await
{
tracing::warn!(
run_id = %run_id,
seq,
error = %error,
"failed to append transcript message"
);
}
seq += 1;
}
})
}
}
/// Intermediate outcome of a resolved run, unified so the transcript writer
/// is awaited on every exit path before `execute_resolved` returns.
enum ExecutionOutcome {
Finished(Box<Result<crate::agent::AgentProcessResult, AgentError>>),
TimedOut,
Cancelled,
} }
fn terminal_status_from_error(error: AgentError) -> TaskStatus { fn terminal_status_from_error(error: AgentError) -> TaskStatus {

View File

@ -1215,9 +1215,20 @@ pub async fn get_agent_run(
let Some(run) = run else { let Some(run) = run else {
return Err(ApiError::not_found(format!("run {id} not found"))); return Err(ApiError::not_found(format!("run {id} not found")));
}; };
Ok(Json( let session_id = run.root_session_id.clone();
json!({ "run": crate::protocol::AgentRunView::from_record(&run, 100_000) }), let transcript = state
)) .storage
.list_agent_run_messages(&id, 10_000)
.await
.map_err(ApiError::internal)?
.into_iter()
.map(crate::protocol::AgentTranscriptMessage::from)
.collect::<Vec<_>>();
Ok(Json(json!({
"run": crate::protocol::AgentRunView::from_record(&run, 100_000),
"session_id": session_id,
"transcript": transcript,
})))
} }
pub async fn get_agent_run_events( pub async fn get_agent_run_events(

View File

@ -98,6 +98,48 @@ pub struct AgentEventView {
pub created_at: i64, pub created_at: i64,
} }
/// Serialized transcript message for a single Agent run, exposed through the
/// HTTP detail endpoint. `reasoning_content` is client-visible here but
/// `provider_state` is never included.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTranscriptMessage {
pub id: String,
pub run_id: String,
pub seq: i64,
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<crate::providers::ToolCall>>,
pub created_at: i64,
}
impl From<crate::storage::agent_run::AgentRunMessageRecord> for AgentTranscriptMessage {
fn from(record: crate::storage::agent_run::AgentRunMessageRecord) -> Self {
let tool_calls = record
.tool_calls_json
.as_deref()
.and_then(|json| serde_json::from_str(json).ok());
Self {
id: record.id,
run_id: record.run_id,
seq: record.seq,
role: record.role,
content: record.content,
reasoning_content: record.reasoning_content,
tool_call_id: record.tool_call_id,
tool_name: record.tool_name,
tool_calls,
created_at: record.created_at,
}
}
}
impl AgentRunView { impl AgentRunView {
pub fn from_record( pub fn from_record(
record: &crate::storage::agent_run::AgentRunRecord, record: &crate::storage::agent_run::AgentRunRecord,

View File

@ -61,6 +61,22 @@ pub const AGENT_SCHEMA_STATEMENTS: &[&str] = &[
"CREATE INDEX IF NOT EXISTS idx_agent_runs_parent ON agent_runs(parent_run_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_parent ON agent_runs(parent_run_id, created_at)",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_recovery ON agent_runs(runtime_generation, status, deadline_at)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_recovery ON agent_runs(runtime_generation, status, deadline_at)",
r#" r#"
CREATE TABLE IF NOT EXISTS agent_run_messages (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
reasoning_content TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_calls_json TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE
)
"#,
"CREATE INDEX IF NOT EXISTS idx_agent_run_messages_run_seq ON agent_run_messages(run_id, seq)",
r#"
CREATE TABLE IF NOT EXISTS agent_session_state ( CREATE TABLE IF NOT EXISTS agent_session_state (
root_session_id TEXT PRIMARY KEY, root_session_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 0,
@ -226,6 +242,23 @@ pub struct AgentRunRecord {
pub updated_at: i64, pub updated_at: i64,
} }
/// Raw persisted transcript row for an Agent run. Incrementally appended by
/// the run's transcript writer; `tool_calls_json` is stored verbatim and only
/// parsed into `providers::ToolCall` at the protocol boundary.
#[derive(Debug, Clone)]
pub struct AgentRunMessageRecord {
pub id: String,
pub run_id: String,
pub seq: i64,
pub role: String,
pub content: String,
pub reasoning_content: Option<String>,
pub tool_call_id: Option<String>,
pub tool_name: Option<String>,
pub tool_calls_json: Option<String>,
pub created_at: i64,
}
/// One run to admit inside `accept_agent_runs`. /// One run to admit inside `accept_agent_runs`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NewAgentRun { pub struct NewAgentRun {
@ -377,6 +410,23 @@ fn run_record_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<AgentRunRecord,
}) })
} }
fn agent_run_message_record_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> Result<AgentRunMessageRecord, StorageError> {
Ok(AgentRunMessageRecord {
id: row.get("id"),
run_id: row.get("run_id"),
seq: row.get("seq"),
role: row.get("role"),
content: row.get("content"),
reasoning_content: row.get("reasoning_content"),
tool_call_id: row.get("tool_call_id"),
tool_name: row.get("tool_name"),
tool_calls_json: row.get("tool_calls_json"),
created_at: row.get("created_at"),
})
}
impl super::Storage { impl super::Storage {
/// Admit a batch of runs in one transaction, claiming any referenced /// Admit a batch of runs in one transaction, claiming any referenced
/// plan items atomically. If any plan item was already taken the whole /// plan items atomically. If any plan item was already taken the whole
@ -497,6 +547,62 @@ impl super::Storage {
} }
} }
/// Append one transcript message for an Agent run. The writer owns the
/// monotonically increasing `seq`; `provider_state` is expected to have
/// been stripped by the caller before this is called.
pub async fn append_agent_run_message(
&self,
run_id: &str,
seq: i64,
message: &crate::bus::ChatMessage,
now: i64,
) -> Result<(), StorageError> {
let tool_calls_json = message
.tool_calls
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| StorageError::Migration(format!("serialize tool_calls: {error}")))?;
sqlx::query(
"INSERT INTO agent_run_messages (id, run_id, seq, role, content, \
reasoning_content, tool_call_id, tool_name, tool_calls_json, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(run_id)
.bind(seq)
.bind(&message.role)
.bind(&message.content)
.bind(&message.reasoning_content)
.bind(&message.tool_call_id)
.bind(&message.tool_name)
.bind(tool_calls_json)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
/// List the persisted transcript for a run ordered by `seq`. The
/// transcript is naturally bounded by the run's iteration budget; the
/// default `limit` is a generous ceiling, not a pagination contract.
pub async fn list_agent_run_messages(
&self,
run_id: &str,
limit: i64,
) -> Result<Vec<AgentRunMessageRecord>, StorageError> {
let rows = sqlx::query(
"SELECT id, run_id, seq, role, content, reasoning_content, tool_call_id, \
tool_name, tool_calls_json, created_at \
FROM agent_run_messages WHERE run_id = ? ORDER BY seq ASC LIMIT ?",
)
.bind(run_id)
.bind(limit)
.fetch_all(&self.pool)
.await?;
rows.iter().map(agent_run_message_record_from_row).collect()
}
/// List runs for a session ordered by `(created_at DESC, id DESC)`. /// List runs for a session ordered by `(created_at DESC, id DESC)`.
/// The cursor is the pair of the last row the client has seen. /// The cursor is the pair of the last row the client has seen.
pub async fn list_agent_runs( pub async fn list_agent_runs(
@ -1061,17 +1167,18 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn fresh_database_creates_schema_v8_agent_tables() { async fn fresh_database_creates_schema_v9_agent_tables() {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;
let version: i64 = sqlx::query_scalar("PRAGMA user_version") let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool()) .fetch_one(storage.pool())
.await .await
.unwrap(); .unwrap();
assert_eq!(version, 8); assert_eq!(version, 9);
for table in [ for table in [
"agent_runs", "agent_runs",
"agent_session_state", "agent_session_state",
"agent_inbox_events", "agent_inbox_events",
"agent_run_messages",
] { ] {
let exists: i64 = sqlx::query_scalar( let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
@ -1371,4 +1478,47 @@ mod tests {
.unwrap() .unwrap()
); );
} }
#[tokio::test]
async fn transcript_messages_round_trip_in_seq_order() {
let (storage, _dir) = create_test_storage().await;
storage
.accept_agent_runs(AcceptAgentRequest {
runs: vec![new_run("run-1", "exec-1", "cli:test:d1")],
now: 100,
})
.await
.unwrap();
let mut assistant = crate::bus::ChatMessage::assistant_with_tool_calls(
"calling".to_string(),
vec![crate::providers::ToolCall {
id: "call-1".to_string(),
name: "bash".to_string(),
arguments: serde_json::json!({}),
}],
);
assistant.reasoning_content = Some("thinking".to_string());
let tool = crate::bus::ChatMessage::tool("call-1", "bash", "output");
storage
.append_agent_run_message("run-1", 0, &assistant, 200)
.await
.unwrap();
storage
.append_agent_run_message("run-1", 1, &tool, 201)
.await
.unwrap();
let messages = storage.list_agent_run_messages("run-1", 10_000).await.unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].seq, 0);
assert_eq!(messages[0].role, "assistant");
assert_eq!(messages[0].reasoning_content.as_deref(), Some("thinking"));
assert!(messages[0].tool_calls_json.is_some());
assert_eq!(messages[1].seq, 1);
assert_eq!(messages[1].role, "tool");
assert_eq!(messages[1].tool_call_id.as_deref(), Some("call-1"));
assert_eq!(messages[1].tool_name.as_deref(), Some("bash"));
}
} }

View File

@ -18,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
use std::path::Path; use std::path::Path;
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 8; const SCHEMA_VERSION: i64 = 9;
const INSERT_MESSAGE_SQL: &str = r#" const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages ( INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state, id, session_id, seq, role, content, reasoning_content, provider_state,
@ -395,16 +395,25 @@ impl Storage {
} }
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
// Legacy table removed in schema v7; drop it so old databases do not // The legacy drops below are a pre-v8 rebuild concern: the batch
// keep dead rows around. // "group" concept was removed in v8 and the old `background_tasks`
// table in v7. Gate them on `current < 8` so a v8 -> v9 upgrade only
// adds the new transcript table and preserves existing run history.
if current < 8 {
// Legacy table removed in schema v7; drop it so old databases do
// not keep dead rows around.
sqlx::query("DROP TABLE IF EXISTS background_tasks") sqlx::query("DROP TABLE IF EXISTS background_tasks")
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
// Schema v8 removes the batch "group" concept entirely: the // Schema v8 removes the batch "group" concept entirely: the
// `agent_run_groups` table is gone, and the run/inbox tables are // `agent_run_groups` table is gone, and the run/inbox tables are
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns. // rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
// Drop in dependency order (inbox -> runs -> groups) so foreign-key // Drop the transcript table before runs and the remaining tables in
// enforcement never blocks the implicit row delete. // dependency order (messages -> inbox -> runs -> groups) so
// foreign-key enforcement never blocks the implicit row delete.
sqlx::query("DROP TABLE IF EXISTS agent_run_messages")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events") sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@ -414,6 +423,7 @@ impl Storage {
sqlx::query("DROP TABLE IF EXISTS agent_run_groups") sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
}
for (table, column, definition) in [ for (table, column, definition) in [
("messages", "source", "source TEXT"), ("messages", "source", "source TEXT"),
("messages", "reasoning_content", "reasoning_content TEXT"), ("messages", "reasoning_content", "reasoning_content TEXT"),
@ -1885,6 +1895,65 @@ mod tests {
); );
} }
#[tokio::test]
async fn v8_migration_preserves_agent_runs_and_adds_transcript_table() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("v8.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
// The v8 `agent_runs` shape is unchanged in v9: v9 only adds the
// transcript table. Build a v8 database holding a durable run so the
// upgrade must preserve it rather than dropping the table.
sqlx::query(agent_run::AGENT_SCHEMA_STATEMENTS[0])
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO agent_runs (id, root_session_id, caller_agent_id, caller_scope_id, \
agent_id, definition_hash, provider_profile, provider_name, model_id, mode, \
depth, execution_id, task, budget_json, status, runtime_generation, attempt, \
completion_slot_reserved, deadline_at, revision, created_at, updated_at) \
VALUES ('run-1', 'cli:c:d', 'ROOT', 'turn-1', 'researcher', 'hash', 'profile', \
'test', 'model', 'foreground', 1, 'exec-1', 'task', '{}', 'completed', 1, 1, \
0, 1000, 0, 1, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 8")
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
let run = storage.get_agent_run("run-1").await.unwrap();
assert!(
run.is_some(),
"v8 agent run must survive the v9 upgrade without a rebuild"
);
assert_eq!(run.unwrap().status.as_str(), "completed");
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, 9);
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'agent_run_messages'",
)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(exists, 1, "agent_run_messages table must be created");
}
#[tokio::test] #[tokio::test]
async fn test_upsert_and_get_session() { async fn test_upsert_and_get_session() {
let (storage, _dir) = create_test_storage().await; let (storage, _dir) = create_test_storage().await;

View File

@ -21,10 +21,10 @@
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" }, { name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" }, { name: "overview", label: "概览", description: "查看运行状态与系统容量" },
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" }, { name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
{ name: "agents", label: "子代理", description: "管理具名子代理定义" }, { name: "agents", label: "子代理", description: "查看活动中的子代理与历史运行" },
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" }, { name: "logs", label: "日志", description: "检查实时事件与运行记录" },
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" }, { name: "memory", label: "记忆", description: "查找和维护长期记忆" },
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" }, { name: "tasks", label: "任务", description: "管理定时任务" },
{ name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" } { name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" }
]; ];
const icons = { const icons = {
@ -153,7 +153,7 @@
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} /> {:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "overview"}<OverviewPage /> {:else if current === "overview"}<OverviewPage />
{:else if current === "tools"}<ToolsPage /> {:else if current === "tools"}<ToolsPage />
{:else if current === "agents"}<AgentsPage notify={(text, error) => toast.show(text, error)} /> {:else if current === "agents"}<AgentsPage />
{:else}<div class="empty-card">即将上线</div>{/if} {:else}<div class="empty-card">即将上线</div>{/if}
</main> </main>
</div> </div>

View File

@ -43,6 +43,8 @@
<path d="m5.25 7.5 4.75 4.75 4.75-4.75" /> <path d="m5.25 7.5 4.75 4.75 4.75-4.75" />
{:else if name === "panel"} {:else if name === "panel"}
<rect x="2.75" y="3.25" width="14.5" height="13.5" rx="2" /><path d="M12.25 3.25v13.5" /> <rect x="2.75" y="3.25" width="14.5" height="13.5" rx="2" /><path d="M12.25 3.25v13.5" />
{:else if name === "back"}
<path d="M12.5 4.5 6.25 10l6.25 5.5M7 10h6.5" />
{/if} {/if}
</svg> </svg>

View File

@ -0,0 +1,339 @@
<script>
import { onMount } from "svelte";
import { api } from "../api.js";
import Icon from "../Icon.svelte";
import StatusBadge from "../StatusBadge.svelte";
let agents = $state([]);
let options = $state({ providers: [], models: [], tools: [], skills: [] });
let loading = $state(true);
let error = $state("");
let editing = $state(null);
let saving = $state(false);
let { notify } = $props();
const blank = () => ({
id: "",
description: "",
provider: "",
model: "",
token_limit: null,
max_tool_iterations: null,
tools: [],
skills: [],
delegateMode: "default",
delegates: [],
role_prompt: "",
enabled: true,
});
async function load() {
loading = true;
error = "";
try {
const [a, o] = await Promise.all([
api("/api/agents"),
api("/api/agents/options"),
]);
agents = a.agents || [];
options = o;
} catch (caught) {
error = caught.message;
} finally {
loading = false;
}
}
function toggleTool(list, name) {
const i = list.indexOf(name);
if (i >= 0) list.splice(i, 1);
else list.push(name);
}
function startNew() {
editing = blank();
}
function editAgent(agent) {
const delegates = agent.delegates;
let delegateMode = "default";
let list = [];
if (delegates == null) {
delegateMode = "default";
} else if (delegates.includes("*")) {
delegateMode = "any";
} else if (delegates.length === 0) {
delegateMode = "none";
} else {
delegateMode = "list";
list = [...delegates];
}
editing = {
id: agent.id,
description: agent.description || "",
provider: agent.provider || "",
model: agent.model || "",
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: [...(agent.tools || [])],
skills: [...(agent.skills || [])],
delegateMode,
delegates: list,
role_prompt: agent.role_prompt || "",
enabled: agent.enabled !== false,
};
}
function cancelEdit() {
editing = null;
}
function delegateLabel(agent) {
const d = agent.delegates;
if (d == null) return "委托: general-purpose默认";
if (d.includes("*")) return "委托: 任意子代理";
if (d.length === 0) return "不可继续委托";
return `委托: ${d.join(", ")}`;
}
async function save() {
if (!editing.id.trim()) {
notify("请填写 Agent ID", true);
return;
}
if (!editing.description.trim()) {
notify("请填写描述", true);
return;
}
if (!editing.role_prompt.trim()) {
notify("请填写角色正文role", true);
return;
}
if (!editing.provider || !editing.model) {
notify("请选择 provider 和 model", true);
return;
}
saving = true;
try {
const payload = {
id: editing.id,
description: editing.description,
provider: editing.provider || null,
model: editing.model || null,
token_limit: editing.token_limit,
max_tool_iterations: editing.max_tool_iterations,
tools: editing.tools,
skills: editing.skills,
role_prompt: editing.role_prompt,
enabled: editing.enabled,
};
if (editing.delegateMode === "none") payload.delegates = [];
else if (editing.delegateMode === "any") payload.delegates = ["*"];
else if (editing.delegateMode === "list") payload.delegates = editing.delegates;
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
editing = null;
notify("子代理已保存(需重载配置生效)");
await load();
} catch (caught) {
notify(caught.message, true);
} finally {
saving = false;
}
}
async function toggleEnabled(agent) {
try {
await api("/api/agents", {
method: "POST",
body: JSON.stringify({
id: agent.id,
description: agent.description || "",
provider: agent.provider || null,
model: agent.model || null,
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: agent.tools || [],
skills: agent.skills || [],
role_prompt: agent.role_prompt || "",
enabled: !agent.enabled,
}),
});
agent.enabled = !agent.enabled;
notify(agent.enabled ? "已启用" : "已禁用");
} catch (caught) {
notify(caught.message, true);
}
}
async function remove(agent) {
if (!confirm(`确定删除子代理「${agent.id}」吗?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(agent.id)}`, { method: "DELETE" });
notify("已删除");
await load();
} catch (caught) {
notify(caught.message, true);
}
}
function toolDesc(name) {
const tool = options.tools.find((t) => t.name === name);
return tool?.description || "";
}
onMount(load);
</script>
<div class="definitions">
<div class="toolbar">
<div>
<h2 style="margin:0">具名子代理</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
子代理由 <code>~/.picobot/agents/*.md</code> 定义工具、Skill、Provider 与模型在此直接指定。主 Agent 可委托给任意子代理。改动需热重载后生效。
</p>
</div>
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button>
</div>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else if agents.length === 0}
<div class="empty-card">暂无子代理定义</div>
{:else}
<div class="cards">
{#each agents as agent (agent.id)}
<article class="card">
<div class="card-row">
<div>
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3>
<p>{agent.description}</p>
<div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span>
<span>model: {agent.model || "—"}</span>
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if}
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
<span>{delegateLabel(agent)}</span>
</div>
{#if agent.tools?.length}
<div class="tag-row">
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div>
{/if}
</div>
<div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button>
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button>
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{#if editing}
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button>
<div class="modal" role="dialog" aria-label="编辑子代理">
<div class="editor-head">
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后需热重载配置生效</small></div>
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button>
</div>
<div class="agent-form">
<div class="form-row">
<label>ID
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" />
</label>
<label>描述
<input bind:value={editing.description} placeholder="通用目的子代理…" />
</label>
</div>
<div class="form-row">
<label>Provider
<select bind:value={editing.provider}>
<option value="">(选择)</option>
{#each options.providers as p (p)}<option value={p}>{p}</option>{/each}
</select>
</label>
<label>Model
<select bind:value={editing.model}>
<option value="">(选择)</option>
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
</select>
</label>
</div>
<div class="form-row">
<label>token_limit
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
</label>
<label>max_tool_iterations
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
</label>
</div>
<div class="form-label">工具 <small>普通工具可直接启用delegate / emit_signal / agent_task 由运行上下文注入)</small></div>
<div class="tag-row selectable">
{#each options.tools as tool (tool.name)}
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
{/each}
</div>
<div class="form-label">Skills <small>(需要工具集中包含 get_skill</small></div>
<div class="tag-row selectable">
{#each options.skills as skill (skill)}
<button class="tag pick" class:picked={editing.skills.includes(skill)} onclick={() => toggleTool(editing.skills, skill)}>{skill}</button>
{/each}
</div>
<div class="form-label">可继续委托给 <small>(该子代理可再委托给谁)</small></div>
<select bind:value={editing.delegateMode}>
<option value="default">默认:仅 general-purpose</option>
<option value="none">不可继续委托</option>
<option value="any">任意子代理</option>
<option value="list">指定列表</option>
</select>
{#if editing.delegateMode === "list"}
<div class="tag-row selectable">
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
{/each}
</div>
{/if}
<div class="form-label">角色正文</div>
<textarea bind:value={editing.role_prompt} placeholder="# Role&#10;&#10;你是一名…"></textarea>
</div>
<div class="editor-actions">
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="primary" onclick={save} disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
{/if}
</div>
<style>
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); }
.tag-row.selectable .tag { cursor: pointer; user-select: none; }
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
.card-actions { display: flex; align-items: center; gap: 8px; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(920px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.editor-head strong { display: block; font-size: 15px; }
.editor-head small { color: var(--muted); font-size: 11px; }
.agent-form { display: grid; gap: 14px; padding: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
textarea { min-height: 360px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
.form-label small { font-weight: 400; color: var(--muted); }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
@media (max-width: 800px) { .form-row { grid-template-columns: 1fr; } }
</style>

View File

@ -1,41 +1,31 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { api } from "../lib/api.js"; import { api, formatTime } from "../lib/api.js";
import Icon from "../lib/Icon.svelte"; import Icon from "../lib/Icon.svelte";
import StatusBadge from "../lib/StatusBadge.svelte"; import StatusBadge from "../lib/StatusBadge.svelte";
import Markdown from "../lib/Markdown.svelte";
import ToolCallCard from "../lib/ToolCallCard.svelte";
let agents = $state([]); let tasks = $state([]);
let options = $state({ providers: [], models: [], tools: [], skills: [] });
let loading = $state(true); let loading = $state(true);
let error = $state(""); let error = $state("");
let editing = $state(null); let tick = $state(0);
let saving = $state(false);
let { notify } = $props();
const blank = () => ({ let selected = $state(null);
id: "", let detail = $state(null);
description: "", let detailError = $state("");
provider: "", let detailTimer = null;
model: "",
token_limit: null,
max_tool_iterations: null,
tools: [],
skills: [],
delegates: [],
role_prompt: "",
enabled: true,
});
async function load() { const activeStatuses = ["queued", "running", "waiting_children"];
loading = true;
error = ""; function isActive(status) {
return activeStatuses.includes(status);
}
async function loadTasks() {
try { try {
const [a, o] = await Promise.all([ tasks = (await api("/api/tasks?limit=200")).tasks || [];
api("/api/agents"), error = "";
api("/api/agents/options"),
]);
agents = a.agents || [];
options = o;
} catch (caught) { } catch (caught) {
error = caught.message; error = caught.message;
} finally { } finally {
@ -43,167 +33,240 @@
} }
} }
function toggleTool(list, name) { function stopDetailPoll() {
const i = list.indexOf(name); if (detailTimer) {
if (i >= 0) list.splice(i, 1); clearInterval(detailTimer);
else list.push(name); detailTimer = null;
}
} }
function startNew() { async function loadDetail() {
editing = blank(); if (!selected) return;
}
function editAgent(agent) {
editing = {
id: agent.id,
description: agent.description || "",
provider: agent.provider || "",
model: agent.model || "",
token_limit: agent.token_limit ?? null,
max_tool_iterations: agent.max_tool_iterations ?? null,
tools: [...(agent.tools || [])],
skills: [...(agent.skills || [])],
delegates: [...(agent.delegates || [])],
role_prompt: agent.role_prompt || "",
enabled: agent.enabled !== false,
};
}
function cancelEdit() {
editing = null;
}
async function save() {
if (!editing.id.trim()) {
notify("请填写 Agent ID", true);
return;
}
if (!editing.description.trim()) {
notify("请填写描述", true);
return;
}
if (!editing.role_prompt.trim()) {
notify("请填写角色正文role", true);
return;
}
if (!editing.provider || !editing.model) {
notify("请选择 provider 和 model", true);
return;
}
saving = true;
try { try {
const payload = { const [runRes, eventsRes] = await Promise.all([
id: editing.id, api(`/api/agent-runs/${encodeURIComponent(selected)}`),
description: editing.description, api(`/api/agent-runs/${encodeURIComponent(selected)}/events?limit=200`),
provider: editing.provider || null, ]);
model: editing.model || null, detail = {
token_limit: editing.token_limit, run: runRes.run,
max_tool_iterations: editing.max_tool_iterations, session_id: runRes.session_id,
tools: editing.tools, transcript: runRes.transcript || [],
skills: editing.skills, events: eventsRes.events || [],
delegates: editing.delegates,
role_prompt: editing.role_prompt,
enabled: editing.enabled,
}; };
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) }); detailError = "";
editing = null; if (!isActive(runRes.run.status)) stopDetailPoll();
notify("子代理已保存(需重载配置生效)");
await load();
} catch (caught) { } catch (caught) {
notify(caught.message, true); detailError = caught.message;
} finally {
saving = false;
} }
} }
async function toggleEnabled(agent) { function openDetail(run) {
selected = run.id;
detail = null;
detailError = "";
stopDetailPoll();
loadDetail();
detailTimer = setInterval(loadDetail, 2000);
}
function closeDetail() {
selected = null;
detail = null;
detailError = "";
stopDetailPoll();
}
function humanize(ms) {
const secs = Math.floor(ms / 1000);
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
function elapsed(ts) {
void tick;
if (!ts) return "";
const ms = ts < 1e12 ? ts * 1000 : ts;
const diff = Date.now() - ms;
if (diff < 0) return "0s";
return humanize(diff);
}
function durationBetween(start, end) {
if (!start) return "";
const a = start < 1e12 ? start * 1000 : start;
const b = end ? (end < 1e12 ? end * 1000 : end) : Date.now();
if (b < a) return "";
return humanize(b - a);
}
function promptExcerpt(run) {
return (run.prompt || run.task || "").slice(0, 120);
}
function toolResult(callId) {
return (
detail?.transcript.find(
(message) => message.role === "tool" && message.tool_call_id === callId
) || null
);
}
function signalSummary(event) {
try { try {
await api("/api/agents", { const payload = JSON.parse(event.payload_json);
method: "POST", return payload.summary || payload.status || event.payload_json.slice(0, 200);
body: JSON.stringify({ } catch {
id: agent.id, return event.payload_json.slice(0, 200);
description: agent.description || "", }
provider: agent.provider || null, }
model: agent.model || null,
token_limit: agent.token_limit ?? null, onMount(() => {
max_tool_iterations: agent.max_tool_iterations ?? null, loadTasks();
tools: agent.tools || [], const listTimer = setInterval(loadTasks, 5000);
skills: agent.skills || [], const tickTimer = setInterval(() => (tick += 1), 1000);
delegates: agent.delegates || [], return () => {
role_prompt: agent.role_prompt || "", clearInterval(listTimer);
enabled: !agent.enabled, clearInterval(tickTimer);
}), stopDetailPoll();
};
}); });
agent.enabled = !agent.enabled;
notify(agent.enabled ? "已启用" : "已禁用");
} catch (caught) {
notify(caught.message, true);
}
}
async function remove(agent) {
if (!confirm(`确定删除子代理「${agent.id}」吗?`)) return;
try {
await api(`/api/agents/${encodeURIComponent(agent.id)}`, { method: "DELETE" });
notify("已删除");
await load();
} catch (caught) {
notify(caught.message, true);
}
}
function toolDesc(name) {
const tool = options.tools.find((t) => t.name === name);
return tool?.description || "";
}
onMount(load);
</script> </script>
<section class="page active content-page"> <section class="page active content-page">
{#if selected}
<div class="detail-head">
<button class="secondary" onclick={closeDetail}><Icon name="back" size={15} />返回</button>
{#if detail}
<div class="detail-title">
<span class="mono">{detail.run.agent_id}</span>
<StatusBadge status={detail.run.status} />
</div>
{/if}
</div>
{#if !detail && !detailError}
<div class="loading">加载详情…</div>
{:else if detailError}
<div class="empty-card error-text">{detailError}</div>
{:else if detail}
<div class="detail-body">
<article class="card">
<div class="meta">
<span>agent: <b>{detail.run.agent_id}</b></span>
<span>provider: {detail.run.provider_name}</span>
<span>model: {detail.run.model_id}</span>
<span>mode: {detail.run.mode}</span>
<span>depth: {detail.run.depth}</span>
<span>{detail.run.tool_calls_count} 次工具调用 · {detail.run.iterations}</span>
{#if detail.session_id}<span>session: <span class="mono">{detail.session_id}</span></span>{/if}
{#if detail.run.parent_run_id}<span>parent: <span class="mono">{detail.run.parent_run_id}</span></span>{/if}
<span>开始 {formatTime(detail.run.started_at)}</span>
<span>结束 {formatTime(detail.run.finished_at)}</span>
<span>耗时 {durationBetween(detail.run.started_at, detail.run.finished_at)}</span>
</div>
</article>
{#if detail.events.length}
<div class="agent-event-cards" aria-label="运行事件">
{#each detail.events as event (event.id)}
<article class:warning={event.severity === "warning"} class:critical={event.severity === "critical"} class="agent-event-card">
<span class="agent-event-icon"><Icon name="bot" size={14} /></span>
<div class="agent-event-body">
<div class="agent-event-title">
{event.event_type === "signal" ? `信号 · ${event.severity || "info"}` : `完成 · ${event.status}`}
<span class="agent-event-delivery">{event.delivery === "steer" ? "steer" : "queue"}</span>
</div>
{#if event.event_type === "signal"}
{#if event.payload_json}<p>{signalSummary(event)}</p>{/if}
{:else}
<p class="agent-event-status">{event.status}{event.last_error ? ` · ${event.last_error}` : ""}</p>
{/if}
</div>
</article>
{/each}
</div>
{/if}
<div class="transcript">
{#if detail.run.task}
<div class="message user">
<div class="avatar">任务</div>
<div class="message-content">
<div class="bubble"><Markdown content={detail.run.task} /></div>
</div>
</div>
{/if}
{#each detail.transcript.filter((m) => m.role !== "tool") as message (message.id)}
<div class:assistant={message.role === "assistant"} class="message">
<div class="avatar">{message.role === "assistant" ? "" : message.role}</div>
<div class="message-content">
{#if message.reasoning_content}
<details class="reasoning-block historical">
<summary>思考过程</summary>
<div class="reasoning-content"><Markdown content={message.reasoning_content} /></div>
</details>
{/if}
{#if message.content}<div class="bubble"><Markdown content={message.content} /></div>{/if}
{#if message.tool_calls?.length}
<div class="tool-calls">
{#each message.tool_calls as call (call.id)}
<ToolCallCard {call} result={toolResult(call.id)} />
{/each}
</div>
{/if}
</div>
</div>
{/each}
{#if detail.transcript.length === 0 && !detail.run.task}
<div class="empty-card">无转录</div>
{/if}
</div>
{#if detail.run.error}
<article class="card error-text">{detail.run.error}</article>
{/if}
</div>
{/if}
{:else}
<div class="toolbar"> <div class="toolbar">
<div> <div>
<h2 style="margin:0">具名子代理</h2> <h2 style="margin:0">子代理活动</h2>
<p style="margin:2px 0 0;color:var(--muted);font-size:12px"> <p style="margin:2px 0 0;color:var(--muted);font-size:12px">
子代理由 <code>~/.picobot/agents/*.md</code> 定义工具、Skill、Provider 与模型在此直接指定。改动需热重载后生效。 查看活动中的子代理与历史运行;子代理定义在「配置 → 子代理定义」中管理
</p> </p>
</div> </div>
<button class="primary" onclick={startNew}><Icon name="add" size={16} />新增子代理</button> <button class="secondary" onclick={loadTasks}><Icon name="refresh" size={16} /></button>
</div> </div>
{#if loading} {#if loading}
<div class="loading">加载中…</div> <div class="loading">加载中…</div>
{:else if error} {:else if error}
<div class="empty-card error-text">{error}</div> <div class="empty-card error-text">{error}</div>
{:else if agents.length === 0}
<div class="empty-card">暂无子代理定义</div>
{:else} {:else}
{#if tasks.some((t) => isActive(t.status))}
<h3 class="section-label">活动中</h3>
<div class="cards"> <div class="cards">
{#each agents as agent (agent.id)} {#each tasks.filter((t) => isActive(t.status)) as task (task.id)}
<article class="card"> <article class="card">
<div class="card-row"> <div class="card-row">
<div> <div class="task-main">
<h3>{agent.id} <StatusBadge status={agent.enabled ? "enabled" : "disabled"} /></h3> <div class="run-row">
<p>{agent.description}</p> <span class="pulse"></span>
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{promptExcerpt(task)}</span>
<StatusBadge status={task.status} />
</div>
<div class="meta"> <div class="meta">
<span>provider: {agent.provider || agent.llm_profile || "—"}</span> <span>mode: {task.mode}</span>
<span>model: {agent.model || "—"}</span> <span>depth: {task.depth}</span>
{#if agent.tools?.length}<span>{agent.tools.length} 个工具</span>{/if} <span class="elapsed">已运行 {elapsed(task.started_at || task.created_at)}</span>
{#if agent.skills?.length}<span>{agent.skills.length} 个 Skill</span>{/if}
{#if agent.delegates?.length}<span>委托: {agent.delegates.join(", ")}</span>{/if}
</div> </div>
{#if agent.tools?.length}
<div class="tag-row">
{#each agent.tools as tool (tool)}<span class="tag" title={toolDesc(tool)}>{tool}</span>{/each}
</div>
{/if}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="secondary" onclick={() => editAgent(agent)}><Icon name="more" size={15} />编辑</button> <button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
<button class="switch" data-state={agent.enabled ? "checked" : "unchecked"} aria-label="启用/禁用" onclick={() => toggleEnabled(agent)}>
<span class="switch-thumb" data-state={agent.enabled ? "checked" : "unchecked"}></span>
</button>
<button class="icon-button danger" aria-label="删除" onclick={() => remove(agent)}><Icon name="dismiss" size={16} /></button>
</div> </div>
</div> </div>
</article> </article>
@ -211,96 +274,49 @@
</div> </div>
{/if} {/if}
{#if editing} <h3 class="section-label">历史活动</h3>
<button type="button" class="modal-scrim" onclick={cancelEdit} aria-label="关闭" tabindex="-1"></button> <div class="cards">
<div class="modal" role="dialog" aria-label="编辑子代理"> {#each tasks.filter((t) => !isActive(t.status)) as task (task.id)}
<div class="editor-head"> <article class="card">
<div><strong>{editing.id ? `编辑 ${editing.id}` : "新增子代理"}</strong><small>保存后需热重载配置生效</small></div> <div class="card-row">
<button class="icon-button" aria-label="关闭" onclick={cancelEdit}><Icon name="dismiss" size={18} /></button> <div class="task-main">
<div class="run-row">
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{promptExcerpt(task)}</span>
<StatusBadge status={task.status} />
</div> </div>
<div class="agent-form"> <div class="meta">
<div class="form-row"> <span>{formatTime(task.created_at)}</span>
<label>ID <span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
<input bind:value={editing.id} placeholder="general-purpose" disabled={!!agents.find((a) => a.id === editing.id)} spellcheck="false" /> {#if task.started_at && task.finished_at}<span>耗时 {durationBetween(task.started_at, task.finished_at)}</span>{/if}
</label>
<label>描述
<input bind:value={editing.description} placeholder="通用目的子代理…" />
</label>
</div> </div>
<div class="form-row">
<label>Provider
<select bind:value={editing.provider}>
<option value="">(选择)</option>
{#each options.providers as p (p)}<option value={p}>{p}</option>{/each}
</select>
</label>
<label>Model
<select bind:value={editing.model}>
<option value="">(选择)</option>
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
</select>
</label>
</div> </div>
<div class="form-row"> <div class="card-actions">
<label>token_limit <button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
</label>
<label>max_tool_iterations
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
</label>
</div> </div>
</div>
<div class="form-label">工具 <small>普通工具可直接启用delegate / emit_signal / agent_task 由运行上下文注入)</small></div> </article>
<div class="tag-row selectable"> {:else}
{#each options.tools as tool (tool.name)} <div class="empty-card">暂无历史活动</div>
<button class="tag pick" class:picked={editing.tools.includes(tool.name)} title={tool.description} onclick={() => toggleTool(editing.tools, tool.name)}>{tool.name}</button>
{/each} {/each}
</div> </div>
{/if}
<div class="form-label">Skills <small>(需要工具集中包含 get_skill</small></div>
<div class="tag-row selectable">
{#each options.skills as skill (skill)}
<button class="tag pick" class:picked={editing.skills.includes(skill)} onclick={() => toggleTool(editing.skills, skill)}>{skill}</button>
{/each}
</div>
<div class="form-label">可委托的目标代理 <small>(子代理可继续委托给这些代理)</small></div>
<div class="tag-row selectable">
{#each agents.filter((a) => a.id !== editing.id) as agent (agent.id)}
<button class="tag pick" class:picked={editing.delegates.includes(agent.id)} onclick={() => toggleTool(editing.delegates, agent.id)}>{agent.id}</button>
{/each}
</div>
<div class="form-label">角色正文</div>
<textarea bind:value={editing.role_prompt} placeholder="# Role&#10;&#10;你是一名…"></textarea>
</div>
<div class="editor-actions">
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="primary" onclick={save} disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</div>
</div>
{/if} {/if}
</section> </section>
<style> <style>
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } .detail-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.tag { padding: 2px 8px; border: 1px solid var(--line); border-radius: 4px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; font-family: var(--font-mono); } .detail-title { display: flex; align-items: center; gap: 10px; }
.tag-row.selectable .tag { cursor: pointer; user-select: none; } .detail-title .mono { font-size: 13px; color: var(--text-soft); }
.tag-row.selectable .tag.picked { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); } .detail-body { display: grid; gap: 12px; }
.transcript { display: grid; gap: 4px; }
.transcript .avatar { width: 28px; height: 28px; font-size: 10px; }
.section-label { margin: 18px 0 8px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.task-main { flex: 1; min-width: 0; }
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
.run-prompt { flex: 1 1 200px; min-width: 120px; }
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.card-actions { display: flex; align-items: center; gap: 8px; } .card-actions { display: flex; align-items: center; gap: 8px; }
.icon-button.danger:hover { color: var(--danger); border-color: var(--danger-border); } .mono { font-family: var(--font-mono); }
.modal-scrim { position: fixed; inset: 0; z-index: 40; border: 0; padding: 0; cursor: default; background: rgba(0,0,0,.45); }
.modal { position: fixed; z-index: 41; top: 6vh; left: 50%; transform: translateX(-50%); width: min(720px, 94vw); max-height: 88vh; overflow: auto; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); box-shadow: var(--shadow-16, var(--shadow-8)); }
.editor-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.editor-head strong { display: block; font-size: 15px; }
.editor-head small { color: var(--muted); font-size: 11px; }
.agent-form { display: grid; gap: 14px; padding: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
input, select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--text); background: var(--panel-2); font-size: 13px; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
textarea { min-height: 140px; resize: vertical; font-family: var(--font-mono); line-height: 1.6; }
.form-label { color: var(--muted); font-size: 12px; font-weight: 600; }
.form-label small { font-weight: 400; color: var(--muted); }
.editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--line); }
</style> </style>

View File

@ -3,6 +3,7 @@
import { Tabs } from "bits-ui"; import { Tabs } from "bits-ui";
import { api } from "../lib/api.js"; import { api } from "../lib/api.js";
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte"; import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
import SubAgentDefinitions from "../lib/components/SubAgentDefinitions.svelte";
let { notify } = $props(); let { notify } = $props();
let tab = $state("appearance"); let tab = $state("appearance");
@ -16,6 +17,7 @@
const isConfig = $derived(tab === "config"); const isConfig = $derived(tab === "config");
const isAppearance = $derived(tab === "appearance"); const isAppearance = $derived(tab === "appearance");
const isSubagents = $derived(tab === "subagents");
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观"); const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
const isDirty = $derived(content !== original); const isDirty = $derived(content !== original);
@ -34,7 +36,7 @@
async function load() { async function load() {
loading = true; loading = true;
if (isAppearance) { loading = false; return; } if (isAppearance || isSubagents) { loading = false; return; }
try { try {
if (isConfig) { if (isConfig) {
const result = await api("/api/config"); const result = await api("/api/config");
@ -119,12 +121,16 @@
<div class="settings-grid"> <div class="settings-grid">
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical"> <Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
<Tabs.List class="settings-nav" aria-label="设置分类"> <Tabs.List class="settings-nav" aria-label="设置分类">
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger> <Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger><Tabs.Trigger value="subagents">子代理定义</Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs.Root> </Tabs.Root>
{#if isAppearance} {#if isAppearance}
<AppearanceSettings /> <AppearanceSettings />
{:else if isSubagents}
<div class="subagents-pane">
<SubAgentDefinitions {notify} />
</div>
{:else if isConfig} {:else if isConfig}
<div class="config-layout"> <div class="config-layout">
<div class="editor-card"> <div class="editor-card">
@ -188,6 +194,7 @@
</section> </section>
<style> <style>
.subagents-pane { min-width: 0; }
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; } .config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
.config-sidebar { display: grid; gap: 14px; } .config-sidebar { display: grid; gap: 14px; }
.sidebar-panel { padding: 14px; } .sidebar-panel { padding: 14px; }

View File

@ -1,13 +1,10 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { Tabs } from "bits-ui";
import { api, formatTime } from "../lib/api.js"; import { api, formatTime } from "../lib/api.js";
import StatusBadge from "../lib/StatusBadge.svelte"; import StatusBadge from "../lib/StatusBadge.svelte";
import Icon from "../lib/Icon.svelte"; import Icon from "../lib/Icon.svelte";
let tab = $state("scheduled");
let jobs = $state([]); let jobs = $state([]);
let tasks = $state([]);
let runs = $state({}); let runs = $state({});
let loading = $state(true); let loading = $state(true);
let error = $state(""); let error = $state("");
@ -17,15 +14,11 @@
loading = true; loading = true;
error = ""; error = "";
try { try {
if (tab === "background") {
tasks = (await api("/api/tasks?limit=200")).tasks;
} else {
jobs = (await api("/api/jobs")).jobs; jobs = (await api("/api/jobs")).jobs;
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [ runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
job.id, job.id,
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => []) await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
]))); ])));
}
} catch (caught) { } catch (caught) {
error = caught.message; error = caught.message;
} finally { } finally {
@ -33,11 +26,6 @@
} }
} }
function changeTab(value) {
tab = value;
load();
}
function countdown(ts) { function countdown(ts) {
void tick; void tick;
if (!ts) return "—"; if (!ts) return "—";
@ -52,17 +40,6 @@
return `${Math.floor(hours / 24)} 天后`; return `${Math.floor(hours / 24)} 天后`;
} }
function elapsed(createdAt) {
void tick;
if (!createdAt) return "";
const ms = createdAt < 1e12 ? createdAt * 1000 : createdAt;
const secs = Math.floor((Date.now() - ms) / 1000);
if (secs < 0) return "";
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
function dotColor(status) { function dotColor(status) {
if (status === "completed" || status === "success" || status === "ok") return "var(--signal)"; if (status === "completed" || status === "success" || status === "ok") return "var(--signal)";
if (status === "timeout") return "var(--accent)"; if (status === "timeout") return "var(--accent)";
@ -79,40 +56,15 @@
<section class="page active content-page"> <section class="page active content-page">
<div class="toolbar"> <div class="toolbar">
<Tabs.Root value={tab} onValueChange={changeTab}> <div>
<Tabs.List class="tabs" aria-label="任务类型"> <h2 style="margin:0">定时任务</h2>
<Tabs.Trigger value="scheduled">定时任务</Tabs.Trigger> <p style="margin:2px 0 0;color:var(--muted);font-size:12px">管理定时任务与巡检;后台子代理运行请到「子代理」页面查看。</p>
<Tabs.Trigger value="background">后台任务</Tabs.Trigger> </div>
</Tabs.List>
</Tabs.Root>
<button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button> <button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button>
</div> </div>
<div class="cards"> <div class="cards">
{#if loading}<div class="loading">加载中…</div> {#if loading}<div class="loading">加载中…</div>
{:else if error}<div class="empty-card error-text">{error}</div> {:else if error}<div class="empty-card error-text">{error}</div>
{:else if tab === "background"}
{#each tasks as task (task.id)}
<article class="card">
<div class="card-row">
<div class="task-main">
<div class="run-row">
<span class="pulse" class:visible={task.status === "running"}></span>
<span class="agent-tag">{task.agent_id || "general"}</span>
<span class="run-prompt">{task.prompt?.slice(0, 120) || ""}</span>
<StatusBadge status={task.status} />
</div>
<div class="meta">
<span>{task.session_id}</span>
<span>{formatTime(task.created_at)}</span>
{#if task.status === "running"}<span class="elapsed">{elapsed(task.created_at)}</span>{/if}
<span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
</div>
</div>
</div>
{#if task.result}<div class="details"><p>{task.result.slice(0, 300)}</p></div>{/if}
{#if task.error}<p class="error-text">{task.error.slice(0, 200)}</p>{/if}
</article>
{:else}<div class="empty-card">暂无后台任务</div>{/each}
{:else} {:else}
{#each jobs as job (job.id)} {#each jobs as job (job.id)}
<article class="card"> <article class="card">
@ -147,11 +99,5 @@
<style> <style>
.cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; } .cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; } .status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.task-main { flex: 1; min-width: 0; }
.run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .run-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.agent-tag { font-size: 11px; color: var(--accent); background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 6px; font-family: var(--font-mono); }
.run-prompt { flex: 1 1 200px; min-width: 120px; }
.pulse { display: none; }
.pulse.visible { display: inline-block; }
</style> </style>