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>
|
/// <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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user