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:
parent
1aa1410b38
commit
34ef846a5b
4 changed files with 55 additions and 42 deletions
|
|
@ -128,6 +128,8 @@ interface ProvisioningRun {
|
|||
* Flushed to liveLeadProcessMessages on result.success.
|
||||
*/
|
||||
directReplyParts: string[];
|
||||
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
||||
provisioningOutputParts: string[];
|
||||
}
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
|
|
@ -446,6 +448,10 @@ function updateProgress(
|
|||
message: string,
|
||||
extras?: Pick<TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail'>
|
||||
): TeamProvisioningProgress {
|
||||
const assistantOutput =
|
||||
run.provisioningOutputParts.length > 0
|
||||
? run.provisioningOutputParts.join('')
|
||||
: run.progress.assistantOutput;
|
||||
run.progress = {
|
||||
...run.progress,
|
||||
state,
|
||||
|
|
@ -455,6 +461,7 @@ function updateProgress(
|
|||
error: extras?.error,
|
||||
warnings: extras?.warnings,
|
||||
cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail,
|
||||
assistantOutput,
|
||||
};
|
||||
return run.progress;
|
||||
}
|
||||
|
|
@ -485,13 +492,17 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
|
|||
|
||||
function emitLogsProgress(run: ProvisioningRun): void {
|
||||
const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
|
||||
if (!logsTail) {
|
||||
const assistantOutput =
|
||||
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('') : undefined;
|
||||
|
||||
if (!logsTail && !assistantOutput) {
|
||||
return;
|
||||
}
|
||||
run.progress = {
|
||||
...run.progress,
|
||||
updatedAt: nowIso(),
|
||||
cliLogsTail: logsTail,
|
||||
...(logsTail !== undefined && { cliLogsTail: logsTail }),
|
||||
...(assistantOutput !== undefined && { assistantOutput }),
|
||||
};
|
||||
run.onProgress(run.progress);
|
||||
}
|
||||
|
|
@ -702,6 +713,7 @@ export class TeamProvisioningService {
|
|||
fsPhase: 'waiting_config',
|
||||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
provisioningOutputParts: [],
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -972,6 +984,7 @@ export class TeamProvisioningService {
|
|||
fsPhase: 'waiting_members',
|
||||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
provisioningOutputParts: [],
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1560,6 +1573,12 @@ export class TeamProvisioningService {
|
|||
if (textParts.length > 0) {
|
||||
const text = textParts.join('');
|
||||
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) {
|
||||
const capture = run.leadRelayCapture;
|
||||
if (!capture.settled) {
|
||||
|
|
|
|||
|
|
@ -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 { Button } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import hljs from 'highlight.js';
|
||||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
|
||||
|
||||
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
||||
|
||||
import type { ProvisioningStep } from './provisioningSteps';
|
||||
|
|
@ -27,6 +28,8 @@ export interface ProvisioningProgressBlockProps {
|
|||
pid?: number;
|
||||
/** Tail of CLI logs */
|
||||
cliLogsTail?: string;
|
||||
/** Accumulated assistant text output for live preview */
|
||||
assistantOutput?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -59,32 +62,6 @@ function useElapsedTimer(startedAt?: string): string | null {
|
|||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export const ProvisioningProgressBlock = ({
|
||||
title,
|
||||
message,
|
||||
|
|
@ -94,22 +71,28 @@ export const ProvisioningProgressBlock = ({
|
|||
startedAt,
|
||||
pid,
|
||||
cliLogsTail,
|
||||
assistantOutput,
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const logsRef = useRef<HTMLPreElement>(null);
|
||||
const highlightedHtml = useMemo(
|
||||
() => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''),
|
||||
[cliLogsTail]
|
||||
);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll CLI logs
|
||||
useEffect(() => {
|
||||
if (logsOpen && logsRef.current) {
|
||||
logsRef.current.scrollTop = logsRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsOpen, cliLogsTail]);
|
||||
|
||||
// Auto-scroll assistant output
|
||||
useEffect(() => {
|
||||
if (outputScrollRef.current) {
|
||||
outputScrollRef.current.scrollTop = outputScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [assistantOutput]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -174,6 +157,17 @@ export const ProvisioningProgressBlock = ({
|
|||
);
|
||||
})}
|
||||
</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 ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
|
|
@ -182,18 +176,15 @@ export const ProvisioningProgressBlock = ({
|
|||
onClick={() => setLogsOpen((v) => !v)}
|
||||
>
|
||||
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
CLI output
|
||||
CLI logs
|
||||
</button>
|
||||
{logsOpen ? (
|
||||
<pre
|
||||
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)]"
|
||||
// Safe: highlightedHtml is built from hljs.highlight() which only produces
|
||||
// hljs-* <span> tags, combined with escapeHtml() for non-JSON lines.
|
||||
// Input is CLI stdout/stderr from a local process, not user-supplied web content.
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
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)]"
|
||||
>
|
||||
{cliLogsTail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ export const TeamProvisioningBanner = ({
|
|||
startedAt={progress.startedAt}
|
||||
pid={progress.pid}
|
||||
cliLogsTail={progress.cliLogsTail}
|
||||
assistantOutput={progress.assistantOutput}
|
||||
onCancel={
|
||||
canCancel
|
||||
? () => {
|
||||
|
|
|
|||
|
|
@ -262,6 +262,8 @@ export interface TeamProvisioningProgress {
|
|||
error?: string;
|
||||
warnings?: string[];
|
||||
cliLogsTail?: string;
|
||||
/** Accumulated assistant text output during provisioning (for live preview). */
|
||||
assistantOutput?: string;
|
||||
}
|
||||
|
||||
export interface GlobalTask extends TeamTaskWithKanban {
|
||||
|
|
|
|||
Loading…
Reference in a new issue