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:
parent
b558a0a99b
commit
b2574dc7af
@ -344,6 +344,10 @@ pub struct AgentLoop {
|
||||
context_window: usize,
|
||||
input_types: Vec<String>,
|
||||
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)]
|
||||
pub struct AgentProcessResult {
|
||||
@ -402,6 +406,7 @@ impl AgentLoop {
|
||||
model_name,
|
||||
input_types,
|
||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||
transcript_sink: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -427,6 +432,7 @@ impl AgentLoop {
|
||||
model_name,
|
||||
input_types,
|
||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||
transcript_sink: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -448,6 +454,7 @@ impl AgentLoop {
|
||||
model_name,
|
||||
input_types,
|
||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||
transcript_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -470,6 +477,7 @@ impl AgentLoop {
|
||||
model_name,
|
||||
input_types,
|
||||
media_registry: MediaHandlerRegistry::with_defaults(),
|
||||
transcript_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -491,6 +499,26 @@ impl AgentLoop {
|
||||
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
|
||||
/// approaching the context window limit. Old results (outside of `keep_recent`
|
||||
/// 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
|
||||
/// a user bubble, while the model still sees the envelope.
|
||||
fn append_steering_messages(
|
||||
&self,
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
emitted_messages: &mut Vec<ChatMessage>,
|
||||
consumed_steering: &mut Vec<TurnInput>,
|
||||
@ -689,7 +718,8 @@ impl AgentLoop {
|
||||
.into_chat_message(turn.turn_id.clone(), iteration);
|
||||
consumed_steering.push(input);
|
||||
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);
|
||||
messages.push(assistant_message.clone());
|
||||
emitted_messages.push(assistant_message);
|
||||
Self::append_steering_messages(
|
||||
emitted_messages.push(assistant_message.clone());
|
||||
self.forward_to_transcript_sink(&assistant_message);
|
||||
self.append_steering_messages(
|
||||
&mut messages,
|
||||
&mut emitted_messages,
|
||||
&mut consumed_steering,
|
||||
@ -968,6 +999,7 @@ impl AgentLoop {
|
||||
attach_reply_media(&mut assistant_message, &reply_media_refs);
|
||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
||||
emitted_messages.push(assistant_message.clone());
|
||||
self.forward_to_transcript_sink(&assistant_message);
|
||||
crate::observability::metrics::global_metrics().record_turn(
|
||||
Some(&accumulated_usage),
|
||||
turn_start.elapsed().as_millis() as u64,
|
||||
@ -1014,7 +1046,8 @@ impl AgentLoop {
|
||||
assistant_message.provider_state = response.provider_state;
|
||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
|
||||
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
|
||||
let tool_results = match self
|
||||
@ -1069,7 +1102,8 @@ impl AgentLoop {
|
||||
);
|
||||
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
|
||||
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 => {
|
||||
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);
|
||||
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 {
|
||||
unreachable!("steering messages require a turn context");
|
||||
};
|
||||
Self::append_steering_messages(
|
||||
self.append_steering_messages(
|
||||
&mut messages,
|
||||
&mut emitted_messages,
|
||||
&mut consumed_steering,
|
||||
@ -1172,6 +1207,7 @@ impl AgentLoop {
|
||||
true,
|
||||
);
|
||||
emitted_messages.push(assistant_message.clone());
|
||||
self.forward_to_transcript_sink(&assistant_message);
|
||||
crate::observability::metrics::global_metrics().record_turn(
|
||||
Some(&accumulated_usage),
|
||||
turn_start.elapsed().as_millis() as u64,
|
||||
@ -1211,6 +1247,7 @@ impl AgentLoop {
|
||||
attach_reply_media(&mut final_message, &reply_media_refs);
|
||||
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
||||
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);
|
||||
crate::observability::metrics::global_metrics()
|
||||
.record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);
|
||||
|
||||
@ -431,9 +431,12 @@ impl SubAgentManager {
|
||||
let mut effective_config = config.clone();
|
||||
effective_config.max_iterations = Some(resolved.max_iterations);
|
||||
let max_result_chars = resolved.max_result_chars;
|
||||
|
||||
let (transcript_tx, transcript_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let agent = self
|
||||
.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![
|
||||
ChatMessage::system(system_prompt),
|
||||
@ -443,65 +446,69 @@ impl SubAgentManager {
|
||||
let start = Instant::now();
|
||||
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(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
agent.process_with_context(history, tool_context.clone()),
|
||||
) => result,
|
||||
_ = tool_context.cancellation.cancelled() => {
|
||||
return Ok(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: start.elapsed().as_millis() as u64,
|
||||
});
|
||||
}
|
||||
) => match result {
|
||||
Ok(inner) => ExecutionOutcome::Finished(Box::new(inner)),
|
||||
Err(_elapsed) => ExecutionOutcome::TimedOut,
|
||||
},
|
||||
_ = tool_context.cancellation.cancelled() => ExecutionOutcome::Cancelled,
|
||||
};
|
||||
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(match result {
|
||||
Ok(Ok(agent_result)) => {
|
||||
let (content, truncated) = truncate_sub_agent_result_at(
|
||||
&agent_result.final_response.content,
|
||||
max_result_chars,
|
||||
);
|
||||
let tool_calls_count = agent_result
|
||||
.emitted_messages
|
||||
.iter()
|
||||
.filter(|m| m.tool_calls.is_some())
|
||||
.count();
|
||||
let iterations = agent_result
|
||||
.emitted_messages
|
||||
.iter()
|
||||
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
|
||||
.count();
|
||||
SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content,
|
||||
content_truncated: truncated,
|
||||
full_content: agent_result.final_response.content,
|
||||
status: TaskStatus::Completed,
|
||||
tool_calls_count,
|
||||
iterations,
|
||||
duration_ms,
|
||||
// Drop the agent (which owns the transcript sender) so the writer can
|
||||
// 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(
|
||||
&agent_result.final_response.content,
|
||||
max_result_chars,
|
||||
);
|
||||
let tool_calls_count = agent_result
|
||||
.emitted_messages
|
||||
.iter()
|
||||
.filter(|m| m.tool_calls.is_some())
|
||||
.count();
|
||||
let iterations = agent_result
|
||||
.emitted_messages
|
||||
.iter()
|
||||
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
|
||||
.count();
|
||||
SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content,
|
||||
content_truncated: truncated,
|
||||
full_content: agent_result.final_response.content,
|
||||
status: TaskStatus::Completed,
|
||||
tool_calls_count,
|
||||
iterations,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: terminal_status_from_error(error),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms,
|
||||
Err(error) => SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
full_content: String::new(),
|
||||
status: terminal_status_from_error(error),
|
||||
tool_calls_count: 0,
|
||||
iterations: 0,
|
||||
duration_ms,
|
||||
},
|
||||
},
|
||||
Err(_elapsed) => SubAgentResult {
|
||||
ExecutionOutcome::TimedOut => SubAgentResult {
|
||||
task_id: task_id.to_string(),
|
||||
content: String::new(),
|
||||
content_truncated: false,
|
||||
@ -511,8 +518,64 @@ impl SubAgentManager {
|
||||
iterations: 0,
|
||||
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 {
|
||||
|
||||
@ -1215,9 +1215,20 @@ pub async fn get_agent_run(
|
||||
let Some(run) = run else {
|
||||
return Err(ApiError::not_found(format!("run {id} not found")));
|
||||
};
|
||||
Ok(Json(
|
||||
json!({ "run": crate::protocol::AgentRunView::from_record(&run, 100_000) }),
|
||||
))
|
||||
let session_id = run.root_session_id.clone();
|
||||
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(
|
||||
|
||||
@ -98,6 +98,48 @@ pub struct AgentEventView {
|
||||
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 {
|
||||
pub fn from_record(
|
||||
record: &crate::storage::agent_run::AgentRunRecord,
|
||||
|
||||
@ -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_recovery ON agent_runs(runtime_generation, status, deadline_at)",
|
||||
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 (
|
||||
root_session_id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
@ -226,6 +242,23 @@ pub struct AgentRunRecord {
|
||||
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`.
|
||||
#[derive(Debug, Clone)]
|
||||
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 {
|
||||
/// Admit a batch of runs in one transaction, claiming any referenced
|
||||
/// 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)`.
|
||||
/// The cursor is the pair of the last row the client has seen.
|
||||
pub async fn list_agent_runs(
|
||||
@ -1061,17 +1167,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
||||
.fetch_one(storage.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(version, 8);
|
||||
assert_eq!(version, 9);
|
||||
for table in [
|
||||
"agent_runs",
|
||||
"agent_session_state",
|
||||
"agent_inbox_events",
|
||||
"agent_run_messages",
|
||||
] {
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
@ -1371,4 +1478,47 @@ mod tests {
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
|
||||
use std::path::Path;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 8;
|
||||
const SCHEMA_VERSION: i64 = 9;
|
||||
const INSERT_MESSAGE_SQL: &str = r#"
|
||||
INSERT INTO messages (
|
||||
id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
@ -395,25 +395,35 @@ impl Storage {
|
||||
}
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// 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")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Schema v8 removes the batch "group" concept entirely: the
|
||||
// `agent_run_groups` table is gone, and the run/inbox tables are
|
||||
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
|
||||
// Drop in dependency order (inbox -> runs -> groups) so foreign-key
|
||||
// enforcement never blocks the implicit row delete.
|
||||
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS agent_runs")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// The legacy drops below are a pre-v8 rebuild concern: the batch
|
||||
// "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")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Schema v8 removes the batch "group" concept entirely: the
|
||||
// `agent_run_groups` table is gone, and the run/inbox tables are
|
||||
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
|
||||
// Drop the transcript table before runs and the remaining tables in
|
||||
// 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")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS agent_runs")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for (table, column, definition) in [
|
||||
("messages", "source", "source 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]
|
||||
async fn test_upsert_and_get_session() {
|
||||
let (storage, _dir) = create_test_storage().await;
|
||||
|
||||
@ -21,10 +21,10 @@
|
||||
{ name: "chat", label: "聊天", description: "与 PicoBot 协作并管理会话" },
|
||||
{ name: "overview", label: "概览", description: "查看运行状态与系统容量" },
|
||||
{ name: "tools", label: "工具", description: "浏览工具、Skills 与 MCP 连接" },
|
||||
{ name: "agents", label: "子代理", description: "管理具名子代理定义" },
|
||||
{ name: "agents", label: "子代理", description: "查看活动中的子代理与历史运行" },
|
||||
{ name: "logs", label: "日志", description: "检查实时事件与运行记录" },
|
||||
{ name: "memory", label: "记忆", description: "查找和维护长期记忆" },
|
||||
{ name: "tasks", label: "任务", description: "跟踪定时任务与后台工作" },
|
||||
{ name: "tasks", label: "任务", description: "管理定时任务" },
|
||||
{ name: "settings", label: "配置", description: "管理 Gateway 与 Agent 配置" }
|
||||
];
|
||||
const icons = {
|
||||
@ -153,7 +153,7 @@
|
||||
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
||||
{:else if current === "overview"}<OverviewPage />
|
||||
{: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}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -43,6 +43,8 @@
|
||||
<path d="m5.25 7.5 4.75 4.75 4.75-4.75" />
|
||||
{: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" />
|
||||
{:else if name === "back"}
|
||||
<path d="M12.5 4.5 6.25 10l6.25 5.5M7 10h6.5" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
|
||||
339
webui/src/lib/components/SubAgentDefinitions.svelte
Normal file
339
webui/src/lib/components/SubAgentDefinitions.svelte
Normal 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 你是一名…"></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>
|
||||
@ -1,41 +1,31 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "../lib/api.js";
|
||||
import { api, formatTime } from "../lib/api.js";
|
||||
import Icon from "../lib/Icon.svelte";
|
||||
import StatusBadge from "../lib/StatusBadge.svelte";
|
||||
import Markdown from "../lib/Markdown.svelte";
|
||||
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
||||
|
||||
let agents = $state([]);
|
||||
let options = $state({ providers: [], models: [], tools: [], skills: [] });
|
||||
let tasks = $state([]);
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
let editing = $state(null);
|
||||
let saving = $state(false);
|
||||
let { notify } = $props();
|
||||
let tick = $state(0);
|
||||
|
||||
const blank = () => ({
|
||||
id: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
model: "",
|
||||
token_limit: null,
|
||||
max_tool_iterations: null,
|
||||
tools: [],
|
||||
skills: [],
|
||||
delegates: [],
|
||||
role_prompt: "",
|
||||
enabled: true,
|
||||
});
|
||||
let selected = $state(null);
|
||||
let detail = $state(null);
|
||||
let detailError = $state("");
|
||||
let detailTimer = null;
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = "";
|
||||
const activeStatuses = ["queued", "running", "waiting_children"];
|
||||
|
||||
function isActive(status) {
|
||||
return activeStatuses.includes(status);
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const [a, o] = await Promise.all([
|
||||
api("/api/agents"),
|
||||
api("/api/agents/options"),
|
||||
]);
|
||||
agents = a.agents || [];
|
||||
options = o;
|
||||
tasks = (await api("/api/tasks?limit=200")).tasks || [];
|
||||
error = "";
|
||||
} catch (caught) {
|
||||
error = caught.message;
|
||||
} finally {
|
||||
@ -43,264 +33,290 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTool(list, name) {
|
||||
const i = list.indexOf(name);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
else list.push(name);
|
||||
function stopDetailPoll() {
|
||||
if (detailTimer) {
|
||||
clearInterval(detailTimer);
|
||||
detailTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
editing = blank();
|
||||
}
|
||||
|
||||
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;
|
||||
async function loadDetail() {
|
||||
if (!selected) return;
|
||||
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,
|
||||
delegates: editing.delegates,
|
||||
role_prompt: editing.role_prompt,
|
||||
enabled: editing.enabled,
|
||||
const [runRes, eventsRes] = await Promise.all([
|
||||
api(`/api/agent-runs/${encodeURIComponent(selected)}`),
|
||||
api(`/api/agent-runs/${encodeURIComponent(selected)}/events?limit=200`),
|
||||
]);
|
||||
detail = {
|
||||
run: runRes.run,
|
||||
session_id: runRes.session_id,
|
||||
transcript: runRes.transcript || [],
|
||||
events: eventsRes.events || [],
|
||||
};
|
||||
await api("/api/agents", { method: "POST", body: JSON.stringify(payload) });
|
||||
editing = null;
|
||||
notify("子代理已保存(需重载配置生效)");
|
||||
await load();
|
||||
detailError = "";
|
||||
if (!isActive(runRes.run.status)) stopDetailPoll();
|
||||
} catch (caught) {
|
||||
notify(caught.message, true);
|
||||
} finally {
|
||||
saving = false;
|
||||
detailError = caught.message;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 || [],
|
||||
delegates: agent.delegates || [],
|
||||
role_prompt: agent.role_prompt || "",
|
||||
enabled: !agent.enabled,
|
||||
}),
|
||||
});
|
||||
agent.enabled = !agent.enabled;
|
||||
notify(agent.enabled ? "已启用" : "已禁用");
|
||||
} catch (caught) {
|
||||
notify(caught.message, true);
|
||||
const payload = JSON.parse(event.payload_json);
|
||||
return payload.summary || payload.status || event.payload_json.slice(0, 200);
|
||||
} catch {
|
||||
return event.payload_json.slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
onMount(() => {
|
||||
loadTasks();
|
||||
const listTimer = setInterval(loadTasks, 5000);
|
||||
const tickTimer = setInterval(() => (tick += 1), 1000);
|
||||
return () => {
|
||||
clearInterval(listTimer);
|
||||
clearInterval(tickTimer);
|
||||
stopDetailPoll();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="page active content-page">
|
||||
<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 与模型在此直接指定。改动需热重载后生效。
|
||||
</p>
|
||||
{#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>
|
||||
<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)}
|
||||
{#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="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}
|
||||
{#if agent.delegates?.length}<span>委托: {agent.delegates.join(", ")}</span>{/if}
|
||||
</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 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>
|
||||
{/each}
|
||||
|
||||
{#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>
|
||||
<h2 style="margin:0">子代理活动</h2>
|
||||
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">
|
||||
查看活动中的子代理与历史运行;子代理定义在「配置 → 子代理定义」中管理。
|
||||
</p>
|
||||
</div>
|
||||
<button class="secondary" onclick={loadTasks}><Icon name="refresh" size={16} />刷新</button>
|
||||
</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>
|
||||
{#if loading}
|
||||
<div class="loading">加载中…</div>
|
||||
{:else if error}
|
||||
<div class="empty-card error-text">{error}</div>
|
||||
{:else}
|
||||
{#if tasks.some((t) => isActive(t.status))}
|
||||
<h3 class="section-label">活动中</h3>
|
||||
<div class="cards">
|
||||
{#each tasks.filter((t) => isActive(t.status)) as task (task.id)}
|
||||
<article class="card">
|
||||
<div class="card-row">
|
||||
<div class="task-main">
|
||||
<div class="run-row">
|
||||
<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">
|
||||
<span>mode: {task.mode}</span>
|
||||
<span>depth: {task.depth}</span>
|
||||
<span class="elapsed">已运行 {elapsed(task.started_at || task.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</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 你是一名…"></textarea>
|
||||
<h3 class="section-label">历史活动</h3>
|
||||
<div class="cards">
|
||||
{#each tasks.filter((t) => !isActive(t.status)) as task (task.id)}
|
||||
<article class="card">
|
||||
<div class="card-row">
|
||||
<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 class="meta">
|
||||
<span>{formatTime(task.created_at)}</span>
|
||||
<span>{task.tool_calls_count} 次工具调用 · {task.iterations} 轮</span>
|
||||
{#if task.started_at && task.finished_at}<span>耗时 {durationBetween(task.started_at, task.finished_at)}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="secondary" onclick={() => openDetail(task)}><Icon name="more" size={15} />详情</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="empty-card">暂无历史活动</div>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<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); }
|
||||
.detail-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.detail-title { display: flex; align-items: center; gap: 10px; }
|
||||
.detail-title .mono { font-size: 13px; color: var(--text-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; }
|
||||
.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(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); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
</style>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { Tabs } from "bits-ui";
|
||||
import { api } from "../lib/api.js";
|
||||
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
|
||||
import SubAgentDefinitions from "../lib/components/SubAgentDefinitions.svelte";
|
||||
|
||||
let { notify } = $props();
|
||||
let tab = $state("appearance");
|
||||
@ -16,6 +17,7 @@
|
||||
|
||||
const isConfig = $derived(tab === "config");
|
||||
const isAppearance = $derived(tab === "appearance");
|
||||
const isSubagents = $derived(tab === "subagents");
|
||||
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
|
||||
const isDirty = $derived(content !== original);
|
||||
|
||||
@ -34,7 +36,7 @@
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
if (isAppearance) { loading = false; return; }
|
||||
if (isAppearance || isSubagents) { loading = false; return; }
|
||||
try {
|
||||
if (isConfig) {
|
||||
const result = await api("/api/config");
|
||||
@ -119,12 +121,16 @@
|
||||
<div class="settings-grid">
|
||||
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
|
||||
<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.Root>
|
||||
|
||||
{#if isAppearance}
|
||||
<AppearanceSettings />
|
||||
{:else if isSubagents}
|
||||
<div class="subagents-pane">
|
||||
<SubAgentDefinitions {notify} />
|
||||
</div>
|
||||
{:else if isConfig}
|
||||
<div class="config-layout">
|
||||
<div class="editor-card">
|
||||
@ -188,6 +194,7 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.subagents-pane { min-width: 0; }
|
||||
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
|
||||
.config-sidebar { display: grid; gap: 14px; }
|
||||
.sidebar-panel { padding: 14px; }
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { Tabs } from "bits-ui";
|
||||
import { api, formatTime } from "../lib/api.js";
|
||||
import StatusBadge from "../lib/StatusBadge.svelte";
|
||||
import Icon from "../lib/Icon.svelte";
|
||||
|
||||
let tab = $state("scheduled");
|
||||
let jobs = $state([]);
|
||||
let tasks = $state([]);
|
||||
let runs = $state({});
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
@ -17,15 +14,11 @@
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
if (tab === "background") {
|
||||
tasks = (await api("/api/tasks?limit=200")).tasks;
|
||||
} else {
|
||||
jobs = (await api("/api/jobs")).jobs;
|
||||
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
|
||||
job.id,
|
||||
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
|
||||
])));
|
||||
}
|
||||
jobs = (await api("/api/jobs")).jobs;
|
||||
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
|
||||
job.id,
|
||||
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
|
||||
])));
|
||||
} catch (caught) {
|
||||
error = caught.message;
|
||||
} finally {
|
||||
@ -33,11 +26,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function changeTab(value) {
|
||||
tab = value;
|
||||
load();
|
||||
}
|
||||
|
||||
function countdown(ts) {
|
||||
void tick;
|
||||
if (!ts) return "—";
|
||||
@ -52,17 +40,6 @@
|
||||
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) {
|
||||
if (status === "completed" || status === "success" || status === "ok") return "var(--signal)";
|
||||
if (status === "timeout") return "var(--accent)";
|
||||
@ -79,40 +56,15 @@
|
||||
|
||||
<section class="page active content-page">
|
||||
<div class="toolbar">
|
||||
<Tabs.Root value={tab} onValueChange={changeTab}>
|
||||
<Tabs.List class="tabs" aria-label="任务类型">
|
||||
<Tabs.Trigger value="scheduled">定时任务</Tabs.Trigger>
|
||||
<Tabs.Trigger value="background">后台任务</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
<div>
|
||||
<h2 style="margin:0">定时任务</h2>
|
||||
<p style="margin:2px 0 0;color:var(--muted);font-size:12px">管理定时任务与巡检;后台子代理运行请到「子代理」页面查看。</p>
|
||||
</div>
|
||||
<button class="secondary" onclick={load}><Icon name="refresh" size={16} />刷新</button>
|
||||
</div>
|
||||
<div class="cards">
|
||||
{#if loading}<div class="loading">加载中…</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}
|
||||
{#each jobs as job (job.id)}
|
||||
<article class="card">
|
||||
@ -147,11 +99,5 @@
|
||||
<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; }
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user