feat: show live Kimi activity on agent cards
This commit is contained in:
+5
-4
@@ -1,14 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>KimiFleet</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#0b1020;--panel:#121a31;--line:#253252;--text:#edf2ff;--muted:#98a7ca;--accent:#76a7ff;--ok:#50d7a1;--warn:#ffc46b;--bad:#ff7777}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top,#19274b,#0b1020 58%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui}header{padding:28px max(6vw,24px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}h1{margin:0;font-size:25px;letter-spacing:-.6px}.sub{color:var(--muted);margin-top:5px}.live{color:var(--ok);font-weight:700}.dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--ok);margin-right:7px;box-shadow:0 0 14px var(--ok)}main{max-width:1280px;margin:0 auto;padding:26px;display:grid;grid-template-columns:1.1fr .9fr;gap:20px}.panel{background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:16px;padding:18px;box-shadow:0 16px 40px #0003}h2{margin:0 0 15px;font-size:15px}.agents{display:grid;gap:12px}.agent{padding:15px;border:1px solid var(--line);border-radius:12px;background:#0d152a}.agent-top{display:flex;justify-content:space-between;align-items:center}.name{font-weight:750;font-size:16px}.badge{padding:4px 9px;border-radius:999px;background:#1a2b4c;color:var(--accent);font-size:12px}.state-ready{color:var(--ok)}.state-working{color:var(--warn)}.state-error{color:var(--bad)}.meta{color:var(--muted);font-size:12px;margin-top:8px}.scopes{margin-top:10px;display:flex;gap:6px;flex-wrap:wrap}.scope{font:11px ui-monospace,monospace;background:#17223d;border-radius:5px;padding:3px 6px;color:#c0cdf1}.events{height:600px;overflow:auto;display:flex;flex-direction:column-reverse;gap:8px}.event{border-left:3px solid var(--accent);background:#0d152a;border-radius:0 8px 8px 0;padding:10px 11px}.event-head{font-size:12px;color:var(--muted);margin-bottom:4px}.event-body{line-height:1.35;word-break:break-word}.empty{color:var(--muted);padding:18px 3px}@media(max-width:850px){main{grid-template-columns:1fr}.events{height:360px}}
|
||||
:root{color-scheme:dark;--bg:#0b1020;--panel:#121a31;--line:#253252;--text:#edf2ff;--muted:#98a7ca;--accent:#76a7ff;--ok:#50d7a1;--warn:#ffc46b;--bad:#ff7777}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top,#19274b,#0b1020 58%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui}header{padding:28px max(6vw,24px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}h1{margin:0;font-size:25px;letter-spacing:-.6px}.sub{color:var(--muted);margin-top:5px}.live{color:var(--ok);font-weight:700}.dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--ok);margin-right:7px;box-shadow:0 0 14px var(--ok)}main{max-width:1280px;margin:0 auto;padding:26px;display:grid;grid-template-columns:1.1fr .9fr;gap:20px}.panel{background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:16px;padding:18px;box-shadow:0 16px 40px #0003}h2{margin:0 0 15px;font-size:15px}.agents{display:grid;gap:12px}.agent{padding:15px;border:1px solid var(--line);border-radius:12px;background:#0d152a}.agent-top{display:flex;justify-content:space-between;align-items:center}.name{font-weight:750;font-size:16px}.badge{padding:4px 9px;border-radius:999px;background:#1a2b4c;color:var(--accent);font-size:12px}.state-ready{color:var(--ok)}.state-working{color:var(--warn)}.state-error{color:var(--bad)}.meta{color:var(--muted);font-size:12px;margin-top:8px}.now{display:flex;align-items:center;gap:8px;margin-top:13px;padding:9px 10px;border-radius:8px;background:#101c33;color:#d8e4ff}.now-mark{width:7px;height:7px;border-radius:50%;background:var(--warn);box-shadow:0 0 10px var(--warn);flex:none}.now.ready .now-mark{background:var(--ok);box-shadow:0 0 10px var(--ok)}.now.error .now-mark{background:var(--bad);box-shadow:0 0 10px var(--bad)}.now-label{font-size:10px;font-weight:800;letter-spacing:.08em;color:var(--muted)}.now-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scopes{margin-top:10px;display:flex;gap:6px;flex-wrap:wrap}.scope{font:11px ui-monospace,monospace;background:#17223d;border-radius:5px;padding:3px 6px;color:#c0cdf1}.events{height:600px;overflow:auto;display:flex;flex-direction:column-reverse;gap:8px}.event{border-left:3px solid var(--accent);background:#0d152a;border-radius:0 8px 8px 0;padding:10px 11px}.event-head{font-size:12px;color:var(--muted);margin-bottom:4px}.event-body{line-height:1.35;word-break:break-word}.empty{color:var(--muted);padding:18px 3px}@media(max-width:850px){main{grid-template-columns:1fr}.events{height:360px}}
|
||||
</style></head><body><header><div><h1>KimiFleet</h1><div class="sub">MCP-controlled Kimi K3 agent fleet</div></div><div class="live"><span class="dot"></span>LIVE</div></header><main><section class="panel"><h2>Agents</h2><div id="agents" class="agents"><div class="empty">Loading agents…</div></div></section><section class="panel"><h2>Activity</h2><div id="events" class="events"><div class="empty">Waiting for events…</div></div></section></main>
|
||||
<script>
|
||||
const agents=document.querySelector('#agents'),events=document.querySelector('#events');
|
||||
const agents=document.querySelector('#agents'),events=document.querySelector('#events'),activityByAgent=new Map();
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function drawAgents(items){agents.innerHTML=items.length?items.map(a=>`<article class="agent"><div class="agent-top"><div><span class="name">${esc(a.name)}</span> <span class="badge">${esc(a.role)}</span></div><b class="state-${esc(a.state)}">${esc(a.state)}</b></div><div class="meta">${esc(a.workspace)}</div><div class="scopes">${(a.scopes||[]).map(s=>`<span class="scope">${esc(s)}</span>`).join('')||'<span class="meta">No exclusive scopes</span>'}</div></article>`).join(''):'<div class="empty">No agents yet. Create one through the MCP tool or REST API.</div>'}
|
||||
function drawAgents(items){agents.innerHTML=items.length?items.map(a=>{const activity=activityByAgent.get(a.name)||a.activity||(a.state==='ready'?'Ready for a task':'Synchronizing live activity…');return `<article class="agent"><div class="agent-top"><div><span class="name">${esc(a.name)}</span> <span class="badge">${esc(a.role)}</span></div><b class="state-${esc(a.state)}">${esc(a.state)}</b></div><div class="meta">${esc(a.workspace)}</div><div class="now ${esc(a.state)}" title="${esc(activity)}"><span class="now-mark"></span><span class="now-label">NOW</span><span class="now-text">${esc(activity)}</span></div><div class="scopes">${(a.scopes||[]).map(s=>`<span class="scope">${esc(s)}</span>`).join('')||'<span class="meta">No exclusive scopes</span>'}</div></article>`}).join(''):'<div class="empty">No agents yet. Create one through the MCP tool or REST API.</div>'}
|
||||
async function refresh(){try{drawAgents(await (await fetch('/agents')).json())}catch{agents.innerHTML='<div class="empty">Dashboard cannot reach KimiFleet.</div>'}}
|
||||
function add(e){const text=pretty(e);if(text===null)return;events.querySelector('.empty')?.remove();const time=new Date(e.time).toLocaleTimeString();const row=document.createElement('article');row.className='event';row.innerHTML=`<div class="event-head">${esc(time)} · ${esc(e.agent)} · ${esc(e.kind)}</div><div class="event-body">${esc(text)}</div>`;events.prepend(row);while(events.children.length>140)events.removeChild(events.lastChild)}
|
||||
function add(e){const live=activity(e);if(live)activityByAgent.set(e.agent,live);const text=pretty(e);if(text===null)return;events.querySelector('.empty')?.remove();const time=new Date(e.time).toLocaleTimeString();const row=document.createElement('article');row.className='event';row.innerHTML=`<div class="event-head">${esc(time)} · ${esc(e.agent)} · ${esc(e.kind)}</div><div class="event-body">${esc(text)}</div>`;events.prepend(row);while(events.children.length>140)events.removeChild(events.lastChild)}
|
||||
function pretty(e){if(e.kind!=='session/update')return e.message;try{const u=JSON.parse(e.message).update;if(u.sessionUpdate==='tool_call_update')return `${u.title||'Tool call'} — ${u.status||'in progress'}`;if(['agent_thought_chunk','agent_message_chunk','available_commands_update','tool_call'].includes(u.sessionUpdate))return null;return u.sessionUpdate||'session update'}catch{return e.message}}
|
||||
function activity(e){if(e.kind==='prompt')return 'Planning assigned task';if(e.kind==='completed')return 'Ready for a task';if(e.kind==='permission')return 'Approving requested action';if(e.kind!=='session/update')return null;try{const u=JSON.parse(e.message).update;if(u.sessionUpdate==='tool_call'){if(u.kind==='read')return 'Reading project files';if(u.kind==='bash')return 'Running a project command';return u.title||'Executing a tool'}if(u.sessionUpdate==='tool_call_update')return u.title||null;if(u.sessionUpdate==='agent_thought_chunk')return 'Analyzing task';if(u.sessionUpdate==='agent_message_chunk')return 'Preparing a report';return null}catch{return null}}
|
||||
new EventSource('/events').onmessage=x=>{add(JSON.parse(x.data));refresh()};refresh();setInterval(refresh,5000);
|
||||
</script></body></html>
|
||||
|
||||
+29
-1
@@ -288,6 +288,8 @@ sealed class KimiAgent : IDisposable
|
||||
public string Role { get; }
|
||||
public IReadOnlyList<string> Scopes { get; }
|
||||
public string State { get; private set; } = "starting";
|
||||
public string Activity { get; private set; } = "Starting ACP session";
|
||||
public DateTimeOffset ActivityUpdatedAt { get; private set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public KimiAgent(string name, string workspace, string role, IReadOnlyList<string> scopes, FleetOptions options, Action<string, string, string> publish)
|
||||
{
|
||||
@@ -319,6 +321,7 @@ sealed class KimiAgent : IDisposable
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
SetActivity("Initializing ACP session");
|
||||
if (!_process.Start()) throw new InvalidOperationException($"Could not start Kimi process for '{Name}'.");
|
||||
_process.Exited += (_, _) => { State = "stopped"; _publish(Name, "exit", $"Kimi exited with {_process.ExitCode}."); };
|
||||
_ = ReadOutputAsync();
|
||||
@@ -333,6 +336,7 @@ sealed class KimiAgent : IDisposable
|
||||
var session = await RequestAsync("session/new", new { cwd = Workspace, mcpServers = Array.Empty<object>() });
|
||||
_sessionId = session.GetProperty("sessionId").GetString() ?? throw new InvalidOperationException("ACP did not return sessionId.");
|
||||
State = "ready";
|
||||
SetActivity("Ready for a task");
|
||||
_publish(Name, "ready", $"Role={Role}; scopes={string.Join(", ", Scopes)}");
|
||||
}
|
||||
|
||||
@@ -341,16 +345,19 @@ sealed class KimiAgent : IDisposable
|
||||
if (string.IsNullOrWhiteSpace(prompt)) throw new InvalidOperationException("Prompt must not be empty.");
|
||||
if (_sessionId is null) throw new InvalidOperationException($"Agent '{Name}' has no ACP session.");
|
||||
State = "working";
|
||||
SetActivity("Planning assigned task");
|
||||
_publish(Name, "prompt", prompt.Length > 160 ? prompt[..160] + "…" : prompt);
|
||||
try
|
||||
{
|
||||
await RequestAsync("session/prompt", new { sessionId = _sessionId, prompt = new[] { new { type = "text", text = prompt } } });
|
||||
State = "ready";
|
||||
SetActivity("Ready for a task");
|
||||
_publish(Name, "completed", "Prompt completed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State = "error";
|
||||
SetActivity("Needs attention");
|
||||
_publish(Name, "error", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -406,10 +413,13 @@ sealed class KimiAgent : IDisposable
|
||||
if (methodName == "session/request_permission" && root.TryGetProperty("id", out var permissionId) && root.TryGetProperty("params", out var permission))
|
||||
{
|
||||
var optionId = SelectApproval(permission);
|
||||
SetActivity("Approving requested action");
|
||||
_ = RespondToPermissionAsync(permissionId.GetRawText(), optionId);
|
||||
_publish(Name, "permission", $"Auto-approved '{optionId}' (yolo mode).");
|
||||
return;
|
||||
}
|
||||
if (methodName == "session/update" && root.TryGetProperty("params", out var updateParams))
|
||||
UpdateActivityFromSessionUpdate(updateParams);
|
||||
_publish(Name, methodName, root.TryGetProperty("params", out var p) ? p.GetRawText() : string.Empty);
|
||||
}
|
||||
else _publish(Name, "protocol", line);
|
||||
@@ -417,6 +427,24 @@ sealed class KimiAgent : IDisposable
|
||||
catch (JsonException) { _publish(Name, "stdout", line); }
|
||||
}
|
||||
|
||||
private void UpdateActivityFromSessionUpdate(JsonElement parameters)
|
||||
{
|
||||
if (!parameters.TryGetProperty("update", out var update) || !update.TryGetProperty("sessionUpdate", out var typeNode)) return;
|
||||
var type = typeNode.GetString();
|
||||
if ((type == "tool_call" || type == "tool_call_update") && update.TryGetProperty("title", out var title) && !string.IsNullOrWhiteSpace(title.GetString()))
|
||||
SetActivity(title.GetString()!);
|
||||
else if (type == "agent_thought_chunk" && !Activity.StartsWith("Reading", StringComparison.OrdinalIgnoreCase) && !Activity.StartsWith("Running", StringComparison.OrdinalIgnoreCase))
|
||||
SetActivity("Analyzing task");
|
||||
else if (type == "agent_message_chunk")
|
||||
SetActivity("Preparing a report");
|
||||
}
|
||||
|
||||
private void SetActivity(string value)
|
||||
{
|
||||
Activity = value;
|
||||
ActivityUpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static string SelectApproval(JsonElement permission)
|
||||
{
|
||||
if (!permission.TryGetProperty("options", out var options) || options.ValueKind != JsonValueKind.Array)
|
||||
@@ -446,7 +474,7 @@ sealed class KimiAgent : IDisposable
|
||||
finally { _stdinLock.Release(); }
|
||||
}
|
||||
|
||||
public object Snapshot() => new { name = Name, workspace = Workspace, role = Role, scopes = Scopes, state = State, sessionId = _sessionId };
|
||||
public object Snapshot() => new { name = Name, workspace = Workspace, role = Role, scopes = Scopes, state = State, activity = Activity, activityUpdatedAt = ActivityUpdatedAt, sessionId = _sessionId };
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user