Merge branch 'main' into feature/human-evaluation-node
This commit is contained in:
commit
3d4b49e030
|
|
@ -41,3 +41,12 @@ __screenshots__/
|
|||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Working notes and local tooling, not part of the published source
|
||||
.claude/
|
||||
docs/project-memory.md
|
||||
docs/assistant-store-implementation-note.md
|
||||
docs/assistant-ui-guide.md
|
||||
docs/bias-impact-experiments-plan.md
|
||||
docs/vault-credential-gate-frontend.md
|
||||
docs/vault-credential-gate-tasks.md
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ authors:
|
|||
affiliation: >-
|
||||
Institute of Information Science and Technologies "A. Faedo" (ISTI-CNR), Pisa, Italy
|
||||
license: AGPL-3.0-or-later
|
||||
repository-code: "https://github.com/luciolelii/humanaiFlow-angular"
|
||||
repository-code: "https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web"
|
||||
keywords:
|
||||
- workflow
|
||||
- human-in-the-loop
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ or unmodified, you must preserve the following attribution:
|
|||
|
||||
Based on HumAIn Flow, originally developed by Lucio Lelii
|
||||
(Institute of Information Science and Technologies "A. Faedo" (ISTI-CNR), Pisa, Italy)
|
||||
https://github.com/luciolelii/humanaiFlow-angular
|
||||
https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web
|
||||
|
||||
The attribution must be preserved in both of the following places:
|
||||
|
||||
|
|
|
|||
2
NOTICE
2
NOTICE
|
|
@ -23,4 +23,4 @@ one:
|
|||
|
||||
Based on HumAIn Flow, originally developed by Lucio Lelii
|
||||
(Institute of Information Science and Technologies "A. Faedo" (ISTI-CNR), Pisa, Italy)
|
||||
https://github.com/luciolelii/humanaiFlow-angular
|
||||
https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ In practice this means:
|
|||
and where the interface shows its legal notices:
|
||||
|
||||
> Based on HumAIn Flow, originally developed by Lucio Lelii (ISTI-CNR)
|
||||
> — https://github.com/luciolelii/humanaiFlow-angular
|
||||
> — https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web
|
||||
|
||||
If you use HumAIn Flow in academic work, please cite it — see
|
||||
[CITATION.cff](CITATION.cff).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"author": "Lucio Lelii <lucio.lelii@isti.cnr.it> (ISTI-CNR)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/luciolelii/humanaiFlow-angular.git"
|
||||
"url": "git+https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web.git"
|
||||
},
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<app-global-notification />
|
||||
<app-confirm-dialog-host></app-confirm-dialog-host>
|
||||
<app-execution-error-dialog-host></app-execution-error-dialog-host>
|
||||
<app-execution-event-dialog-host></app-execution-event-dialog-host>
|
||||
<app-human-interaction-dialog-host></app-human-interaction-dialog-host>
|
||||
<app-node-settings-dialog-host></app-node-settings-dialog-host>
|
||||
<app-subflow-preview-dialog-host></app-subflow-preview-dialog-host>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
|||
import { RouterOutlet } from '@angular/router';
|
||||
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
|
||||
import { ExecutionErrorDialogHostComponent } from '@shared/execution-error-dialog/execution-error-dialog';
|
||||
import { ExecutionEventDialogHostComponent } from '@shared/execution-event-dialog/execution-event-dialog';
|
||||
import { GlobalNotificationComponent } from '@shared/global-notification/global-notification';
|
||||
import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog';
|
||||
import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog';
|
||||
|
|
@ -20,7 +21,7 @@ import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog';
|
|||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
|
||||
imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, ExecutionEventDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ExecutionEventDialogState = {
|
||||
/** A short name for the kind of event, used as the dialog title. */
|
||||
title: string;
|
||||
/** Where and when it happened: node name and time, when the event says. */
|
||||
subtitle: string;
|
||||
/** The event's own sentence, which is often a paragraph and belongs in the body, not the title. */
|
||||
message: string;
|
||||
/** Whatever the event recorded beyond that sentence, rendered as a tree. */
|
||||
details: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds the execution event currently opened for reading.
|
||||
*
|
||||
* <p>Separate from the settings dialog, which can only render fields of text: what an event carries
|
||||
* is a small object - counts, names, nested values - and reading it as a wall of JSON means counting
|
||||
* braces to find one number.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExecutionEventDialogService {
|
||||
private readonly _state = signal<ExecutionEventDialogState | null>(null);
|
||||
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
open(state: ExecutionEventDialogState) {
|
||||
this._state.set(state);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._state.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,8 +36,8 @@ export class AboutDialogComponent {
|
|||
readonly licenseUrl = 'https://www.gnu.org/licenses/agpl-3.0.html';
|
||||
|
||||
readonly sources = [
|
||||
{ label: 'Web interface', url: 'https://github.com/luciolelii/humanaiFlow-angular' },
|
||||
{ label: 'Backend service', url: 'https://github.com/luciolelii/humainflow-service' }
|
||||
{ label: 'Web interface', url: 'https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web' },
|
||||
{ label: 'Backend service', url: 'https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-service' }
|
||||
];
|
||||
|
||||
close(event?: Event) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
*/
|
||||
|
||||
.execution-event__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* The event's sentence: prose, so it reads as prose rather than as a heading. */
|
||||
.execution-event__message {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.execution-event__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.execution-event__copy {
|
||||
--mdc-outlined-button-label-text-color: #475569;
|
||||
}
|
||||
|
||||
.execution-event__details {
|
||||
max-height: 55vh;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
-->
|
||||
|
||||
@if (state(); as dialogState) {
|
||||
<app-modal-shell
|
||||
[title]="dialogState.title"
|
||||
[subtitle]="dialogState.subtitle"
|
||||
ariaLabel="Execution event details"
|
||||
maxWidth="680px"
|
||||
(backdropClick)="close()"
|
||||
(closeClick)="close()">
|
||||
|
||||
<div class="execution-event__body">
|
||||
@if (dialogState.message) {
|
||||
<p class="execution-event__message">{{ dialogState.message }}</p>
|
||||
}
|
||||
|
||||
<div class="execution-event__actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="execution-event__copy"
|
||||
aria-label="Copy the event details"
|
||||
(click)="copyDetails($event)">
|
||||
<mat-icon [fontIcon]="copied() ? 'check' : 'content_copy'"></mat-icon>
|
||||
<span>{{ copied() ? 'Copied' : 'Copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="execution-event__details">
|
||||
<app-json-viewer [value]="dialogState.details"></app-json-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ExecutionEventDialogService } from '@services/dialogs/execution-event-dialog';
|
||||
import { JsonViewerComponent } from '@shared/json-viewer/json-viewer';
|
||||
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
|
||||
|
||||
/**
|
||||
* Reads what one execution event recorded.
|
||||
*
|
||||
* <p>The numbers behind an event - characters over budget, tool names, durations - are a small
|
||||
* object, so they are shown as one: a tree that can be scanned, not a block of JSON to be parsed by
|
||||
* eye. The event's own sentence sits in the body rather than the title, because several of them run
|
||||
* to a full paragraph and a title that wraps five lines reads as an error page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-execution-event-dialog-host',
|
||||
imports: [MatButtonModule, MatIconModule, ModalShellComponent, JsonViewerComponent],
|
||||
templateUrl: './execution-event-dialog.html',
|
||||
styleUrl: './execution-event-dialog.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ExecutionEventDialogHostComponent {
|
||||
private readonly dialog = inject(ExecutionEventDialogService);
|
||||
|
||||
readonly state = this.dialog.state;
|
||||
readonly copied = signal(false);
|
||||
|
||||
private copiedReset: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
close() {
|
||||
this.dialog.close();
|
||||
}
|
||||
|
||||
/** Copies the details as JSON: what gets pasted into an issue is the data, not the tree drawing. */
|
||||
async copyDetails(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const details = this.state()?.details;
|
||||
if (details === undefined) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
|
||||
this.copied.set(true);
|
||||
if (this.copiedReset) clearTimeout(this.copiedReset);
|
||||
this.copiedReset = setTimeout(() => this.copied.set(false), 2000);
|
||||
} catch {
|
||||
// Clipboard access can be refused; the tree is still there to read and select by hand.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import { TaskExecution, TaskExecutionStep } from '@models/task-execution';
|
|||
import {
|
||||
buildAuthorizationGate,
|
||||
buildVisibleExecutionLogs,
|
||||
executionEventTitle,
|
||||
isScrolledToEnd,
|
||||
getExecutionInputValues,
|
||||
getExecutionOutputValues,
|
||||
hasStoredValue,
|
||||
|
|
@ -45,6 +47,69 @@ describe('execution viewer runtime values', () => {
|
|||
})).toEqual({ approve: 'Candidate evidence' });
|
||||
});
|
||||
|
||||
it('keeps the numbers an event recorded readable, so a warning can be opened instead of guessed at', () => {
|
||||
// The characters-over-budget in a pruning warning only lived in the API until now.
|
||||
const [event] = buildVisibleExecutionLogs([{
|
||||
id: 'event-1',
|
||||
timestamp: 1,
|
||||
type: 'LLM_CONTEXT_PRUNED',
|
||||
message: 'Still 6332 character(s) over budget',
|
||||
details: { iteration: 4, totalCharsAfter: 66332, budgetChars: 60000 }
|
||||
}]);
|
||||
|
||||
expect(event.detailsText).toContain('"totalCharsAfter": 66332');
|
||||
expect(event.detailsText).toContain('"budgetChars": 60000');
|
||||
});
|
||||
|
||||
it('keeps following while the end is in view, and lets go once the reader scrolls back', () => {
|
||||
// The whole point: a log that yanks itself to the newest line every poll cannot be read backwards.
|
||||
expect(isScrolledToEnd({ scrollHeight: 1000, scrollTop: 800, clientHeight: 200 })).toBe(true);
|
||||
expect(isScrolledToEnd({ scrollHeight: 1000, scrollTop: 0, clientHeight: 200 })).toBe(false);
|
||||
});
|
||||
|
||||
it('tolerates a line arriving between the scroll and the check', () => {
|
||||
// 20px short of the end: a list that just grew must not read as "the reader scrolled away".
|
||||
expect(isScrolledToEnd({ scrollHeight: 1020, scrollTop: 800, clientHeight: 200 })).toBe(true);
|
||||
// 200px short is a deliberate move back, and following stops.
|
||||
expect(isScrolledToEnd({ scrollHeight: 1200, scrollTop: 800, clientHeight: 200 })).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a list shorter than its viewport as being at the end', () => {
|
||||
expect(isScrolledToEnd({ scrollHeight: 120, scrollTop: 0, clientHeight: 400 })).toBe(true);
|
||||
});
|
||||
|
||||
it('names an event in two words, so a paragraph does not end up as a title', () => {
|
||||
// "Still 6332 character(s) over budget after pruning everything available..." set as a heading
|
||||
// wraps five lines and reads as an error page; the type says the same thing short.
|
||||
expect(executionEventTitle('LLM_CONTEXT_PRUNED')).toBe('LLM context pruned');
|
||||
expect(executionEventTitle('MCP_TOOL_CALL')).toBe('MCP tool call');
|
||||
expect(executionEventTitle('HUMAN_EVALUATION_RECORDED')).toBe('Human evaluation recorded');
|
||||
});
|
||||
|
||||
it('falls back to something sayable for an event type it has never seen', () => {
|
||||
expect(executionEventTitle('SOME_FUTURE_EVENT')).toBe('Some future event');
|
||||
expect(executionEventTitle(null)).toBe('Execution event');
|
||||
expect(executionEventTitle(' ')).toBe('Execution event');
|
||||
});
|
||||
|
||||
it('offers nothing to open when an event carries no details', () => {
|
||||
// A button that opens an empty box is worse than no button.
|
||||
const [noDetails] = buildVisibleExecutionLogs([{ id: 'a', timestamp: 1, type: 'STEP_STARTED' }]);
|
||||
const [emptyDetails] = buildVisibleExecutionLogs([{ id: 'b', timestamp: 1, type: 'STEP_STARTED', details: {} }]);
|
||||
|
||||
expect(noDetails.detailsText).toBeNull();
|
||||
expect(emptyDetails.detailsText).toBeNull();
|
||||
});
|
||||
|
||||
it('still shows something for a payload that will not serialise', () => {
|
||||
const cyclic: Record<string, unknown> = { tool: 'read_file' };
|
||||
cyclic['self'] = cyclic;
|
||||
|
||||
const [event] = buildVisibleExecutionLogs([{ id: 'c', timestamp: 1, type: 'MCP_TOOL_CALL', details: cyclic }]);
|
||||
|
||||
expect(event.detailsText).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces inner subflow identifiers from bias experiment events', () => {
|
||||
const [event] = buildVisibleExecutionLogs([{
|
||||
id: 'event-1',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ export type ExecutionIntermediateInputGroup = {
|
|||
|
||||
export type ExecutionLogEntryView = ExecutionEventLogEntry & {
|
||||
messageText: string;
|
||||
/** The event's own details, formatted for reading, or null when it carries none worth opening. */
|
||||
detailsText: string | null;
|
||||
levelText: string;
|
||||
innerExecutionId: string | null;
|
||||
innerNodeId: string | null;
|
||||
|
|
@ -245,6 +247,7 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu
|
|||
return {
|
||||
...entry,
|
||||
messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry),
|
||||
detailsText: formatExecutionLogDetails(entry.details),
|
||||
levelText: String(entry.level ?? 'INFO').toUpperCase(),
|
||||
innerExecutionId: nonEmptyLogDetail(details['innerExecutionId']),
|
||||
innerNodeId: nonEmptyLogDetail(details['innerNodeId']),
|
||||
|
|
@ -255,6 +258,70 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How close to the end still counts as being at the end, in pixels.
|
||||
*
|
||||
* <p>Not zero: a list that grew by a line between the scroll and the handler would otherwise read as
|
||||
* "the reader scrolled away", and following would stop on its own while nobody touched anything.
|
||||
*/
|
||||
export const LOGS_FOLLOW_THRESHOLD_PX = 32;
|
||||
|
||||
/** Whether a scrolling viewport is close enough to its end for a log to keep following it. */
|
||||
export function isScrolledToEnd(viewport: { scrollHeight: number; scrollTop: number; clientHeight: number }): boolean {
|
||||
const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
return distanceFromBottom <= LOGS_FOLLOW_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
/** Initialisms that read as shouting when title-cased, and as typos when lower-cased. */
|
||||
const EVENT_TITLE_INITIALISMS = new Set(['llm', 'mcp', 'http', 'id']);
|
||||
|
||||
/**
|
||||
* A short name for a kind of event, for the head of a dialog.
|
||||
*
|
||||
* <p>The event's own message is regularly a full paragraph - "Still 6332 character(s) over budget
|
||||
* after pruning everything available..." - and a paragraph set as a title wraps five lines and reads
|
||||
* as an error page. The type says the same thing in two words, and the message keeps its place in
|
||||
* the body.
|
||||
*/
|
||||
export function executionEventTitle(type: string | null | undefined): string {
|
||||
const raw = String(type ?? '').trim();
|
||||
if (!raw) return 'Execution event';
|
||||
|
||||
const words = raw.toLowerCase().split(/[_\s]+/).filter(Boolean);
|
||||
if (!words.length) return 'Execution event';
|
||||
|
||||
return words
|
||||
.map((word, index) => {
|
||||
if (EVENT_TITLE_INITIALISMS.has(word)) return word.toUpperCase();
|
||||
return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* What an event carries beyond its sentence - iteration numbers, character counts, tool names.
|
||||
*
|
||||
* <p>Formatted here rather than in the template because the decision of what is worth opening
|
||||
* belongs with the data: an event with no details, or an empty map, must not offer a button that
|
||||
* opens nothing.
|
||||
*/
|
||||
export function formatExecutionLogDetails(details: unknown): string | null {
|
||||
if (details == null) return null;
|
||||
if (typeof details !== 'object') {
|
||||
const text = String(details).trim();
|
||||
return text || null;
|
||||
}
|
||||
if (Array.isArray(details) ? details.length === 0 : Object.keys(details).length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
// A cyclic or otherwise unserialisable payload is still worth something to a reader.
|
||||
return String(details);
|
||||
}
|
||||
}
|
||||
|
||||
function nonEmptyLogDetail(value: unknown): string | null {
|
||||
const normalized = value == null ? '' : String(value).trim();
|
||||
return normalized || null;
|
||||
|
|
|
|||
|
|
@ -465,6 +465,40 @@
|
|||
font-size: 11px;
|
||||
}
|
||||
|
||||
.execution-log-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Quiet until hovered: one of these sits on every row that carries details. */
|
||||
.execution-log-details-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.execution-log-details-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.execution-log-details-btn mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.execution-log-level {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -495,8 +529,56 @@
|
|||
color: #991b1b;
|
||||
}
|
||||
|
||||
.execution-logs-scroll {
|
||||
.execution-logs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.execution-logs-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 12px 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.execution-logs-follow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.execution-logs-follow:hover {
|
||||
border-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Held position: stated plainly, since a log that stopped moving on its own looks like a log that stopped. */
|
||||
.execution-logs-follow-paused {
|
||||
border-color: #fcd34d;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.execution-logs-follow mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.execution-logs-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -443,7 +443,22 @@
|
|||
}
|
||||
</div>
|
||||
} @else if (activeAsideTab() === 'logs') {
|
||||
<div #logsScrollViewport class="execution-logs-scroll p-3 space-y-3">
|
||||
<div class="execution-logs-panel">
|
||||
<div class="execution-logs-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class="execution-logs-follow"
|
||||
[class.execution-logs-follow-paused]="!followLogs()"
|
||||
[attr.aria-pressed]="!followLogs()"
|
||||
[matTooltip]="followLogs()
|
||||
? 'Following the newest events - click to hold the position'
|
||||
: 'Scrolling is held - click to follow the newest events again'"
|
||||
(click)="toggleFollowLogs($event)">
|
||||
<mat-icon [fontIcon]="followLogs() ? 'vertical_align_bottom' : 'pause'"></mat-icon>
|
||||
<span>{{ followLogs() ? 'Following' : 'Paused' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div #logsScrollViewport class="execution-logs-scroll p-3 space-y-3" (scroll)="onLogsScrolled()">
|
||||
@if (logsLoading()) {
|
||||
<div class="text-xs text-slate-500">Loading execution log...</div>
|
||||
} @else if (logsError()) {
|
||||
|
|
@ -484,13 +499,26 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="execution-log-level" [ngClass]="logLevelClass(event.level)">
|
||||
{{ event.levelText }}
|
||||
</span>
|
||||
<div class="execution-log-actions">
|
||||
@if (event.detailsText) {
|
||||
<button
|
||||
type="button"
|
||||
class="execution-log-details-btn"
|
||||
matTooltip="View event details"
|
||||
[attr.aria-label]="'View details of ' + event.messageText"
|
||||
(click)="openEventDetails(event, $event)">
|
||||
<mat-icon fontIcon="visibility"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
<span class="execution-log-level" [ngClass]="logLevelClass(event.level)">
|
||||
{{ event.levelText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (activeAsideTab() === 'output') {
|
||||
<div class="p-3 space-y-3">
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
HumanInteractionDialogService
|
||||
} from '@services/dialogs/human-interaction-dialog';
|
||||
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
import { ExecutionEventDialogService } from '@services/dialogs/execution-event-dialog';
|
||||
import {
|
||||
LLMDescriptorWithCredential,
|
||||
openLLMDescriptorSettingsWithCredential
|
||||
|
|
@ -81,6 +82,8 @@ import {
|
|||
stringifyOutputValue,
|
||||
formatDuration,
|
||||
fallbackExecutionLogMessage,
|
||||
executionEventTitle,
|
||||
isScrolledToEnd,
|
||||
logLevelClass as _logLevelClass,
|
||||
logTypeIcon as _logTypeIcon,
|
||||
inheritedSimulator as _inheritedSimulator,
|
||||
|
|
@ -140,10 +143,12 @@ function uploadFailureMessage(failure: unknown): string {
|
|||
})
|
||||
export class TaskExecutionViewerComponent implements OnDestroy {
|
||||
private static readonly EVENTS_POLL_INTERVAL_MS = 5000;
|
||||
|
||||
private taskExecutionsService = inject(TaskExecutionsService);
|
||||
private flowsService = inject(FlowsService);
|
||||
private humanInteractionDialog = inject(HumanInteractionDialogService);
|
||||
private settingsDialog = inject(NodeSettingsDialogService);
|
||||
private eventDialog = inject(ExecutionEventDialogService);
|
||||
private fieldRetriever = inject(FieldRetriever);
|
||||
private containersService = inject(ContainersService);
|
||||
private blocksService = inject(BlocksService);
|
||||
|
|
@ -249,6 +254,14 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
readonly logsLoading = signal(false);
|
||||
readonly logsError = signal<string | null>(null);
|
||||
private readonly logsScrollViewport = viewChild<ElementRef<HTMLDivElement>>('logsScrollViewport');
|
||||
|
||||
/**
|
||||
* Whether the log sticks to the newest line.
|
||||
*
|
||||
* <p>On while the bottom is in view, off as soon as the reader scrolls away from it: a panel that
|
||||
* yanks itself back every poll cannot be read backwards, which is exactly when a log matters.
|
||||
*/
|
||||
readonly followLogs = signal(true);
|
||||
private sourceFlowRequestVersion = 0;
|
||||
private readonly sourceFlowCache = new Map<string, FlowData>();
|
||||
private readonly requestedCredentialProviders = new Set<string>();
|
||||
|
|
@ -1555,10 +1568,52 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
|
||||
private scrollLogsToBottom() {
|
||||
const element = this.logsScrollViewport()?.nativeElement;
|
||||
if (!element || this.activeAsideTab() !== 'logs') return;
|
||||
if (!element || this.activeAsideTab() !== 'logs' || !this.followLogs()) return;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the toggle honest about what the panel is doing.
|
||||
*
|
||||
* <p>Reaching the bottom by hand resumes following, so the common case - read back, then return to
|
||||
* the live end - needs no button at all. The margin covers a list that grew by a line between the
|
||||
* scroll and this handler.
|
||||
*/
|
||||
onLogsScrolled() {
|
||||
const element = this.logsScrollViewport()?.nativeElement;
|
||||
if (!element) return;
|
||||
this.followLogs.set(isScrolledToEnd(element));
|
||||
}
|
||||
|
||||
toggleFollowLogs(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
const following = !this.followLogs();
|
||||
this.followLogs.set(following);
|
||||
if (following) queueMicrotask(() => this.scrollLogsToBottom());
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens what an event recorded beyond its one-line message.
|
||||
*
|
||||
* <p>The numbers that explain a warning - how many characters were over budget, which tool was
|
||||
* called, how long a step waited - were already reaching the browser and being dropped on the
|
||||
* floor, leaving the API as the only way to read them.
|
||||
*/
|
||||
openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) {
|
||||
domEvent?.preventDefault();
|
||||
domEvent?.stopPropagation();
|
||||
if (!event.detailsText) return;
|
||||
|
||||
const when = new Date(event.timestamp).toLocaleString();
|
||||
this.eventDialog.open({
|
||||
title: executionEventTitle(event.type),
|
||||
subtitle: event.nodeName ? `${event.nodeName} · ${when}` : when,
|
||||
message: event.messageText,
|
||||
details: event.details
|
||||
});
|
||||
}
|
||||
|
||||
logLevelClass(level: string | null | undefined): string {
|
||||
return _logLevelClass(level);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue