mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
* 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>
156 lines
5.0 KiB
Svelte
156 lines
5.0 KiB
Svelte
<script lang="ts">
|
|
import { ExternalLink, Globe } from 'lucide-svelte'
|
|
import { JobService, type Job } from '$lib/gen'
|
|
import { base } from '$lib/base'
|
|
import { msToReadableTimeShort } from '$lib/utils'
|
|
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
|
|
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
|
|
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
|
|
import { webSearchResultOf, type WebSearchResult } from './copilot/chat/shared'
|
|
import GfmMarkdown from './GfmMarkdown.svelte'
|
|
import type { AgentTraceEntry } from './agentTrace'
|
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
interface Props {
|
|
entries: AgentTraceEntry[]
|
|
workspaceId?: string
|
|
}
|
|
|
|
let { entries, workspaceId }: Props = $props()
|
|
|
|
let expanded = new SvelteSet<number>()
|
|
// A search opens on its sources, so for it the reader's toggle records a close.
|
|
let collapsedSearches = new SvelteSet<number>()
|
|
// Row state is keyed by position, which means nothing once the viewer is handed
|
|
// another run: row 0 would stay open showing the previous run's job.
|
|
$effect(() => {
|
|
entries
|
|
expanded.clear()
|
|
collapsedSearches.clear()
|
|
jobs.clear()
|
|
})
|
|
// A tool's own job holds what the envelope does not: its logs, how long it
|
|
// took, and whether it succeeded. Fetched when a row is opened rather than
|
|
// upfront, so a run with twenty calls does not issue twenty requests to draw
|
|
// a list of names.
|
|
let jobs = new SvelteMap<string, Job | 'loading' | 'failed'>()
|
|
|
|
async function loadJob(jobId: string) {
|
|
if (jobs.has(jobId)) {
|
|
return
|
|
}
|
|
jobs.set(jobId, 'loading')
|
|
try {
|
|
jobs.set(
|
|
jobId,
|
|
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $operatingWorkspace! })
|
|
)
|
|
} catch {
|
|
// A tool job can be gone (retention) or unreadable. The row still has its
|
|
// arguments and result from the envelope, so only the extras are lost.
|
|
jobs.set(jobId, 'failed')
|
|
}
|
|
}
|
|
|
|
function toggle(index: number, entry: AgentTraceEntry) {
|
|
if (expanded.delete(index)) {
|
|
return
|
|
}
|
|
expanded.add(index)
|
|
if (entry.kind === 'tool' && entry.jobId) {
|
|
loadJob(entry.jobId)
|
|
}
|
|
}
|
|
|
|
function jobOf(jobId: string | undefined): Job | undefined {
|
|
if (!jobId) return undefined
|
|
const job = jobs.get(jobId)
|
|
return typeof job === 'object' ? job : undefined
|
|
}
|
|
|
|
function toolLabel(
|
|
entry: Extract<AgentTraceEntry, { kind: 'tool' }>,
|
|
search: WebSearchResult | undefined
|
|
): string {
|
|
const duration = jobOf(entry.jobId)?.['duration_ms']
|
|
return [
|
|
entry.name,
|
|
search?.query !== undefined ? `"${search.query}"` : undefined,
|
|
duration !== undefined ? msToReadableTimeShort(duration) : undefined
|
|
]
|
|
.filter((part) => part !== undefined)
|
|
.join(' · ')
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col w-full min-w-0 gap-1">
|
|
{#each entries as entry, index (index)}
|
|
{#if entry.kind === 'assistant'}
|
|
<div class="min-w-0">
|
|
<GfmMarkdown md={entry.content} noPadding />
|
|
</div>
|
|
{:else if entry.kind === 'search'}
|
|
<!-- Open on its sources, as the session chat shows a search. A tool that returns the
|
|
same shape stays closed like every other tool row: the agent may call it twenty
|
|
times, and the reader came for the run, not for its bibliography. -->
|
|
<ChatCollapsibleCard
|
|
label="Searched the web"
|
|
expanded={Boolean(entry.sources) && !collapsedSearches.has(index)}
|
|
toggleable={Boolean(entry.sources)}
|
|
onToggle={() => {
|
|
if (!collapsedSearches.delete(index)) collapsedSearches.add(index)
|
|
}}
|
|
>
|
|
{#if entry.sources}
|
|
<WebSearchSourcesDisplay sources={entry.sources} />
|
|
{/if}
|
|
</ChatCollapsibleCard>
|
|
{:else}
|
|
{@const job = jobOf(entry.jobId)}
|
|
{@const failed = job?.type === 'CompletedJob' && !job.success}
|
|
{@const search = webSearchResultOf(entry.result)}
|
|
<ChatCollapsibleCard
|
|
label={toolLabel(entry, search)}
|
|
expanded={expanded.has(index)}
|
|
onToggle={() => toggle(index, entry)}
|
|
contentClass="space-y-3"
|
|
>
|
|
{#if entry.args}
|
|
<ToolContentDisplay title="Parameters" content={entry.args} toolName={entry.name} />
|
|
{/if}
|
|
{#if job?.logs}
|
|
<ToolContentDisplay title="Logs" content={job.logs} />
|
|
{/if}
|
|
{#if search && !failed}
|
|
<WebSearchSourcesDisplay sources={search.sources} favicons={false} />
|
|
{:else}
|
|
<ToolContentDisplay
|
|
title="Result"
|
|
content={entry.result}
|
|
error={failed ? entry.result : undefined}
|
|
/>
|
|
{/if}
|
|
{#if entry.resourcePath}
|
|
<div class="text-2xs text-hint flex items-center gap-1">
|
|
<Globe size={11} />
|
|
{entry.resourcePath}
|
|
</div>
|
|
{:else if entry.jobId}
|
|
<a
|
|
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
|
|
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $operatingWorkspace}"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<ExternalLink size={11} />
|
|
Open job
|
|
</a>
|
|
{/if}
|
|
</ChatCollapsibleCard>
|
|
{/if}
|
|
{/each}
|
|
</div>
|