Files
windmill/frontend/src/lib/components/AgentResultDisplay.svelte
T
hugocasaandClaude Opus 5 a08992834d feat: render an AI agent result as its answer, not as raw JSON (#11051)
* feat: render an AI agent result as its answer, not as raw JSON

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sanitize agent markdown through the shared plugin chain

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: fold agent stream events incrementally per poll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: separate the agent meta line from the result toggle group

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add a transcript view of an agent run's conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: retire AIAgentLogViewer in favour of the transcript

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show the partial transcript a max-iterations failure carries

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: move the agent meta line and system prompt below the conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: show an agent run as what it did, not as a conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: name the agent run breakdown a trace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): label the save-as-agent form fields per the guidelines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reuse the resource form's path and description fields

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep the action tags on a max-iterations failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep offline replay inert and the streamed answer to one turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reset the streamed answer on providers that skip tool_call

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry the event type narrowing through the stream parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: survive a malformed message rather than take the viewer down

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: coerce agent messages once at the parse boundary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show an agent run as one scroll ending in its output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep every streamed turn instead of dropping the narration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: share the chat divider and drop the unsafe run auto-scroll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give the labelled divider a border colour and the standard pretty icon

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pad the agent run below its badges as well as above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: follow a streaming run's pane without moving the page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the chat's stick-to-bottom mechanics with the agent run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the answer's citations and end a turn's reasoning with the turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read the agent stream through the windmill-chat sdk parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show an agent run's thinking instead of falling back to raw json

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a stream the fold cannot use from claiming the run pane

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: identify an agent run by more than the job id the replay withholds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the tests and comment lines that were not earning their place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a streamed turn's text shifting when a tool call closes it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:38:46 +02:00

117 lines
4.2 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte'
import { Badge } from '$lib/components/common'
import GfmMarkdown from './GfmMarkdown.svelte'
import AgentTrace from './AgentTrace.svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import LabeledDivider from './LabeledDivider.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import { buildAgentTrace, splitFinalAnswer } from './agentTrace'
import { runPane } from './agentScroll'
import { createBottomSticker } from './stickToBottom'
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
interface Props {
result: AgentResult
workspaceId?: string
/** Identifies the run, so a reused viewer lands on the new one's output. */
runKey?: string
/**
* How to render an output that is not text: whatever the result viewer would do
* with that object on its own, a table for rows or the file viewer for an S3
* object. Passed in rather than imported so this does not reach back into the
* viewer rendering it.
*/
structuredOutput: Snippet<[unknown]>
}
let { result, workspaceId, runKey, structuredOutput }: Props = $props()
let summary = $derived(summarizeAgentResult(result))
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
// The turn that produced `output` is not a trace row: the output block below is
// that same text, and printing it twice in one scroll reads as the agent having
// answered itself. Its citations move down with it.
let answer = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output))
let trace = $derived(answer.trace)
let reasoning = $derived(result.reasoning?.trim())
let reasoningExpanded = $state(false)
let anchor: HTMLElement | undefined = $state()
const sticker = createBottomSticker()
$effect(() => {
// Also on arrival from a stream: the run finishing adds the output separator,
// so the end has moved from wherever the stream had the reader parked.
runKey
trace.length
reasoning
sticker.scrollToEnd(runPane(anchor))
})
</script>
<div bind:this={anchor} class="flex flex-col w-full py-3">
{#if trace.length > 0}
<AgentTrace entries={trace} {workspaceId} />
{/if}
{#if reasoning}
<!-- The whole run's thinking, which the worker hands back joined rather than
per iteration, so it reads as one block above the answer it led to. -->
<ChatCollapsibleCard
label="Thinking"
expanded={reasoningExpanded}
onToggle={() => (reasoningExpanded = !reasoningExpanded)}
contentClass="font-main"
>
<GfmMarkdown md={reasoning} prose="xs" noPadding />
</ChatCollapsibleCard>
{/if}
{#if trace.length > 0 || reasoning}
<LabeledDivider class="my-3">
<span class="text-2xs text-hint">Output</span>
</LabeledDivider>
{/if}
<div>
{#if textOutput !== undefined}
{#if textOutput === ''}
<span class="text-tertiary text-xs">The agent returned no answer</span>
{:else}
<!-- A model writes this, and what it writes is steerable by whatever
reached its context — a user message, a tool's output. So it is
untrusted input and goes through the shared sanitizing chain, which
is also what makes it inert on the public replay page. -->
<GfmMarkdown md={textOutput} noPadding />
{/if}
{:else}
{@render structuredOutput(result.output)}
{/if}
{#if answer.sources}
<div class="mt-2">
<WebSearchSourcesDisplay sources={answer.sources} />
</div>
{/if}
</div>
<!-- What the run cost, as a footnote to what it produced. -->
<div class="flex items-center gap-2 flex-wrap text-xs mt-3">
{#if summary.toolCalls > 0}
<Badge color="blue">
{summary.toolCalls}
{summary.toolCalls === 1 ? 'tool call' : 'tool calls'}
</Badge>
{/if}
{#if summary.webSearches > 0}
<Badge color="blue">
{summary.webSearches}
{summary.webSearches === 1 ? 'web search' : 'web searches'}
</Badge>
{/if}
{#if summary.tokens !== undefined}
<Badge color="gray">{formatTokenCount(summary.tokens)} tokens</Badge>
{/if}
{#if summary.cachedTokens}
<Badge color="gray">{formatTokenCount(summary.cachedTokens)} cached</Badge>
{/if}
</div>
</div>