Files
windmill/frontend/src/lib/components/AgentResultDisplay.svelte
T
GuilhemandClaude Opus 5 fa0fc24269 feat: render a tool's web search result as the session web search card (#11313)
* feat: render a tool's web search result as the session web search card

Any tool whose result is exactly `{ sources: [{ url, title? }], query? }`
now renders as the card the AI session shows for a provider-side web
search, in the agent step trace and in the flow chat. The provider-side
search itself gets the same card on both surfaces: its citations, which
the worker records on the assistant turn that follows, move onto the
search row.

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

* fix: keep the trace tool label off the lazily fetched job

The web search card's label read the tool's job, which is only fetched
once the row is expanded, so the query suffix appeared only after the
row had been opened. The shape of the result decides the label; the
job's failure still decides whether the sources or the error show.

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

* fix: keep unrenderable sources and tool hostnames out of the search card

A result whose urls the card cannot render — relative, or javascript: —
matched the shape and replaced the tool's own output with an empty list,
so the predicate now requires what the renderer accepts. A tool's sources
can name internal services, so they no longer resolve a favicon through
Google's service; provider-side search results still do. An absent
optional reaches JSON as null from a Python tool, which now reads as
absent rather than rejecting the whole result.

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

* fix: name the host when a source carries a blank title

A source whose title is an empty string rendered as a link with no text
at all, the empty title also suppressing the hostname beside 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-23 12:33:12 +00:00

110 lines
4.0 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 { 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 stay in the trace, on the search that found them.
let trace = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output))
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}
</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>