From 2b951615962143a6d8f252edc843d7b5e287c2dc Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 20 Jun 2026 18:14:42 +0200 Subject: [PATCH] Read workflow-status telemetry from CLI transcript files Dynamic ultracode workflows run as a detached background task with their own transcript dir, so their subagent events never flow through the main -p stream. The previous parent_tool_use_id tap therefore captured nothing for real workflows and the status stayed "none" the whole run. Replace the tap with a file-based reader: - TapWorkflowLaunch parses the "Transcript dir:" line from the Workflow tool_result and registers the run. - WorkflowProgressTracker becomes a per-session registry of transcript paths (no longer a stream accumulator). - WorkflowTelemetryReader reads journal.jsonl for live counts (running) and .json for rich per-agent telemetry (completed); FinalizeChain persists the rich JSON into chains.workflow_info. - Modal renders phases + a per-agent list (label/phase/state/tokens/ tool-calls/duration) instead of the obsolete tool breakdown. Sync ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md to the new mechanism. Co-Authored-By: Claude Opus 4.8 --- ARCHITECTURE.md | 24 ++- ARCHITECTURE_QUICK.md | 22 +- Endpoints/SessionEndpoint.cs | 15 +- README.md | 2 +- Services/AnthropicService.cs | 13 +- Services/ClaudeCliService.cs | 59 +++--- Services/WorkflowProgressTracker.cs | 307 +++++++++++++++++++--------- wwwroot/css/chat.css | 98 ++++++++- wwwroot/js/modals.js | 88 ++++++-- 9 files changed, 453 insertions(+), 175 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3c291bc..4e9ef19 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -165,7 +165,7 @@ gott/ │ ├── KreaService.cs # Krea Image Generation API │ ├── ImageService.cs # Bild-Resize + WebP-Konvertierung (512px + 256px Thumbnail) │ ├── StreamingTracker.cs # Tracking aktiver Streams + CancellationTokenSource für Stop -│ ├── WorkflowProgressTracker.cs # In-Memory-Telemetrie laufender dynamischer Workflows (Ultracode) pro Session +│ ├── WorkflowProgressTracker.cs # Registry der dynamischen Workflows (Ultracode) pro Session + WorkflowTelemetryReader │ ├── WorkspaceHelper.cs # Workspace-Pfad-Auflösung (relativ → absolut) │ ├── AppPaths.cs # Zentrale Pfade (WwwRoot) – Dev vs. Publish-Modus │ ├── KeyScannerService.cs # API-Key-Scanner (GitHub Code Search, GitHub Issues/PRs, HuggingFace Hub, Google/Serper) @@ -382,7 +382,7 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id) - `ToolNames` – Komma-separierte Liste der verwendeten Tools - `ArchiveId` – Zugehöriges Archiv (wenn archiviert) - `StreamErrorInfo` – Fehlerdetails bei Stream-Abbrüchen -- `WorkflowInfo` – JSON-Telemetrie eines dynamischen Workflows (Ultracode): Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung), aggregierte Subagent-Tokens. `null` wenn die Chain keinen Workflow gespawnt hat (siehe [Workflow-Status](#workflow-status)) +- `WorkflowInfo` – JSON-Telemetrie eines dynamischen Workflows (Ultracode): die reiche, beim Lauf-Ende aus der `.json` gelesene Telemetrie – Phasen + Per-Agent-Details (Label/Phase/State/Tokens/Tool-Calls/Dauer) + Summen. `null` wenn die Chain keinen Workflow gespawnt hat (siehe [Workflow-Status](#workflow-status)) - `PlanMode` – Ob die Chain im Plan-Modus erstellt wurde (informativ) - `DurationMs` – Gesamtdauer der Chain @@ -462,7 +462,7 @@ RemoteConnectionManager → AuthService → DatabaseService → EnsureDefaultUse | `KreaService` | Singleton | Krea API für Bildgenerierung, Job-Polling, Image-Download | | `ImageService` | Singleton | Bild-Resize + WebP-Konvertierung (512px + 256px Thumbnail) für Avatare und Session-Bilder | | `StreamingTracker` | Singleton | Trackt aktive Streams + CancellationTokenSource pro Session für Stop-Button | -| `WorkflowProgressTracker` | Singleton | In-Memory-Telemetrie laufender dynamischer Workflows (Ultracode) pro Session: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung), aggregierte Subagent-Tokens. Wird beim Chain-Finalize in `chains.workflow_info` persistiert (analog `StreamingTracker`) | +| `WorkflowProgressTracker` | Singleton | **Registry** laufender dynamischer Workflows (Ultracode) pro Session: merkt sich pro Session die aus dem Workflow-`tool_result` abgegriffenen Transcript-Pfade (`journal.jsonl` / `.json`). Die Telemetrie wird **on demand** via `WorkflowTelemetryReader` aus diesen Dateien gelesen (live aus `journal.jsonl`, abgeschlossen aus `.json`) und beim Chain-Finalize in `chains.workflow_info` persistiert (analog `StreamingTracker`) | | `KeyScannerService` | Singleton | API-Key-Scanner (GitHub Code Search, GitHub Issues/PRs, HuggingFace Hub, Google/Serper), täglicher Scan | | `GitHubEventScannerService` | Background | Echtzeit-Scanner: GitHub Public Events API (PushEvent-Diffs nach geleakten Keys), 30s Poll-Intervall | | `KeyPatterns` | Static | Zentrale Key-Pattern-Definitionen für alle Scanner (Regex, Typ-Erkennung, Display-Names) | @@ -1520,20 +1520,24 @@ Wenn im Plan-Modus eine `ask_user_choice`-Rückfrage kommt und der User antworte ### Workflow-Status -Da die CLI Subagent-Events (Top-Level-Messages mit gesetztem `parent_tool_use_id`) bewusst aus dem sichtbaren Stream/History ausblendet (zentraler `parent_tool_use_id`-Guard in `ClaudeCliService.RunCliStreamingAsync`), gäbe es ohne separates Tracking keine Sichtbarkeit auf den Workflow-Fortschritt. Der **Workflow-Status** macht diese Subagent-Telemetrie live und nachträglich abrufbar. +Dynamische Ultracode-Workflows laufen als **detachter Hintergrund-Task mit eigenem Transcript-Verzeichnis** – ihre Subagent-Events fließen **nicht** durch den Haupt-(`-p`)-Stream (anders als klassische Task-Subagenten, die mit gesetztem `parent_tool_use_id` inline im Parent-Stream ankommen). Die echte Telemetrie liegt daher in zwei Dateien des CLI-Transcripts und wird **on demand** gelesen, nicht aus dem Stream akkumuliert. Der **Workflow-Status** macht diese Telemetrie live und nachträglich abrufbar. **Backend:** -- **`WorkflowProgressTracker`** (Singleton, registriert in `Program.cs`) – In-Memory-Tracking pro Session: Subagent-Anzahl (dedupliziert per `parent_tool_use_id`), Tool-Aufrufe + Aufschlüsselung (`ToolBreakdown`), aggregierte Subagent-Input-/Output-Tokens, `RecentTools` (letzte 12). Lock-leicht, defensiv – Telemetrie darf den Stream nie blockieren. -- **Tap statt Filter:** Der `parent_tool_use_id`-Guard blendet Subagent-Events weiterhin aus dem Stream aus (`continue`), greift aber für `type == "assistant"`-Turns via `TapSubagentEvent()` die Tool-Namen + Token ab und meldet sie an `RecordSubagent()`. Der existierende Guard bleibt unverändert. -- **Persistierung:** Neue Spalte `chains.workflow_info` (TEXT, via `TryAddColumn` in `DatabaseService.cs`; geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`), Feld `ChainRecord.WorkflowInfo`. Beim `FinalizeChain` (`AnthropicService.cs`) wird die Telemetrie als JSON in die Chain serialisiert und der Live-Tracker freigegeben – mit **ChainId-Abgleich** (`ChainIdFor`), damit ein anderer Finalize-Pfad derselben Session (z.B. ein Panel-Member) den Workflow-Eintrag nicht fälschlich abräumt. -- **Endpoint:** `GET /api/sessions/{id}/workflow-status` (`SessionEndpoint.HandleWorkflowStatus`) liefert in dieser Reihenfolge: (1) `status: "running"` + Live-Snapshot, solange der Tracker einen Eintrag hat; (2) `status: "completed"` + persistiertes `workflow_info` der jüngsten aktiven Chain; (3) `status: "none"` mit `workflow: null`. Top-Level-Felder: `sessionId`, `streaming`, `status`, `chainId` (entfällt bei `status: "none"`) und das Telemetrie-Objekt `workflow` (`null` bei `none`). +- **Capture (`ClaudeCliService.TapWorkflowLaunch`)** – Im `tool_result`-Pfad: Enthält der Inhalt `"Workflow launched"`, parst die Methode die Zeile `Transcript dir: ` und ruft `_workflowTracker.Register(...)`. Rein additiv, wirft nie. Der `parent_tool_use_id`-Guard in `ClaudeCliService.RunCliStreamingAsync` blendet klassische Subagent-Events weiterhin per `continue` aus dem sichtbaren Stream aus – **tappt sie aber nicht mehr** ab. +- **Registry (`WorkflowProgressTracker`)** (Singleton, registriert in `Program.cs`) – ist **nur eine Registry**, kein Stream-Akkumulator. Speichert pro Session einen `WorkflowRunRef` (`ChainId`, `RunId`, `JournalPath`, `RichJsonPath`, `StartedAt`), abgeleitet aus der Transcript-Dir. Methoden: `Register` / `Get` / `ChainIdFor` / `End`. In-Memory pro Session, defensiv. +- **Reader (`WorkflowTelemetryReader`, statisch)** – liest die Telemetrie aus zwei Dateien: + - **Live:** `/journal.jsonl` (append-only; pro Subagent `started`/`result`-Zeilen). `ReadLive` ⇒ `status: "running"`, `subagentCount` (= Anzahl `started`), `completedCount` (= Anzahl `result`), `durationMs`. **Keine** Tokens/Tool-Calls live verfügbar. + - **Abgeschlossen:** `/workflows/.json` (wird **erst am Lauf-Ende** geschrieben). `ReadCompleted` ⇒ `status: "completed"`, `workflowName`, `summary`, `durationMs`, `phases[]` (index/title/detail), `agents[]` (index/label/phaseTitle/state/model/tokens/toolCalls/durationMs/resultPreview), `totalTokens`, `totalToolCalls`, `subagentCount`, `completedCount`. + - `Read()` bevorzugt die reiche, fertige `.json` und fällt sonst auf das Live-`journal.jsonl` zurück. +- **Persistierung:** Spalte `chains.workflow_info` (TEXT, via `TryAddColumn` in `DatabaseService.cs`; geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`), Feld `ChainRecord.WorkflowInfo`. Beim `FinalizeChain` (`AnthropicService.cs`) wird die reiche `WorkflowProgress`-JSON in die Chain serialisiert und der Tracker-Eintrag freigegeben – mit **ChainId-Abgleich** (`ChainIdFor`), damit ein anderer Finalize-Pfad derselben Session (z.B. ein Panel-Member) den Workflow-Eintrag nicht fälschlich abräumt. +- **Endpoint:** `GET /api/sessions/{id}/workflow-status` (`SessionEndpoint.HandleWorkflowStatus`) liefert in dieser Reihenfolge: (1) ist für die Session ein Lauf registriert → Telemetrie aus den Transcript-Dateien lesen (`running` aus `journal.jsonl` / `completed` aus `.json`); (2) sonst die jüngste aktive Chain mit nicht-leerem `workflow_info` → `status: "completed"`; (3) sonst `status: "none"`, `workflow: null`. JSON-Shape: `running`/`completed` → `{ sessionId, streaming, status, chainId, workflow }`; `none` → `{ sessionId, streaming, status: "none", workflow: null }` (**kein `chainId` im `none`-Zweig**). -**Felder im `workflow`-Objekt** (camelCase): `subagentCount`, `subagentToolCalls`, `subagentInputTokens`, `subagentOutputTokens`, `toolBreakdown`, `recentTools`, `startedAt`, `lastActivityAt`. +**Felder im `workflow`-Objekt** (camelCase, via `WriteAsJsonAsync`): `status`, `chainId`, `workflowName`, `summary`, `startedAt`, `subagentCount`, `completedCount`, `totalTokens`, `totalToolCalls`, `durationMs`, `phases[]` (`index`, `title`, `detail`), `agents[]` (`index`, `label`, `phaseTitle`, `state`, `model`, `tokens`, `toolCalls`, `durationMs`, `resultPreview`). **Frontend:** - Header-Menü-Eintrag **"Workflow-Status"** (Button `#workflowStatusBtn` in `index.html`, direkt nach "Payload anzeigen") - API-Wrapper `apiGetWorkflowStatus(sessionId)` in `js/api.js` -- `showWorkflowStatusModal(sessionId)` in `js/modals.js` – rendert Status-Head, Statistik-Grid (Subagenten/Tool-Aufrufe/Input-/Output-Tokens), Tool-Aufschlüsselung und Zeitstempel. **Pollt alle 2 s**, solange `status === "running"`, und stoppt automatisch, sobald das Modal geschlossen wird (Overlay nicht mehr im DOM) oder der Workflow endet. +- `showWorkflowStatusModal(sessionId)` in `js/modals.js` – rendert Status-Head, Summen-Kacheln (Subagenten/Fertig/Tokens/Tool-Aufrufe), die **Phasen** und eine **Per-Agent-Liste** (Label, Phase, State, Tokens, Tool-Calls, Dauer). **Pollt alle 2 s**, solange `status === "running"`, und stoppt automatisch, sobald das Modal geschlossen wird (Overlay nicht mehr im DOM) oder der Workflow endet. - Click-Handler in `js/pages/chat-page.js`, Styles `.workflow-status-modal` (+ `.wf-*`) in `css/chat.css` --- diff --git a/ARCHITECTURE_QUICK.md b/ARCHITECTURE_QUICK.md index deeb1c8..b0c3e18 100644 --- a/ARCHITECTURE_QUICK.md +++ b/ARCHITECTURE_QUICK.md @@ -162,7 +162,7 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id) | **EmbeddingBackgroundService** | Backfill bestehender Messages + Live-Embedding neuer Messages/Memory-Files | | **RemoteConnectionManager** | WebSocket-basierte Remote-Verbindungen, Command-Execution auf Remote-Clients | | **StreamingTracker** | Tracking aktiver Streams + CancellationTokenSource pro Session für Stop | -| **WorkflowProgressTracker** | In-Memory-Telemetrie laufender dynamischer ultracode-Workflows pro Session (Subagent-Anzahl, Tool-Aufrufe + Aufschlüsselung, aggregierte Subagent-Tokens); LIVE während des Laufs, danach in `chains.workflow_info` persistiert (analog StreamingTracker) | +| **WorkflowProgressTracker** | **Registry** der Transcript-Pfade pro Session für dynamische ultracode-Workflows (`WorkflowRunRef`: ChainId/RunId/JournalPath/RichJsonPath/StartedAt) – kein Stream-Akkumulator. Die Telemetrie wird on demand über `WorkflowTelemetryReader` aus den CLI-Transcript-Dateien gelesen: `journal.jsonl` (live) bzw. `.json` (abgeschlossen, reich) und beim Finalize in `chains.workflow_info` persistiert | | **AppPaths** | Zentrale Pfade (WwwRoot, Dev vs. Publish-Modus) (static) | | **KeyScannerService** | API-Key-Scanner (GitHub Code Search, GitHub Issues/PRs, HuggingFace Hub, Google/Serper), täglicher Scan | | **GitHubEventScannerService** | Echtzeit-Scanner: GitHub Public Events API (PushEvent-Diffs nach geleakten Keys), 30s Poll-Intervall | @@ -358,7 +358,7 @@ Zusätzlich zum Worker kann eine Session ein **Panel** aus mehreren Agenten habe **Member streamen LIVE** (wie der Session-Agent): CLI forwardet `TextDelta`/`ToolStart`/`ToolResult`, Anthropic-API streamt pro Tool-Runde über `NonStreamingLoopCallbacks`, OpenAI = one-shot (Text am Ende). Ein `SemaphoreSlim`-Write-Lock in `SseHelper.TryWriteSse` serialisiert die SSE-Byte-Flushes paralleler Member (sonst vermischen sich die Frames). -**Subagent-Filterung:** Spawnt ein CLI-Member intern einen Subagenten (Claude-CLI-`Agent`-Tool), sendet die CLI dessen Interna als separate Top-Level-Events mit `parent_tool_use_id`. Der Parser (`ClaudeCliService.RunCliStreamingAsync`) verwirft diese **zentral am Ursprung** (Guard auf nicht-leeres `parent_tool_use_id`) → keine namenlosen Tool-Boxen, weder im Live-Stream noch in der History (beide lesen aus denselben gefilterten `CliStreamEvent`s). Die `Agent`-Tool-Box selbst (`parent_tool_use_id=null`) bleibt sichtbar; der finale `result`-Event (mit den **aggregierten** Subagent-Tokens) wird nicht gefiltert → Token-Bilanz vollständig. Dieselben unterdrückten Subagent-Events werden zusätzlich **additiv** in den `WorkflowProgressTracker` abgegriffen (`TapSubagentEvent`, wirft nie) → speist den Workflow-Status, ohne den Guard/Stream zu verändern. +**Subagent-Filterung:** Spawnt ein CLI-Member intern einen Subagenten (Claude-CLI-`Agent`-Tool), sendet die CLI dessen Interna als separate Top-Level-Events mit `parent_tool_use_id`. Der Parser (`ClaudeCliService.RunCliStreamingAsync`) verwirft diese **zentral am Ursprung** (Guard auf nicht-leeres `parent_tool_use_id`) → keine namenlosen Tool-Boxen, weder im Live-Stream noch in der History (beide lesen aus denselben gefilterten `CliStreamEvent`s). Die `Agent`-Tool-Box selbst (`parent_tool_use_id=null`) bleibt sichtbar; der finale `result`-Event (mit den **aggregierten** Subagent-Tokens) wird nicht gefiltert → Token-Bilanz vollständig. Der Guard blendet diese klassischen Subagent-Events nur **aus** (`continue`) – er tappt sie nicht mehr ab. Der Workflow-Status der dynamischen ultracode-Läufe liest seine Telemetrie ohnehin aus den Transcript-Dateien (siehe unten), nicht aus dem Stream. **Backend:** `ChatService.PrepareContext` baut `ChatContext.PanelAgents` (`List`) + `MoaLayers`. `RunPanel(layers)` (AnthropicService.Panel.cs) läuft im ChatEndpoint-try VOR dem Worker-Dispatch: pro Runde Member parallel (`Task.WhenAll` + `SemaphoreSlim`), Provider-Weiche pro Member, **jede** Runde als `[Panel: Name]`-Message mit `Tag="panel"` persistiert (aus Worker-History gefiltert); JSON-Transkript = `{name, model, layer, totalLayers, segments:[Text/Tool…]}`. `NonStreamingLoopCallbacks.Persist=false` verhindert Doppel-Persistenz. Member-Tokens via `seedUsage` in Worker-`usage` gefaltet → Live-Badge **und** DB (kein separates Panel-Usage-Event). Synthese-Block in letzte User-Message gemergt. Panel-Fehler graceful. @@ -375,13 +375,23 @@ Zusätzlich zum Worker kann eine Session ein **Panel** aus mehreren Agenten habe ## Workflow-Status (dynamische ultracode-Workflows) -Live-Telemetrie der Subagenten, die ein **ultracode**-Lauf über das `Workflow`/`Task`-Tool der Claude CLI spawnt (diese Subagent-Events sind im Haupt-Stream/Verlauf bewusst ausgeblendet, siehe Subagent-Filterung oben). +Telemetrie eines **ultracode**-Laufs, den die Claude CLI über das `Workflow`-Tool startet. **Entscheidend:** Solche Workflows laufen als **detachter Hintergrund-Task mit eigenem Transcript-Verzeichnis** – ihre Subagent-Events fließen NICHT durch den Haupt-(`-p`)-Stream (anders als klassische Task-Subagenten mit gesetztem `parent_tool_use_id`). Die echte Telemetrie liegt in zwei Dateien des CLI-Transcripts und wird **on demand** gelesen, nicht aus dem Stream abgegriffen. -**Backend:** `WorkflowProgressTracker` (Singleton, lokale Instanz in `Program.cs`, weil `ClaudeCliService` befüllt und `AnthropicService` beim Finalize persistiert – dieselbe Instanz). `ClaudeCliService.TapSubagentEvent` → `RecordSubagent(...)` führt pro Session In-Memory: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung), aggregierte Input/Output-Tokens. Beim `FinalizeChain` (`AnthropicService.cs`) wird der Stand als JSON in `chains.workflow_info` (`ChainRecord.WorkflowInfo`, Migration via `TryAddColumn` in `DatabaseService.cs`, geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`) geschrieben – mit ChainId-Abgleich, damit ein Panel-Member derselben Session ihn nicht löscht. +**Capture:** Im tool_result-Pfad ruft `ClaudeCliService.TapWorkflowLaunch(sessionId, chainId, resultContent)` auf; enthält der Inhalt „Workflow launched", parst die Methode die Zeile „Transcript dir: " und ruft `_workflowTracker.Register(...)`. Rein additiv, wirft nie. (Der `parent_tool_use_id`-Guard blendet klassische Subagent-Events weiterhin aus dem sichtbaren Stream aus – er tappt sie aber NICHT mehr.) -**Endpoint:** `GET /api/sessions/{id}/workflow-status` → LIVE-Snapshot aus dem Tracker, sonst persistiertes `workflow_info` der jüngsten Chain, sonst `status: "none"`. JSON: `{ sessionId, streaming, status (running/completed/none), chainId, workflow }`; das `workflow`-Objekt trägt camelCase `subagentCount`, `subagentToolCalls`, `subagentInputTokens`, `subagentOutputTokens`, `toolBreakdown`, `recentTools`, `startedAt`, `lastActivityAt`. +**Registry:** `WorkflowProgressTracker` (Singleton, lokale Instanz in `Program.cs`, weil `ClaudeCliService` befüllt und `AnthropicService` beim Finalize liest/persistiert – dieselbe Instanz) ist NUR eine Registry, kein Stream-Akkumulator. Speichert pro Session einen `WorkflowRunRef { ChainId, RunId, JournalPath, RichJsonPath, StartedAt }`, abgeleitet aus der Transcript-Dir. Methoden: `Register` / `Get` / `ChainIdFor` / `End` – in-memory pro Session, defensiv. -**Frontend:** Header-Menü-Eintrag **„Workflow-Status"** (`#workflowStatusBtn` in `index.html`, direkt nach „Payload anzeigen"), Wrapper `apiGetWorkflowStatus` (`api.js`), `showWorkflowStatusModal` (`modals.js`, pollt alle 2 s solange `status === 'running'`, stoppt von selbst beim Schließen des Modals), Click-Handler in `pages/chat-page.js`, `.workflow-status-modal`-Styles in `css/chat.css`. +**Reader:** Die statische `WorkflowTelemetryReader`-Klasse liest die Telemetrie aus zwei Quellen: +- LIVE `/journal.jsonl` (append-only; pro Subagent `started`/`result`-Zeilen) → `ReadLive` liefert `status: "running"`, `subagentCount` (= Anzahl `started`), `completedCount` (= Anzahl `result`), `durationMs`. Keine Tokens/Tool-Calls live verfügbar. +- ABGESCHLOSSEN `/workflows/.json` (erst am Laufende geschrieben) → `ReadCompleted` liefert `status: "completed"`, `workflowName`, `summary`, `durationMs`, `phases[]` (index/title/detail), `agents[]` (index/label/phaseTitle/state/model/tokens/toolCalls/durationMs/resultPreview), `totalTokens`, `totalToolCalls`, `subagentCount`, `completedCount`. + +`Read()` bevorzugt die reiche, fertige `.json` und fällt sonst auf das Live-`journal.jsonl` zurück. + +**Persistenz:** Beim `FinalizeChain` (`AnthropicService.cs`) wird die reiche `WorkflowProgress`-JSON in `chains.workflow_info` (`ChainRecord.WorkflowInfo`, Migration via `TryAddColumn` in `DatabaseService.cs`, geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`) geschrieben – mit ChainId-Abgleich (`ChainIdFor`), damit ein Panel-Member derselben Session ihn nicht löscht; danach wird der Tracker-Eintrag via `End` freigegeben. + +**Endpoint:** `GET /api/sessions/{id}/workflow-status` (`SessionEndpoint.HandleWorkflowStatus`), Reihenfolge: (1) ist für die Session ein Lauf registriert → Telemetrie aus den Transcript-Dateien (running aus `journal.jsonl` / completed aus `.json`); (2) sonst die jüngste aktive Chain mit nicht-leerem `workflow_info` → `status: "completed"`; (3) sonst `status: "none"`, `workflow: null`. JSON (running/completed): `{ sessionId, streaming, status, chainId, workflow }`; im `none`-Zweig: `{ sessionId, streaming, status: "none", workflow: null }` (KEIN `chainId`). Das `workflow`-Objekt trägt camelCase `status`, `chainId`, `workflowName`, `summary`, `startedAt`, `subagentCount`, `completedCount`, `totalTokens`, `totalToolCalls`, `durationMs`, `phases[]` (index/title/detail), `agents[]` (index/label/phaseTitle/state/model/tokens/toolCalls/durationMs/resultPreview). + +**Frontend:** Header-Menü-Eintrag **„Workflow-Status"** (`#workflowStatusBtn` in `index.html`, direkt nach „Payload anzeigen"), Wrapper `apiGetWorkflowStatus` (`api.js`), `showWorkflowStatusModal` (`modals.js`, pollt alle 2 s solange `status === 'running'`, stoppt von selbst beim Schließen des Modals), Click-Handler in `pages/chat-page.js`, `.workflow-status-modal`-Styles in `css/chat.css`. Das Modal rendert **Phasen** + eine **Per-Agent-Liste** (Label, Phase, State, Tokens, Tool-Calls, Dauer) + Summen-Kacheln (`totalTokens`/`totalToolCalls`) – keine `toolBreakdown`/`recentTools` mehr. --- diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 4bb3fd8..e35dc3f 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -456,12 +456,17 @@ public static class SessionEndpoint var streaming = streamingTracker.IsStreaming(sessionId); - // 1) Live: ein Workflow läuft gerade (Tracker hat einen Eintrag). - var live = workflowTracker.GetSnapshot(sessionId); - if (live != null) + // 1) Aktueller Lauf registriert: Telemetrie aus den Transcript-Dateien lesen – + // live aus journal.jsonl (Counts), nach Abschluss aus wf_.json (reich). + var run = workflowTracker.Get(sessionId); + if (run != null) { - await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = "running", chainId = live.ChainId, workflow = live }); - return; + var wp = WorkflowTelemetryReader.Read(run); + if (wp != null) + { + await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = wp.Status, chainId = wp.ChainId, workflow = wp }); + return; + } } // 2) Sonst: zuletzt persistierte Workflow-Telemetrie aus den aktiven Chains der Session. diff --git a/README.md b/README.md index b8ea6e2..fe53b5c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Self-hosted AI-Coding-Agent mit **Anthropic Claude API** als Haupt-Provider (ink - **Modus-Menü** – Dropdown im Composer (Normal / Plan / Ultracode / Fusion / MoA); alle Modi sind transient und gelten nur für die nächste Anfrage - **Plan-Modus** – Read-only-Modus zum Analysieren und Planen ohne Änderungen - **Ultracode** – Composite-Modus, der eine einzelne Anfrage als nativen dynamischen Workflow ausführt (effort `xhigh`, gibt der Claude CLI die `Workflow`/`Task`-Tools frei; fächert die Arbeit über mehrere Subagenten auf). Nur Claude-Modelle (nicht GLM, nicht Plan-Modus) -- **Workflow-Status** – Live-Telemetrie laufender Ultracode-Workflows (Subagent-Anzahl, Tool-Aufrufe inkl. Aufschlüsselung, aggregierte Token); im Header-Menü pro Session abrufbar, persistiert nach Lauf-Ende +- **Workflow-Status** – Telemetrie laufender Ultracode-Workflows: live der Subagent-Fortschritt (gestartet/fertig) während des Laufs, nach Abschluss reiche Per-Agent-/Phasen-Telemetrie (Tokens, Tool-Calls, Dauer je Subagent); im Header-Menü pro Session abrufbar, persistiert nach Lauf-Ende - **Web-Search** – Integrierte Web-Suche mit Quellenangaben (URLs + Titel) in Chat und geteilten Nachrichten - **Multi-Provider** – Anthropic Claude (Haupt-Provider, inkl. Claude CLI), z.ai/GLM (Modell-IDs mit `glm:`-Prefix, laufen über die Claude CLI mit z.ai-Endpoint), Nvidia (g4f.space), Pollinations (kostenlos), Google Gemini (kostenloser Free Tier), OpenAI - **19 Tools** – `read_file`, `write_file`, `edit_file`, `list_files`, `delete_file`, `execute`, `background_execute`, `glob`, `grep`, `todo_write`, `memory_read`, `memory_write`, `memory_list`, `memory_search`, `remote_execute`, `send_to_session`, `ask_user_choice`, `github_code_search`, `serper_web_search` diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 1ed4e4d..be5f4c6 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -329,7 +329,18 @@ public partial class AnthropicService if (_workflowTracker != null && !string.IsNullOrEmpty(chain.SessionId) && _workflowTracker.ChainIdFor(chain.SessionId) == chain.ChainId) { - chain.WorkflowInfo = _workflowTracker.GetJson(chain.SessionId); + try + { + // Reiche End-Telemetrie aus der wf_.json des Laufs lesen und persistieren + // (Fallback: Live-Counts aus dem journal.jsonl, falls die json noch nicht da ist). + var wp = WorkflowTelemetryReader.Read(_workflowTracker.Get(chain.SessionId)); + if (wp != null) + { + wp.ChainId = chain.ChainId; + chain.WorkflowInfo = JsonSerializer.Serialize(wp); + } + } + catch { /* Persistenz der Telemetrie darf den Finalize nie crashen */ } _workflowTracker.End(chain.SessionId); } return SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo); diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs index 93e334a..3f963d2 100644 --- a/Services/ClaudeCliService.cs +++ b/Services/ClaudeCliService.cs @@ -93,39 +93,35 @@ public class ClaudeCliService } /// - /// Greift aus einem (im Haupt-Stream unterdrückten) Subagent-Turn die Telemetrie ab: - /// die vom Subagenten aufgerufenen Tool-Namen und seine Token-Nutzung. Rein additiv für den - /// Workflow-Status – wirft nie (Telemetrie darf den Stream-Parser nicht stören). + /// Erkennt den tool_result des dynamischen Workflow-Tools ("Workflow launched in background … + /// Transcript dir: …") und registriert den Lauf im WorkflowProgressTracker. Der Workflow läuft + /// als detachter Hintergrund-Task mit eigenem Transcript – seine Live-/End-Telemetrie wird + /// später aus den dort liegenden Dateien (journal.jsonl / wf_<runId>.json) gelesen, nicht aus + /// dem Stream. Rein additiv – wirft nie (Telemetrie darf den Stream-Parser nicht stören). /// - private void TapSubagentEvent(string sessionId, string chainId, string parentToolUseId, JsonElement json) + private void TapWorkflowLaunch(string sessionId, string chainId, string resultContent) { try { - if (!json.TryGetProperty("message", out var msg)) + if (_workflowTracker == null || string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(resultContent)) + return; + if (resultContent.IndexOf("Workflow launched", StringComparison.OrdinalIgnoreCase) < 0) return; - var toolNames = new List(); - if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) + // Zeile "Transcript dir: " extrahieren. + string transcriptDir = null; + foreach (var raw in resultContent.Split('\n')) { - foreach (var item in content.EnumerateArray()) + var line = raw.Trim(); + const string marker = "Transcript dir:"; + if (line.StartsWith(marker, StringComparison.OrdinalIgnoreCase)) { - if (item.TryGetProperty("type", out var it) && it.GetString() == "tool_use" - && item.TryGetProperty("name", out var nm)) - { - var n = nm.GetString(); - if (!string.IsNullOrEmpty(n)) toolNames.Add(n); - } + transcriptDir = line.Substring(marker.Length).Trim(); + break; } } - - long inTok = 0, outTok = 0; - if (msg.TryGetProperty("usage", out var usage)) - { - if (usage.TryGetProperty("input_tokens", out var it2) && it2.ValueKind == JsonValueKind.Number) inTok = it2.GetInt64(); - if (usage.TryGetProperty("output_tokens", out var ot2) && ot2.ValueKind == JsonValueKind.Number) outTok = ot2.GetInt64(); - } - - _workflowTracker.RecordSubagent(sessionId, chainId, parentToolUseId, toolNames, inTok, outTok); + if (!string.IsNullOrEmpty(transcriptDir)) + _workflowTracker.Register(sessionId, chainId, transcriptDir); } catch { /* Telemetrie darf den Stream nie stören */ } } @@ -553,12 +549,11 @@ public class ClaudeCliService && parentToolUseId.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(parentToolUseId.GetString())) { - // Subagent-Event: weiterhin aus dem Haupt-Stream AUSBLENDEN (continue) – - // hier wird nur die Telemetrie für den Workflow-Status ABGEGRIFFEN - // (Tool-Aufrufe + Subagent-Tokens), ohne den sichtbaren Verlauf zu verändern. - // Die konsolidierten Subagent-Turns kommen als type:"assistant" (mit usage). - if (_workflowTracker != null && !string.IsNullOrEmpty(sessionId) && type == "assistant") - TapSubagentEvent(sessionId, chainId, parentToolUseId.GetString(), json); + // Subagent-Event aus dem Haupt-Stream AUSBLENDEN (continue). Hinweis: Die + // dynamischen Workflow-Subagenten laufen out-of-band (eigener Hintergrund- + // Task + Transcript) und tauchen hier ohnehin NICHT auf – ihre Telemetrie + // wird aus den Transcript-Dateien gelesen (siehe TapWorkflowLaunch). Dieser + // Guard betrifft nur klassische Task-Subagenten (sichtbarer Verlauf bleibt sauber). continue; } @@ -775,6 +770,12 @@ public class ClaudeCliService ToolResultContent = resultContent, ToolResultIsError = resultIsError }); + + // Dynamischen Workflow-Start abgreifen (Transcript-Pfad merken), + // damit der /workflow-status-Endpoint die Telemetrie aus den + // Transcript-Dateien lesen kann. Stört den Stream nie. + if (!resultIsError) + TapWorkflowLaunch(sessionId, chainId, resultContent); } } } diff --git a/Services/WorkflowProgressTracker.cs b/Services/WorkflowProgressTracker.cs index 44ad0d6..a2ba659 100644 --- a/Services/WorkflowProgressTracker.cs +++ b/Services/WorkflowProgressTracker.cs @@ -1,131 +1,248 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Text.Json; -using System.Text.Json.Serialization; namespace Gott.Services; /// -/// Sammelt Live-Telemetrie laufender dynamischer Workflows ("ultracode"): die Aktivität der -/// Subagenten, die die Claude CLI über das Workflow-/Task-Tool spawnt. Diese Subagent-Events -/// werden im Haupt-Stream bewusst unterdrückt (parent_tool_use_id-Guard in ClaudeCliService) – -/// hier werden sie abgegriffen, damit der Workflow-Fortschritt LIVE (während des Laufs) und -/// danach (persistiert in chains.workflow_info) abrufbar ist. In-Memory pro Session, analog -/// StreamingTracker. Telemetrie darf den Stream nie blockieren → alles defensiv + lock-leicht. +/// Verfolgt dynamische Workflows ("ultracode"), die die Claude CLI über das Workflow-Tool +/// startet. WICHTIG: Diese Workflows laufen als detachter Hintergrund-Task mit eigenem +/// Transcript-Verzeichnis – ihre Subagent-Events fließen NICHT durch den Haupt-Stream (anders +/// als klassische Task-Subagenten mit parent_tool_use_id). Die echte Telemetrie liegt in zwei +/// Dateien des CLI-Transcripts: +/// • <transcriptDir>/journal.jsonl – LIVE (append-only: started/result pro Subagent) +/// • <proj>/workflows/<runId>.json – am ENDE geschrieben (reiche Per-Agent-Telemetrie) +/// Beides empirisch verifiziert. Dieser Tracker ist daher nur eine Registry: er merkt sich +/// pro Session die aus dem Workflow-tool_result abgegriffenen Pfade; gelesen wird erst bei Abruf +/// (Endpoint) bzw. beim Finalize. In-Memory pro Session, defensiv (nie werfend). /// public class WorkflowProgressTracker { - private readonly ConcurrentDictionary _progress - = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _runs + = new ConcurrentDictionary(); /// - /// Verbucht ein Subagent-Event (Tool-Aufrufe + Token-Nutzung eines Subagenten). - /// Lazy: legt den Eintrag bei der ERSTEN Subagent-Aktivität einer Session an, d.h. nur - /// echte Workflow-Läufe (mit gespawnten Subagenten) erzeugen überhaupt einen Eintrag. + /// Registriert einen gestarteten Workflow-Lauf für die Session (aus dem Workflow-tool_result + /// abgegriffene Transcript-Dir). Überschreibt einen früheren Lauf derselben Session. /// - public void RecordSubagent(string sessionId, string chainId, string parentToolUseId, - IReadOnlyList toolNames, long inputTokens, long outputTokens) + public void Register(string sessionId, string chainId, string transcriptDir) { - if (string.IsNullOrEmpty(sessionId)) return; - var wp = _progress.GetOrAdd(sessionId, _ => new WorkflowProgress + if (string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(transcriptDir)) return; + try { - ChainId = chainId, - StartedAt = DateTime.UtcNow - }); - lock (wp.Sync) - { - // Stale-Eintrag eines früheren, nicht sauber finalisierten Laufs (gleiche Session, - // andere Chain) → atomar für die neue Chain zurücksetzen, statt fälschlich zu mischen. - if (wp.ChainId != chainId) + var runId = Path.GetFileName(transcriptDir.TrimEnd('/')); + // = /subagents/workflows/ + var workflowsDir = Path.GetDirectoryName(transcriptDir); // /subagents/workflows + var subagentsDir = Path.GetDirectoryName(workflowsDir); // /subagents + var projDir = Path.GetDirectoryName(subagentsDir); // + var journalPath = Path.Combine(transcriptDir, "journal.jsonl"); + var richJsonPath = projDir != null ? Path.Combine(projDir, "workflows", runId + ".json") : null; + + _runs[sessionId] = new WorkflowRunRef { - wp.ChainId = chainId; - wp.StartedAt = DateTime.UtcNow; - wp.SubagentIds.Clear(); - wp.SubagentCount = 0; - wp.SubagentToolCalls = 0; - wp.SubagentInputTokens = 0; - wp.SubagentOutputTokens = 0; - wp.ToolBreakdown.Clear(); - wp.RecentTools.Clear(); - } - if (!string.IsNullOrEmpty(parentToolUseId) && wp.SubagentIds.Add(parentToolUseId)) - wp.SubagentCount = wp.SubagentIds.Count; - if (toolNames != null) - { - foreach (var tn in toolNames) - { - if (string.IsNullOrEmpty(tn)) continue; - wp.SubagentToolCalls++; - wp.ToolBreakdown[tn] = wp.ToolBreakdown.TryGetValue(tn, out var c) ? c + 1 : 1; - wp.RecentTools.Add(tn); - if (wp.RecentTools.Count > 12) wp.RecentTools.RemoveAt(0); - } - } - wp.SubagentInputTokens += inputTokens; - wp.SubagentOutputTokens += outputTokens; - wp.LastActivityAt = DateTime.UtcNow; + ChainId = chainId, + RunId = runId, + JournalPath = journalPath, + RichJsonPath = richJsonPath, + StartedAt = DateTime.UtcNow + }; } + catch { /* Telemetrie darf den Stream nie stören */ } } - /// Threadsicherer Live-Snapshot – null, wenn für die Session kein Workflow läuft. - public WorkflowProgress GetSnapshot(string sessionId) - => _progress.TryGetValue(sessionId, out var wp) ? wp.Snapshot() : null; + /// Registry-Eintrag der Session – null, wenn kein Workflow registriert ist. + public WorkflowRunRef Get(string sessionId) + => _runs.TryGetValue(sessionId, out var r) ? r : null; - /// Aktuellen Stand als JSON serialisieren (threadsicher) – null wenn kein Eintrag. - public string GetJson(string sessionId) - => _progress.TryGetValue(sessionId, out var wp) ? wp.ToJson() : null; - - /// ChainId des laufenden Workflow-Eintrags (für die Zuordnung beim Finalize). + /// ChainId des registrierten Workflow-Laufs (für die Zuordnung beim Finalize). public string ChainIdFor(string sessionId) - => _progress.TryGetValue(sessionId, out var wp) ? wp.ChainId : null; + => _runs.TryGetValue(sessionId, out var r) ? r.ChainId : null; /// Entfernt den Eintrag (beim Chain-Finalize aufzurufen). - public void End(string sessionId) => _progress.TryRemove(sessionId, out _); + public void End(string sessionId) => _runs.TryRemove(sessionId, out _); +} + +/// Registrierter Workflow-Lauf einer Session: ChainId + die Pfade seiner Telemetrie-Dateien. +public class WorkflowRunRef +{ + public string ChainId { get; set; } + public string RunId { get; set; } + public string JournalPath { get; set; } + public string RichJsonPath { get; set; } + public DateTime StartedAt { get; set; } } /// -/// Telemetrie eines dynamischen Workflows: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung) -/// und aggregierte Subagent-Tokens. Wird live geführt und beim Chain-Finalize als JSON in -/// chains.workflow_info persistiert. +/// Liest die Workflow-Telemetrie aus den CLI-Transcript-Dateien. Zwei Quellen, je nach Phase: +/// das append-only journal.jsonl (live, während des Laufs) und die reiche wf_<runId>.json +/// (erst bei Abschluss). Alle Methoden sind defensiv und liefern null statt zu werfen. +/// +public static class WorkflowTelemetryReader +{ + /// + /// Bevorzugt die reiche, fertige wf_<runId>.json (existiert nur nach Abschluss); + /// fällt sonst auf das live journal.jsonl zurück. Liefert null, wenn nichts lesbar ist. + /// + public static WorkflowProgress Read(WorkflowRunRef run) + { + if (run == null) return null; + var done = ReadCompleted(run.RichJsonPath); + if (done != null) { done.ChainId = run.ChainId; done.StartedAt = run.StartedAt; return done; } + var live = ReadLive(run.JournalPath, run.StartedAt); + if (live != null) { live.ChainId = run.ChainId; return live; } + return null; + } + + /// Reiche Telemetrie aus der wf_<runId>.json (am Laufende geschrieben). + public static WorkflowProgress ReadCompleted(string richJsonPath) + { + if (string.IsNullOrEmpty(richJsonPath) || !File.Exists(richJsonPath)) return null; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(richJsonPath)); + var root = doc.RootElement; + var wp = new WorkflowProgress + { + Status = "completed", + WorkflowName = GetStr(root, "workflowName"), + Summary = GetStr(root, "summary"), + DurationMs = GetLong(root, "durationMs"), + StartedAt = DateTime.UtcNow + }; + + // Phasen + if (root.TryGetProperty("phases", out var phases) && phases.ValueKind == JsonValueKind.Array) + { + int pi = 0; + foreach (var p in phases.EnumerateArray()) + wp.Phases.Add(new WorkflowPhaseInfo + { + Index = ++pi, + Title = GetStr(p, "title"), + Detail = GetStr(p, "detail") + }); + } + + // Per-Agent aus workflowProgress (type == "workflow_agent") + if (root.TryGetProperty("workflowProgress", out var prog) && prog.ValueKind == JsonValueKind.Array) + { + foreach (var ev in prog.EnumerateArray()) + { + if (GetStr(ev, "type") != "workflow_agent") continue; + var a = new WorkflowAgentInfo + { + Index = (int)GetLong(ev, "index"), + Label = GetStr(ev, "label"), + PhaseTitle = GetStr(ev, "phaseTitle"), + State = GetStr(ev, "state"), + Model = GetStr(ev, "model"), + Tokens = GetLong(ev, "tokens"), + ToolCalls = (int)GetLong(ev, "toolCalls"), + DurationMs = GetLong(ev, "durationMs"), + ResultPreview = Trim(GetStr(ev, "resultPreview"), 240) + }; + wp.Agents.Add(a); + wp.TotalTokens += a.Tokens; + wp.TotalToolCalls += a.ToolCalls; + } + } + + wp.SubagentCount = wp.Agents.Count; + wp.CompletedCount = wp.Agents.Count(x => x.State == "done"); + if (wp.SubagentCount == 0) wp.SubagentCount = (int)GetLong(root, "agentCount"); + return wp; + } + catch { return null; } + } + + /// Live-Fortschritt aus dem append-only journal.jsonl (started/result pro Subagent). + public static WorkflowProgress ReadLive(string journalPath, DateTime startedAt) + { + if (string.IsNullOrEmpty(journalPath) || !File.Exists(journalPath)) return null; + try + { + var started = new HashSet(); + var done = new HashSet(); + // ReadAllText statt ReadAllLines: robust gegen halb geschriebene letzte Zeile. + foreach (var raw in File.ReadAllText(journalPath).Split('\n')) + { + var line = raw.Trim(); + if (line.Length == 0) continue; + JsonElement ev; + try { using var d = JsonDocument.Parse(line); ev = d.RootElement.Clone(); } + catch { continue; } + var t = GetStr(ev, "type"); + var aid = GetStr(ev, "agentId"); + if (string.IsNullOrEmpty(aid)) continue; + if (t == "started") started.Add(aid); + else if (t == "result") done.Add(aid); + } + if (started.Count == 0 && done.Count == 0) return null; + return new WorkflowProgress + { + Status = "running", + StartedAt = startedAt, + SubagentCount = started.Count, + CompletedCount = done.Count, + DurationMs = (long)Math.Max(0, (DateTime.UtcNow - startedAt).TotalMilliseconds) + }; + } + catch { return null; } + } + + private static string GetStr(JsonElement e, string name) + => e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() : null; + + private static long GetLong(JsonElement e, string name) + => e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + ? v.GetInt64() : 0L; + + private static string Trim(string s, int max) + => string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "…"; +} + +/// +/// Telemetrie eines dynamischen Workflows. Live (aus journal.jsonl): nur Status + Subagent-Zähler. +/// Abgeschlossen (aus wf_<runId>.json): zusätzlich Phasen, Per-Agent-Details (Tokens, Tool-Calls, +/// Dauer, Label, Phase) und Summen. Wird beim Chain-Finalize als JSON in chains.workflow_info +/// persistiert und vom /workflow-status-Endpoint ausgeliefert. /// public class WorkflowProgress { - [JsonIgnore] public object Sync { get; } = new object(); - // Dedup-Hilfsstruktur (nicht persistiert; SubagentCount trägt den Wert). - [JsonIgnore] public HashSet SubagentIds { get; set; } = new HashSet(); - + public string Status { get; set; } // "running" | "completed" public string ChainId { get; set; } + public string WorkflowName { get; set; } + public string Summary { get; set; } public DateTime StartedAt { get; set; } - public DateTime LastActivityAt { get; set; } public int SubagentCount { get; set; } - public int SubagentToolCalls { get; set; } - public long SubagentInputTokens { get; set; } - public long SubagentOutputTokens { get; set; } - public Dictionary ToolBreakdown { get; set; } = new Dictionary(); - public List RecentTools { get; set; } = new List(); + public int CompletedCount { get; set; } + public long TotalTokens { get; set; } + public int TotalToolCalls { get; set; } + public long DurationMs { get; set; } + public List Phases { get; set; } = new List(); + public List Agents { get; set; } = new List(); +} - public WorkflowProgress Snapshot() - { - lock (Sync) - { - return new WorkflowProgress - { - ChainId = ChainId, - StartedAt = StartedAt, - LastActivityAt = LastActivityAt, - SubagentCount = SubagentCount, - SubagentToolCalls = SubagentToolCalls, - SubagentInputTokens = SubagentInputTokens, - SubagentOutputTokens = SubagentOutputTokens, - ToolBreakdown = new Dictionary(ToolBreakdown), - RecentTools = new List(RecentTools) - }; - } - } +public class WorkflowPhaseInfo +{ + public int Index { get; set; } + public string Title { get; set; } + public string Detail { get; set; } +} - // Serialisiert einen entkoppelten Snapshot (Snapshot() kopiert unter Lock) – so läuft die - // Serialisierung außerhalb jeder kritischen Sektion und kann nie auf eine live mutierte - // Collection treffen. - public string ToJson() => JsonSerializer.Serialize(Snapshot()); +public class WorkflowAgentInfo +{ + public int Index { get; set; } + public string Label { get; set; } + public string PhaseTitle { get; set; } + public string State { get; set; } // "done" | "running" | "queued" | … + public string Model { get; set; } + public long Tokens { get; set; } + public int ToolCalls { get; set; } + public long DurationMs { get; set; } + public string ResultPreview { get; set; } } diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index fbd9bc9..98ef4e8 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -1025,9 +1025,27 @@ opacity: var(--semi-transparent); } +.wf-title { + display: flex; + flex-direction: column; + gap: .15em; + margin-bottom: var(--box-spacing); +} + +.wf-name { + font-family: var(--mono); + color: var(--text-accent); + font-weight: 600; +} + +.wf-summary { + font-size: .9em; + opacity: var(--semi-transparent); +} + .wf-stat-grid { display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: var(--badge-spacing); } @@ -1060,27 +1078,91 @@ margin-bottom: var(--badge-spacing); } -.wf-tool-list { +/* Phasen-Liste */ +.wf-phase-list { display: flex; flex-direction: column; gap: var(--border-width); } -.wf-tool-row { +.wf-phase-row { display: flex; - justify-content: space-between; + align-items: baseline; + gap: var(--badge-spacing); padding: .35em var(--badge-spacing); background: var(--f3-bg); border-radius: var(--border-radius); } -.wf-tool-name { - font-family: var(--mono); +.wf-phase-title { + font-weight: 600; } -.wf-tool-count { - color: var(--primary); +.wf-phase-detail { + font-size: .85em; + opacity: var(--semi-transparent); +} + +/* Subagenten-Liste */ +.wf-agent-list { + display: flex; + flex-direction: column; + gap: var(--border-width); +} + +.wf-agent-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--badge-spacing); + padding: .4em var(--badge-spacing); + background: var(--f3-bg); + border-radius: var(--border-radius); +} + +.wf-agent-main { + display: flex; + align-items: center; + gap: var(--badge-spacing); + min-width: 0; +} + +.wf-agent-state { + width: .6em; + height: .6em; + border-radius: 50%; + flex-shrink: 0; + background: var(--text); +} + +.wf-agent-state.wf-ag-done { background: var(--secondary); } +.wf-agent-state.wf-ag-running { background: var(--info); } +.wf-agent-state.wf-ag-other { background: var(--primary); opacity: var(--semi-transparent); } + +.wf-agent-label { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wf-agent-phase { + font-size: .8em; + opacity: var(--semi-transparent); + white-space: nowrap; +} + +.wf-agent-metrics { + display: flex; + gap: var(--box-spacing); font-family: var(--mono); + font-size: .82em; + opacity: var(--semi-transparent); + flex-shrink: 0; +} + +.wf-agent-metrics span { + white-space: nowrap; } .wf-meta { diff --git a/wwwroot/js/modals.js b/wwwroot/js/modals.js index ddf19b7..f362b2d 100644 --- a/wwwroot/js/modals.js +++ b/wwwroot/js/modals.js @@ -100,6 +100,14 @@ async function showPayloadPreviewModal(sessionId) { function _wfNum(n) { return Number(n || 0).toLocaleString('de-DE'); } function _wfTime(t) { try { return new Date(t).toLocaleTimeString('de-DE'); } catch (e) { return ''; } } +function _wfDur(ms) { + ms = Number(ms || 0); + if (ms <= 0) return '–'; + var s = Math.round(ms / 1000); + if (s < 60) return s + 's'; + var m = Math.floor(s / 60); + return m + 'm ' + (s % 60) + 's'; +} function _renderWorkflowStatus(body, data) { var status = (data && data.status) || 'none'; @@ -127,11 +135,22 @@ function _renderWorkflowStatus(body, data) { return; } + // Name / Zusammenfassung des Workflows (nur nach Abschluss vorhanden). + if (wf.workflowName || wf.summary) { + var title = document.createElement('div'); + title.className = 'wf-title'; + title.innerHTML = (wf.workflowName ? '' + esc(wf.workflowName) + '' : '') + + (wf.summary ? '' + esc(wf.summary) + '' : ''); + body.appendChild(title); + } + + // Kennzahlen. var stats = [ { label: 'Subagenten', value: _wfNum(wf.subagentCount) }, - { label: 'Tool-Aufrufe', value: _wfNum(wf.subagentToolCalls) }, - { label: 'Input-Tokens', value: _wfNum(wf.subagentInputTokens) }, - { label: 'Output-Tokens', value: _wfNum(wf.subagentOutputTokens) } + { label: 'Fertig', value: _wfNum(wf.completedCount) + ' / ' + _wfNum(wf.subagentCount) }, + { label: 'Tokens', value: _wfNum(wf.totalTokens) }, + { label: 'Tool-Aufrufe', value: _wfNum(wf.totalToolCalls) }, + { label: status === 'running' ? 'Läuft seit' : 'Dauer', value: _wfDur(wf.durationMs) } ]; var grid = document.createElement('div'); grid.className = 'wf-stat-grid'; @@ -144,30 +163,59 @@ function _renderWorkflowStatus(body, data) { }); body.appendChild(grid); - var tb = wf.toolBreakdown || {}; - var keys = Object.keys(tb).sort(function (a, b) { return tb[b] - tb[a]; }); - if (keys.length) { - var sec = document.createElement('div'); - sec.className = 'wf-section'; - sec.innerHTML = '
Tool-Aufschlüsselung
'; - var list = document.createElement('div'); - list.className = 'wf-tool-list'; - keys.forEach(function (k) { + // Phasen (nur nach Abschluss). + var phases = wf.phases || []; + if (phases.length) { + var psec = document.createElement('div'); + psec.className = 'wf-section'; + psec.innerHTML = '
Phasen
'; + var plist = document.createElement('div'); + plist.className = 'wf-phase-list'; + phases.forEach(function (p) { var row = document.createElement('div'); - row.className = 'wf-tool-row'; - row.innerHTML = '' + esc(k) + - '' + esc(String(tb[k])) + ''; - list.appendChild(row); + row.className = 'wf-phase-row'; + row.innerHTML = '' + esc(p.title || '') + '' + + (p.detail ? '' + esc(p.detail) + '' : ''); + plist.appendChild(row); }); - sec.appendChild(list); - body.appendChild(sec); + psec.appendChild(plist); + body.appendChild(psec); + } + + // Subagenten-Details (nur nach Abschluss; live liegen noch keine Labels vor). + var agents = wf.agents || []; + if (agents.length) { + var asec = document.createElement('div'); + asec.className = 'wf-section'; + asec.innerHTML = '
Subagenten
'; + var alist = document.createElement('div'); + alist.className = 'wf-agent-list'; + agents.forEach(function (a) { + var st = a.state || ''; + var stCls = st === 'done' ? 'wf-ag-done' : (st === 'running' ? 'wf-ag-running' : 'wf-ag-other'); + var row = document.createElement('div'); + row.className = 'wf-agent-row'; + row.innerHTML = + '
' + + '' + + '' + esc(a.label || ('Agent ' + (a.index || ''))) + '' + + (a.phaseTitle ? '' + esc(a.phaseTitle) + '' : '') + + '
' + + '
' + + ' ' + _wfNum(a.tokens) + '' + + ' ' + _wfNum(a.toolCalls) + '' + + ' ' + _wfDur(a.durationMs) + '' + + '
'; + alist.appendChild(row); + }); + asec.appendChild(alist); + body.appendChild(asec); } if (wf.startedAt) { var ts = document.createElement('div'); ts.className = 'wf-meta'; - ts.textContent = 'Start: ' + _wfTime(wf.startedAt) + - (wf.lastActivityAt ? ' · letzte Aktivität: ' + _wfTime(wf.lastActivityAt) : ''); + ts.textContent = 'Start: ' + _wfTime(wf.startedAt); body.appendChild(ts); } }