feat(dashboard): AgentModal live working feed & thinking stream
CI - Build & Test / Backend (.NET) (push) Failing after 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s

This commit is contained in:
2026-06-11 16:13:28 +02:00
parent c29740a466
commit b1888bd8ef
4 changed files with 180 additions and 25 deletions
@@ -343,6 +343,23 @@ public class DashboardController(
} }
} }
/// <summary>
/// Returns the most recent activity entries (assistant messages) for a specific agent.
/// </summary>
[HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
{
try
{
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
return new List<AgentActivityEntry>();
}
}
/// <summary> /// <summary>
/// Returns the list of available models that can be assigned to agents. /// Returns the list of available models that can be assigned to agents.
/// Reads from OpenClaw config dynamically, falls back to hardcoded list. /// Reads from OpenClaw config dynamically, falls back to hardcoded list.
+5
View File
@@ -104,3 +104,8 @@ public sealed record UpdateDashboardTaskRequest(
public sealed record UpdateDashboardTaskStatusRequest( public sealed record UpdateDashboardTaskStatusRequest(
string Status string Status
); );
public sealed record AgentActivityEntry(
string Time,
string Text
);
+38
View File
@@ -953,6 +953,44 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
return Math.Clamp(totalQueuePressure + agentPressure, 0, 100); return Math.Clamp(totalQueuePressure + agentPressure, 0, 100);
} }
/// <summary>
/// Fetches the most recent assistant activity (last N messages) for a specific agent.
/// Returns entries with timestamp and truncated content text.
/// Falls back to an empty list if the session is unreachable.
/// </summary>
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5)
{
var entries = new List<AgentActivityEntry>();
try
{
var sessionKey = $"agent:{agentId}:main";
var messages = await GetSessionHistoryAsync(sessionKey, Math.Clamp(limit * 2, 1, 100));
foreach (var msg in messages)
{
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(msg.Content))
continue;
if (msg.Content == "REPLY_SKIP" || msg.Content == "ANNOUNCE_SKIP")
continue;
// Truncate content to first 200 chars for compact display
var text = msg.Content.Length > 200
? msg.Content[..200] + "…"
: msg.Content;
var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts);
entries.Add(new AgentActivityEntry(timeAgo, text));
}
}
catch
{
// Return empty list if gateway is unreachable
}
return entries.Take(Math.Clamp(limit, 1, 20)).ToList();
}
/// <summary> /// <summary>
/// Returns the list of available models by reading from the OpenClaw config, /// Returns the list of available models by reading from the OpenClaw config,
/// with fallback to hardcoded list. /// with fallback to hardcoded list.
+120 -25
View File
@@ -24,11 +24,19 @@ interface ModelOption {
provider: string provider: string
} }
interface ActivityEntry {
time: string
text: string
}
const availableModels = ref<ModelOption[]>([]) const availableModels = ref<ModelOption[]>([])
const selectedModel = ref('') const selectedModel = ref('')
const currentModel = ref('') const currentModel = ref('')
const saving = ref(false) const saving = ref(false)
const activityEntries = ref<ActivityEntry[]>([])
const activityLoaded = ref(false)
async function loadModels() { async function loadModels() {
try { try {
const res = await fetch('/api/dashboard/models', { credentials: 'include' }) const res = await fetch('/api/dashboard/models', { credentials: 'include' })
@@ -75,9 +83,23 @@ async function saveModel() {
} }
} }
async function loadActivity() {
try {
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/activity?limit=5`, { credentials: 'include' })
if (res.ok) {
activityEntries.value = await res.json()
}
} catch {
// silent — fallback to empty
} finally {
activityLoaded.value = true
}
}
onMounted(async () => { onMounted(async () => {
await loadModels() await loadModels()
await loadCurrentModel() await loadCurrentModel()
await loadActivity()
}) })
</script> </script>
@@ -139,16 +161,35 @@ onMounted(async () => {
<!-- Current Task --> <!-- Current Task -->
<section class="modal-section"> <section class="modal-section">
<h3 class="section-label">Current Task</h3> <h3 class="section-label">
Current Task
<span v-if="agent.active" class="thinking-indicator">
<span class="thinking-dots-inline">
<span class="tdot"></span>
<span class="tdot"></span>
<span class="tdot"></span>
</span>
Thinking
</span>
</h3>
<p class="section-value"> <p class="section-value">
{{ agent.currentTask }} {{ agent.currentTask }}
<span v-if="agent.active" class="thinking-dots">
<span class="thinking-dot blue"></span>
<span class="thinking-dot violet"></span>
</span>
</p> </p>
</section> </section>
<!-- Recent Activity -->
<section class="modal-section">
<h3 class="section-label">Recent Activity</h3>
<div v-if="!activityLoaded" class="activity-placeholder">Loading</div>
<div v-else-if="activityEntries.length === 0" class="activity-placeholder">No recent activity</div>
<div v-else class="activity-list">
<div v-for="(entry, idx) in activityEntries" :key="idx" class="activity-item">
<span class="activity-time">{{ entry.time }}</span>
<span class="activity-text">{{ entry.text.length > 100 ? entry.text.slice(0, 100) + '…' : entry.text }}</span>
</div>
</div>
</section>
<!-- Goal + Progress --> <!-- Goal + Progress -->
<section class="modal-section"> <section class="modal-section">
<h3 class="section-label">Goal</h3> <h3 class="section-label">Goal</h3>
@@ -384,34 +425,88 @@ onMounted(async () => {
transition: width 0.5s ease; transition: width 0.5s ease;
} }
/* Thinking Dots */ /* Thinking Indicator */
.thinking-dots { .thinking-indicator {
display: inline-flex; display: inline-flex;
align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0; font-size: 9px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: #a78bfa;
margin-left: 8px;
vertical-align: middle;
} }
.thinking-dot { .thinking-dots-inline {
width: 7px; display: inline-flex;
height: 7px; gap: 4px;
}
.thinking-dots-inline .tdot {
width: 5px;
height: 5px;
border-radius: 50%; border-radius: 50%;
background: #a78bfa;
animation: thinking-bounce 1.2s ease-in-out infinite;
} }
.thinking-dot.blue { .thinking-dots-inline .tdot:nth-child(2) {
background: #3b82f6; animation-delay: 0.2s;
box-shadow: 0 0 8px #3b82f6;
animation: pulse-dot-blue 1.2s ease-in-out infinite;
} }
.thinking-dot.violet { .thinking-dots-inline .tdot:nth-child(3) {
background: #8b7cf6; animation-delay: 0.4s;
box-shadow: 0 0 8px #8b7cf6;
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
} }
@keyframes pulse-dot-blue { @keyframes thinking-bounce {
0%, 100% { opacity: 0.4; transform: scale(0.7); } 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
50% { opacity: 1; transform: scale(1.3); } 40% { transform: scale(1); opacity: 1; }
} }
@keyframes pulse-dot-violet {
0%, 100% { opacity: 0.3; transform: scale(0.6); } /* Recent Activity */
50% { opacity: 1; transform: scale(1.4); } .activity-placeholder {
font-size: 10px;
color: #6b7385;
font-style: italic;
padding: 4px 0;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 160px;
overflow-y: auto;
}
.activity-list::-webkit-scrollbar {
width: 4px;
}
.activity-list::-webkit-scrollbar-track {
background: transparent;
}
.activity-list::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.15);
border-radius: 3px;
}
.activity-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.activity-time {
font-size: 9px;
font-weight: 600;
color: #6b7385;
font-variant-numeric: tabular-nums;
}
.activity-text {
font-size: 11px;
line-height: 1.45;
color: #c4c8d4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
/* Footer */ /* Footer */