feat: accumulate and display assistant output during provisioning

- Added `provisioningOutputParts` to `ProvisioningRun` for accumulating assistant text during the provisioning phase.
- Updated `updateProgress` and `emitLogsProgress` functions to include the new `assistantOutput` in the progress state.
- Enhanced `ProvisioningProgressBlock` to display live assistant output using `MarkdownViewer`.
- Modified `TeamProvisioningBanner` to pass the new `assistantOutput` prop for rendering in the UI.
This commit is contained in:
iliya 2026-02-24 14:32:51 +02:00 committed by Илия
parent 1aa1410b38
commit 34ef846a5b
4 changed files with 55 additions and 42 deletions

View file

@ -128,6 +128,8 @@ interface ProvisioningRun {
* Flushed to liveLeadProcessMessages on result.success. * Flushed to liveLeadProcessMessages on result.success.
*/ */
directReplyParts: string[]; directReplyParts: string[];
/** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[];
} }
type ProvisioningAuthSource = type ProvisioningAuthSource =
@ -446,6 +448,10 @@ function updateProgress(
message: string, message: string,
extras?: Pick<TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail'> extras?: Pick<TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail'>
): TeamProvisioningProgress { ): TeamProvisioningProgress {
const assistantOutput =
run.provisioningOutputParts.length > 0
? run.provisioningOutputParts.join('')
: run.progress.assistantOutput;
run.progress = { run.progress = {
...run.progress, ...run.progress,
state, state,
@ -455,6 +461,7 @@ function updateProgress(
error: extras?.error, error: extras?.error,
warnings: extras?.warnings, warnings: extras?.warnings,
cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail, cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail,
assistantOutput,
}; };
return run.progress; return run.progress;
} }
@ -485,13 +492,17 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
function emitLogsProgress(run: ProvisioningRun): void { function emitLogsProgress(run: ProvisioningRun): void {
const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer); const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
if (!logsTail) { const assistantOutput =
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('') : undefined;
if (!logsTail && !assistantOutput) {
return; return;
} }
run.progress = { run.progress = {
...run.progress, ...run.progress,
updatedAt: nowIso(), updatedAt: nowIso(),
cliLogsTail: logsTail, ...(logsTail !== undefined && { cliLogsTail: logsTail }),
...(assistantOutput !== undefined && { assistantOutput }),
}; };
run.onProgress(run.progress); run.onProgress(run.progress);
} }
@ -702,6 +713,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config', fsPhase: 'waiting_config',
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
provisioningOutputParts: [],
progress: { progress: {
runId, runId,
teamName: request.teamName, teamName: request.teamName,
@ -972,6 +984,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members', fsPhase: 'waiting_members',
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
provisioningOutputParts: [],
progress: { progress: {
runId, runId,
teamName: request.teamName, teamName: request.teamName,
@ -1560,6 +1573,12 @@ export class TeamProvisioningService {
if (textParts.length > 0) { if (textParts.length > 0) {
const text = textParts.join(''); const text = textParts.join('');
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
// During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
if (!run.provisioningComplete) {
run.provisioningOutputParts.push(text);
}
if (run.leadRelayCapture) { if (run.leadRelayCapture) {
const capture = run.leadRelayCapture; const capture = run.leadRelayCapture;
if (!capture.settled) { if (!capture.settled) {

View file

@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import hljs from 'highlight.js';
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps'; import type { ProvisioningStep } from './provisioningSteps';
@ -27,6 +28,8 @@ export interface ProvisioningProgressBlockProps {
pid?: number; pid?: number;
/** Tail of CLI logs */ /** Tail of CLI logs */
cliLogsTail?: string; cliLogsTail?: string;
/** Accumulated assistant text output for live preview */
assistantOutput?: string;
className?: string; className?: string;
} }
@ -59,32 +62,6 @@ function useElapsedTimer(startedAt?: string): string | null {
return elapsed; return elapsed;
} }
function highlightLogsHtml(text: string): string {
return text
.split('\n')
.map((line) => {
const trimmed = line.trimStart();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed: unknown = JSON.parse(trimmed);
const pretty = JSON.stringify(parsed, null, 2);
return hljs.highlight(pretty, { language: 'json' }).value;
} catch {
return escapeHtml(line);
}
}
if (line === '[stdout]' || line === '[stderr]') {
return `<span class="hljs-comment">${escapeHtml(line)}</span>`;
}
return escapeHtml(line);
})
.join('\n');
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export const ProvisioningProgressBlock = ({ export const ProvisioningProgressBlock = ({
title, title,
message, message,
@ -94,22 +71,28 @@ export const ProvisioningProgressBlock = ({
startedAt, startedAt,
pid, pid,
cliLogsTail, cliLogsTail,
assistantOutput,
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt); const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(false);
const logsRef = useRef<HTMLPreElement>(null); const logsRef = useRef<HTMLPreElement>(null);
const highlightedHtml = useMemo( const outputScrollRef = useRef<HTMLDivElement>(null);
() => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''),
[cliLogsTail]
);
// Auto-scroll CLI logs
useEffect(() => { useEffect(() => {
if (logsOpen && logsRef.current) { if (logsOpen && logsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight; logsRef.current.scrollTop = logsRef.current.scrollHeight;
} }
}, [logsOpen, cliLogsTail]); }, [logsOpen, cliLogsTail]);
// Auto-scroll assistant output
useEffect(() => {
if (outputScrollRef.current) {
outputScrollRef.current.scrollTop = outputScrollRef.current.scrollHeight;
}
}, [assistantOutput]);
return ( return (
<div <div
className={cn( className={cn(
@ -174,6 +157,17 @@ export const ProvisioningProgressBlock = ({
); );
})} })}
</div> </div>
{assistantOutput ? (
<div className="mt-2">
<p className="mb-1 text-[11px] font-medium text-[var(--color-text-muted)]">Live output</p>
<div
ref={outputScrollRef}
className="max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2"
>
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
</div>
</div>
) : null}
{cliLogsTail ? ( {cliLogsTail ? (
<div className="mt-2"> <div className="mt-2">
<button <button
@ -182,18 +176,15 @@ export const ProvisioningProgressBlock = ({
onClick={() => setLogsOpen((v) => !v)} onClick={() => setLogsOpen((v) => !v)}
> >
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
CLI output CLI logs
</button> </button>
{logsOpen ? ( {logsOpen ? (
<pre <pre
ref={logsRef} ref={logsRef}
className="hljs mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]" className="mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
// Safe: highlightedHtml is built from hljs.highlight() which only produces >
// hljs-* <span> tags, combined with escapeHtml() for non-JSON lines. {cliLogsTail}
// Input is CLI stdout/stderr from a local process, not user-supplied web content. </pre>
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : null} ) : null}
</div> </div>
) : null} ) : null}

View file

@ -145,6 +145,7 @@ export const TeamProvisioningBanner = ({
startedAt={progress.startedAt} startedAt={progress.startedAt}
pid={progress.pid} pid={progress.pid}
cliLogsTail={progress.cliLogsTail} cliLogsTail={progress.cliLogsTail}
assistantOutput={progress.assistantOutput}
onCancel={ onCancel={
canCancel canCancel
? () => { ? () => {

View file

@ -262,6 +262,8 @@ export interface TeamProvisioningProgress {
error?: string; error?: string;
warnings?: string[]; warnings?: string[];
cliLogsTail?: string; cliLogsTail?: string;
/** Accumulated assistant text output during provisioning (for live preview). */
assistantOutput?: string;
} }
export interface GlobalTask extends TeamTaskWithKanban { export interface GlobalTask extends TeamTaskWithKanban {