feat(dashboard): AgentModal live working feed & thinking stream
This commit is contained in:
@@ -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>
|
||||
/// Returns the list of available models that can be assigned to agents.
|
||||
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
|
||||
|
||||
@@ -104,3 +104,8 @@ public sealed record UpdateDashboardTaskRequest(
|
||||
public sealed record UpdateDashboardTaskStatusRequest(
|
||||
string Status
|
||||
);
|
||||
|
||||
public sealed record AgentActivityEntry(
|
||||
string Time,
|
||||
string Text
|
||||
);
|
||||
|
||||
@@ -953,6 +953,44 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
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>
|
||||
/// Returns the list of available models by reading from the OpenClaw config,
|
||||
/// with fallback to hardcoded list.
|
||||
|
||||
@@ -24,11 +24,19 @@ interface ModelOption {
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
time: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const availableModels = ref<ModelOption[]>([])
|
||||
const selectedModel = ref('')
|
||||
const currentModel = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const activityEntries = ref<ActivityEntry[]>([])
|
||||
const activityLoaded = ref(false)
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
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 () => {
|
||||
await loadModels()
|
||||
await loadCurrentModel()
|
||||
await loadActivity()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -139,16 +161,35 @@ onMounted(async () => {
|
||||
|
||||
<!-- Current Task -->
|
||||
<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">
|
||||
{{ agent.currentTask }}
|
||||
<span v-if="agent.active" class="thinking-dots">
|
||||
<span class="thinking-dot blue"></span>
|
||||
<span class="thinking-dot violet"></span>
|
||||
</span>
|
||||
</p>
|
||||
</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 -->
|
||||
<section class="modal-section">
|
||||
<h3 class="section-label">Goal</h3>
|
||||
@@ -384,34 +425,88 @@ onMounted(async () => {
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* Thinking Dots */
|
||||
.thinking-dots {
|
||||
/* Thinking Indicator */
|
||||
.thinking-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
.thinking-dots-inline {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.thinking-dots-inline .tdot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #a78bfa;
|
||||
animation: thinking-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.thinking-dot.blue {
|
||||
background: #3b82f6;
|
||||
box-shadow: 0 0 8px #3b82f6;
|
||||
animation: pulse-dot-blue 1.2s ease-in-out infinite;
|
||||
.thinking-dots-inline .tdot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.thinking-dot.violet {
|
||||
background: #8b7cf6;
|
||||
box-shadow: 0 0 8px #8b7cf6;
|
||||
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
|
||||
.thinking-dots-inline .tdot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@keyframes pulse-dot-blue {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.7); }
|
||||
50% { opacity: 1; transform: scale(1.3); }
|
||||
@keyframes thinking-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes pulse-dot-violet {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.6); }
|
||||
50% { opacity: 1; transform: scale(1.4); }
|
||||
|
||||
/* Recent Activity */
|
||||
.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 */
|
||||
|
||||
Reference in New Issue
Block a user