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,
|
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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
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>
|
<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 你是一名…"></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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user