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.
|
* 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) {
|
||||||
|
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
? () => {
|
? () => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue