mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: flow chat job-backed detail, smooth streaming and answer chrome (#11186)
* feat: flow chat job-backed detail, smooth streaming and answer chrome * chore: document setFlowPath, drop unused chatIdentity, tighten comments * fix: read a nested agent tool's call from its own job * fix: show only the model's arguments on a tool card read from its job * feat: add attachments to the chat sdk message type * refactor: read a flow chat message's files from the message, not its run * fix: keep every job-backed answer when reads finish out of order * refactor: build flow chat tool cards from the conversation row alone * feat: keep the model picker's current choice when a flow chat retry replays its run * fix: export ChatAttachment from the chat sdk entry point * fix: drop a conversation list for the flow path before setFlowPath * fix: return the current listing, not old-path rows, after setFlowPath * fix: treat a listing stale on kind or path as one stale listing * revert: drop setFlowPath and rebuild the chat when the flow path changes * fix: refuse a flow chat retry once any turn ran while it read the run * docs: say where a display message's createdAt comes from * style: drop the rule between New chat and the conversation list
This commit is contained in:
@@ -471,7 +471,9 @@
|
||||
return jobId ?? ''
|
||||
}}
|
||||
conversationKind="test"
|
||||
frame="boxed"
|
||||
path={$pathStore}
|
||||
identity={$initialPathStore || fakeInitialPath}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
@@ -558,7 +560,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="pt-4 flex flex-col border-t relative">
|
||||
<!-- The rule divides the inputs form from its results. Chat mode has no form: the
|
||||
chat is its own panel, and a second line right under it reads as a stray edge. -->
|
||||
<div
|
||||
class="pt-4 flex flex-col relative {flowStore.val.value?.chat_input_enabled
|
||||
? ''
|
||||
: 'border-t'}"
|
||||
>
|
||||
{#if flowHasChanged()}
|
||||
<div class="pb-2">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A soft edge on a scroller, so content scrolling out of view fades instead of being
|
||||
* cut against whatever borders it.
|
||||
*
|
||||
* Rendered as an overlay in the scroller's positioned ancestor rather than inside the
|
||||
* scroller: `sticky` would resolve against the scroller's padding box and leave the
|
||||
* first few pixels unfaded. It shows only when there is something hidden in that
|
||||
* direction, so a transcript that fits shows no edge at all.
|
||||
*/
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
/** The scrolling element this masks. */
|
||||
scroller: HTMLElement | undefined
|
||||
edge?: 'top' | 'bottom'
|
||||
/** Tailwind colour stop to fade from — the surface the scroller sits on. */
|
||||
from?: string
|
||||
/** Tailwind height of the fade band. */
|
||||
height?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
scroller,
|
||||
edge = 'top',
|
||||
from = 'from-surface',
|
||||
height = 'h-4',
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
let hidden = $state(true)
|
||||
|
||||
$effect(() => {
|
||||
const el = scroller
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
// A pixel of slack: fractional scroll offsets otherwise leave the bottom edge
|
||||
// showing on a scroller that is already at its end.
|
||||
hidden =
|
||||
edge === 'top' ? el.scrollTop <= 1 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
|
||||
}
|
||||
update()
|
||||
el.addEventListener('scroll', update, { passive: true })
|
||||
// Content arriving or the pane resizing changes what is hidden without a scroll.
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
if (el.firstElementChild) observer.observe(el.firstElementChild)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update)
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge(
|
||||
'pointer-events-none absolute inset-x-0 transition-opacity duration-150',
|
||||
edge === 'top' ? 'top-0 bg-gradient-to-b' : 'bottom-0 bg-gradient-to-t',
|
||||
from,
|
||||
'to-transparent',
|
||||
height,
|
||||
hidden ? 'opacity-0' : 'opacity-100',
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
@@ -35,6 +35,7 @@
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
|
||||
import AIChatModelSettings from './AIChatModelSettings.svelte'
|
||||
import ScrollFade from '$lib/components/ScrollFade.svelte'
|
||||
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
|
||||
import { SkillsMenu } from './skills/skillsMenu.svelte'
|
||||
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
|
||||
@@ -591,6 +592,15 @@
|
||||
// The typing-dots indicator implies the AI is busy, which is misleading while
|
||||
// the loop is parked on the user; surface a text pill instead so users know to
|
||||
// act on the tool above.
|
||||
// A step name hangs its icon in the column's left padding (see AssistantMessage), so a
|
||||
// transcript carrying one widens the padding, on both sides to keep the column centred.
|
||||
const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName))
|
||||
const columnClass = $derived(
|
||||
wideLayout
|
||||
? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
|
||||
: `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
|
||||
)
|
||||
|
||||
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
|
||||
|
||||
// Gated on `loading` because a card restored from history still looks parked:
|
||||
@@ -823,12 +833,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
bind:this={scrollElement}
|
||||
onscroll={onScroll}
|
||||
>
|
||||
<div
|
||||
class={wideLayout
|
||||
? 'w-full max-w-3xl mx-auto px-7 flex flex-col pb-2'
|
||||
: 'w-full max-w-2xl mx-auto px-3 flex flex-col pb-2'}
|
||||
bind:clientHeight={height}
|
||||
>
|
||||
<div class="{columnClass} flex flex-col pb-2" bind:clientHeight={height}>
|
||||
{#each messages as message, messageIndex (messageIndex)}
|
||||
<AIChatMessage
|
||||
{message}
|
||||
@@ -867,6 +872,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sits below the scroll-to-latest button, which carries z-10. -->
|
||||
<ScrollFade scroller={scrollElement} />
|
||||
{#if showScrollToLatest}
|
||||
<div
|
||||
transition:fade={{ duration: 120 }}
|
||||
@@ -892,11 +899,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={wideLayout
|
||||
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
|
||||
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
|
||||
>
|
||||
<!-- Same horizontal padding as the transcript above: the composer's edges line up with
|
||||
the messages rather than sitting closer to the panel edge. -->
|
||||
<div class="relative {columnClass} pb-2">
|
||||
{#if showFlowPendingActionControls}
|
||||
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -95,7 +95,7 @@ import { copilotInfo } from '$lib/aiStore'
|
||||
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
||||
import { TypewriterReveal } from './typewriterReveal'
|
||||
import { prefersInstantReveal, TypewriterReveal } from './typewriterReveal'
|
||||
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
|
||||
import {
|
||||
createAppBackendRunnableContextElement,
|
||||
@@ -155,11 +155,6 @@ import { appendAttachedFilesRoster } from './files/fileTools'
|
||||
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
|
||||
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
|
||||
|
||||
// SSR and users who prefer reduced motion get no typewriter pacing.
|
||||
function prefersInstantReveal(): boolean {
|
||||
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||
}
|
||||
|
||||
// Compaction of the stored history: once the projected request size
|
||||
// (contextTokens — the provider's report when current, a fresh chars/4
|
||||
// estimate otherwise — plus the new user message) reaches the trigger ratio of
|
||||
@@ -3959,6 +3954,9 @@ export class AIChatManager implements ChatViewHost {
|
||||
{
|
||||
role: 'assistant',
|
||||
content: this.currentReply,
|
||||
// Stamped as it lands. A chat restored from history predates this and
|
||||
// simply shows no time rather than a made-up one.
|
||||
createdAt: new Date().toISOString(),
|
||||
...(this.currentReasoning
|
||||
? { reasoning: this.currentReasoning, reasoningDurationMs }
|
||||
: {}),
|
||||
|
||||
@@ -138,7 +138,9 @@
|
||||
{:else}
|
||||
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
|
||||
{#if message.role === 'assistant'}
|
||||
<div class="px-[1px]"><AssistantMessage {message} workspace={messageWorkspace} /></div>
|
||||
<div class="px-[1px] group/answer"
|
||||
><AssistantMessage {message} workspace={messageWorkspace} /></div
|
||||
>
|
||||
{:else if message.role === 'tool'}
|
||||
<div class="px-[1px]"
|
||||
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
} from './workspaceItems.svelte'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import { Bot, ExternalLink } from 'lucide-svelte'
|
||||
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { displayDate } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
message: DisplayMessage
|
||||
@@ -23,6 +27,21 @@
|
||||
|
||||
let { message, workspace }: Props = $props()
|
||||
|
||||
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
|
||||
// the browser — so the job link is absent rather than empty elsewhere.
|
||||
const jobId = $derived(message.role === 'assistant' ? message.jobId : undefined)
|
||||
const createdAt = $derived(message.role === 'assistant' ? message.createdAt : undefined)
|
||||
const runHref = $derived(jobId ? `${base}/run/${jobId}?workspace=${workspace}` : undefined)
|
||||
// Today's answers show the time alone; the day earns its place only on a conversation
|
||||
// read back later. Resolved at render, so a chat left open across midnight keeps
|
||||
// yesterday's format until it is reopened.
|
||||
const timestamp = $derived.by(() => {
|
||||
if (!createdAt) return undefined
|
||||
const at = new Date(createdAt)
|
||||
const today = new Date().toDateString() === at.toDateString()
|
||||
return displayDate(at, false, !today)
|
||||
})
|
||||
|
||||
const reasoning = $derived(
|
||||
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
|
||||
)
|
||||
@@ -113,9 +132,16 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- An agent step's answer is headed by the agent's own icon, hung in the margin so the
|
||||
answer itself stays on the same left edge as the reader's messages. The icon sits in
|
||||
the padding the message column already carries. -->
|
||||
{#if stepName}
|
||||
<div class="text-2xs text-tertiary font-medium mb-1 truncate" title="Answered by {stepName}">
|
||||
{stepName}
|
||||
<div
|
||||
class="flex items-center gap-2 -ml-6 mb-1 text-2xs text-tertiary"
|
||||
title="Answered by {stepName}"
|
||||
>
|
||||
<Bot size={16} class="shrink-0" />
|
||||
<span class="font-mono truncate">{stepName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -140,3 +166,28 @@
|
||||
<Markdown md={message.content} {plugins} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message.content}
|
||||
<!-- Kept in flow while invisible, so revealing it on hover does not nudge the message
|
||||
below. A thinking-only row has no answer to copy, and the next row links its run. -->
|
||||
<div
|
||||
class="flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<CopyButton value={message.content} title="Copy answer" class="-ml-1" />
|
||||
{#if timestamp}
|
||||
<span>{timestamp}</span>
|
||||
{/if}
|
||||
{#if runHref}
|
||||
<a
|
||||
href={runHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
|
||||
title="Open this run"
|
||||
>
|
||||
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
|
||||
<ExternalLink size={11} class="shrink-0" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
CircleMinus,
|
||||
FileText,
|
||||
PanelRight,
|
||||
Lock
|
||||
Lock,
|
||||
ExternalLink
|
||||
} from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
isPlanCardTool,
|
||||
@@ -249,6 +251,19 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet jobLink()}
|
||||
<a
|
||||
href="{base}/run/{message.jobId}?workspace={chatHost.operatingWorkspace}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline"
|
||||
title="Open this run"
|
||||
>
|
||||
<span>job <span class="font-mono">{message.jobId?.slice(0, 8)}</span></span>
|
||||
<ExternalLink size={11} class="shrink-0" />
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<!-- Which system a call reaches is the first thing to know about it, so an MCP call
|
||||
is marked before its label. Awaited rather than drawn immediately: the MCP logo
|
||||
appearing first and being replaced would flicker on every row. -->
|
||||
@@ -275,7 +290,7 @@
|
||||
headerClass={message.needsConfirmation ? 'opacity-80' : ''}
|
||||
labelClass={showPreviewChip ? 'truncate' : ''}
|
||||
contentClass="space-y-3"
|
||||
headerRight={showPreviewChip ? previewChip : undefined}
|
||||
headerRight={showPreviewChip ? previewChip : message.jobId ? jobLink : undefined}
|
||||
headerLeft={mcpServer?.workspace ? serverMark : undefined}
|
||||
>
|
||||
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
|
||||
|
||||
@@ -634,6 +634,8 @@ export type ToolDisplayMessage = {
|
||||
* workspace rides along: a chat is readable from any workspace, and the same path names
|
||||
* a different server in each. */
|
||||
mcpServer?: { workspace: string; path: string }
|
||||
/** The run behind the call. Flow chats only: the card links to it. */
|
||||
jobId?: string
|
||||
showFade?: boolean
|
||||
actions?: ToolDisplayAction[]
|
||||
userQuestion?: UserQuestionDisplay
|
||||
@@ -677,7 +679,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
/** The run behind this answer. Flow chats only: a copilot turn happens in the
|
||||
* browser and has no job. */
|
||||
jobId?: string
|
||||
/** When the message was stored, as the server reports it. */
|
||||
/** When the answer arrived: the server's time for a flow chat's stored row, the browser's
|
||||
* for a copilot answer, which is stamped as it lands. Absent on a copilot chat restored
|
||||
* from history, which predates the stamp. */
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
// state is the `onReveal` callback — so the pacing is unit-testable with an
|
||||
// injected clock and scheduler.
|
||||
|
||||
import { BROWSER } from 'esm-env'
|
||||
|
||||
/** SSR and readers who prefer reduced motion get no pacing: text lands as it arrives. */
|
||||
export function prefersInstantReveal(): boolean {
|
||||
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|
||||
}
|
||||
|
||||
type Schedule = (cb: () => void) => unknown
|
||||
type Cancel = (handle: unknown) => void
|
||||
|
||||
|
||||
@@ -857,6 +857,7 @@
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
path={$pathStore}
|
||||
identity={$initialPathStore || fakeInitialPath}
|
||||
conversationKind="test"
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { FRAME_CLASS, type ChatFrame } from './flowChatProps'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -20,7 +21,15 @@
|
||||
additionalInputs?: Record<string, any>
|
||||
) => Promise<string | undefined>
|
||||
deploymentInProgress?: boolean
|
||||
/** The flow the chat runs and lists conversations for. Must be the path a run records,
|
||||
* or a conversation is stored under one path and looked for under another. */
|
||||
path: string
|
||||
/**
|
||||
* What the chat's stored inputs are filed under, when that is not the path. An unsaved
|
||||
* flow's path changes as its author types, so the editor passes something that holds
|
||||
* still for the flow it is editing.
|
||||
*/
|
||||
identity?: string
|
||||
hideSidebar?: boolean
|
||||
inputSchema?: Record<string, any>
|
||||
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
|
||||
@@ -29,6 +38,7 @@
|
||||
/** The flow's description, shown under the empty transcript's prompt. */
|
||||
description?: string
|
||||
wideLayout?: boolean
|
||||
frame?: ChatFrame
|
||||
/**
|
||||
* What this surface's own runs are: the editor runs previews and lists its test
|
||||
* chats, the flow page runs the deployed flow and lists only its users' chats.
|
||||
@@ -42,11 +52,13 @@
|
||||
onRunFlow,
|
||||
deploymentInProgress = false,
|
||||
path,
|
||||
identity = undefined,
|
||||
hideSidebar = false,
|
||||
inputSchema = undefined,
|
||||
flowModules = undefined,
|
||||
description = undefined,
|
||||
wideLayout = false,
|
||||
frame = 'top',
|
||||
conversationKind = 'deployed'
|
||||
}: Props = $props()
|
||||
|
||||
@@ -103,7 +115,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
|
||||
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
|
||||
{#if chat && chatState}
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar
|
||||
@@ -114,20 +126,26 @@
|
||||
canFilterKind={conversationKind !== 'deployed'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
|
||||
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
|
||||
{#key chat}
|
||||
<FlowChatInterface
|
||||
{chat}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{flowModules}
|
||||
{path}
|
||||
{workspace}
|
||||
{description}
|
||||
{wideLayout}
|
||||
{conversationKind}
|
||||
/>
|
||||
{/key}
|
||||
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
|
||||
the panel edge the way the session chat does, while the sidebar and the border
|
||||
dividing it from the chat still reach the bottom. -->
|
||||
<div class="flex flex-1 min-w-0 min-h-0 pb-3">
|
||||
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
|
||||
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
|
||||
{#key chat}
|
||||
<FlowChatInterface
|
||||
{chat}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{flowModules}
|
||||
{path}
|
||||
{identity}
|
||||
{workspace}
|
||||
{description}
|
||||
{wideLayout}
|
||||
{conversationKind}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { emptyString, type DynamicInput } from '$lib/utils'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import type { Chat } from 'windmill-chat'
|
||||
import { chatFlowKey } from './flowChatProps'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
|
||||
import {
|
||||
@@ -35,6 +36,8 @@
|
||||
* and the attachments input. */
|
||||
flowModules?: FlowModule[]
|
||||
path: string
|
||||
/** What the stored inputs are filed under when the path is not steady (see FlowChat). */
|
||||
identity?: string
|
||||
workspace?: string
|
||||
/** The flow's description, shown under the empty transcript's prompt. */
|
||||
description?: string
|
||||
@@ -49,6 +52,7 @@
|
||||
additionalInputsSchema,
|
||||
flowModules,
|
||||
path,
|
||||
identity = undefined,
|
||||
workspace = undefined,
|
||||
description = undefined,
|
||||
wideLayout = false,
|
||||
@@ -122,7 +126,7 @@
|
||||
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
|
||||
|
||||
function getStorageKey(): string {
|
||||
return `${STORAGE_KEY_PREFIX}${path}`
|
||||
return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}`
|
||||
}
|
||||
|
||||
function loadInputsFromStorage(): Record<string, any> | null {
|
||||
@@ -181,7 +185,10 @@
|
||||
? undefined
|
||||
: 'This workspace has no object storage, so files cannot be attached.',
|
||||
workspace: () => workspace,
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
|
||||
// The model controls only: a retry changes model when the reader did, but replays
|
||||
// the run's own attachments rather than whatever the composer holds now.
|
||||
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
|
||||
}
|
||||
)
|
||||
setChatViewHost(chatHost)
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
: 'w-[44px]'}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex-shrink-0 border-b">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** What separates the chat from what sits above it. `boxed` is its own panel, corners
|
||||
* clipped so the sidebar's edge follows them; `top` a dividing line under an enclosing
|
||||
* header; `none` for a surface where the chat is the whole pane. */
|
||||
export type ChatFrame = 'boxed' | 'top' | 'none'
|
||||
|
||||
export const FRAME_CLASS: Record<ChatFrame, string> = {
|
||||
boxed: 'border rounded-md',
|
||||
top: 'border-t',
|
||||
none: ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Which flow a chat is for, as something that holds still: `identity` where a surface has
|
||||
* one, since `path` follows the path field as its author types. `||`, not `??`: an empty
|
||||
* identity is no identity.
|
||||
*/
|
||||
export function chatFlowKey(props: { path: string; identity?: string }): string {
|
||||
return props.identity || props.path
|
||||
}
|
||||
@@ -11,7 +11,14 @@ import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/ar
|
||||
import type { AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
|
||||
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
|
||||
import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils'
|
||||
import {
|
||||
prefersInstantReveal,
|
||||
TypewriterReveal,
|
||||
type TypewriterRevealOptions
|
||||
} from '$lib/components/copilot/chat/typewriterReveal'
|
||||
import { JobService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { attachmentLanes } from './messageAttachments'
|
||||
import type { AttachmentsTarget } from './agentAttachmentInput'
|
||||
|
||||
export type FlowChatViewHostOptions = {
|
||||
@@ -23,12 +30,17 @@ export type FlowChatViewHostOptions = {
|
||||
/** Why attaching is off despite the flow taking attachments — no object storage, say.
|
||||
* Undefined while the workspace has not answered: an explanation must not be a guess. */
|
||||
attachmentsUnavailable?: () => string | undefined
|
||||
/** The workspace the transcript's paths resolve against. */
|
||||
/** The workspace the transcript's paths resolve against, and a retry reads its run from. */
|
||||
workspace?: () => string | undefined
|
||||
/** Whether sending is refused right now (a deployment in progress, say). The composer
|
||||
* is disabled on the same condition; this covers the sends the composer does not
|
||||
* make itself: a queued message going out, a retry. */
|
||||
sendDisabled?: () => boolean
|
||||
/** Flow inputs edited by a control beside the composer, such as the model button. A retry
|
||||
* takes their current value rather than the failed turn's. */
|
||||
inputsShownInComposer?: () => string[]
|
||||
/** Injectables for tests: the clock and scheduler behind the typewriter pacing. */
|
||||
revealOptions?: Pick<TypewriterRevealOptions, 'instant' | 'now' | 'schedule' | 'cancel'>
|
||||
}
|
||||
|
||||
function isBusy(status: ChatState['status']): boolean {
|
||||
@@ -79,6 +91,27 @@ function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The step name says which AI agent step wrote a message, so it only tells the reader
|
||||
* anything once the conversation holds more than one. Counted over the transcript rather
|
||||
* than over the flow's steps: a conversation outlives edits to the flow, so it can carry
|
||||
* labels from a shape the flow no longer has.
|
||||
*/
|
||||
export function showsStepNames(messages: readonly ChatMessage[]): boolean {
|
||||
return new Set(messages.map((m) => m.stepName).filter(Boolean)).size > 1
|
||||
}
|
||||
|
||||
/** How much of a streaming message is on screen, per lane, in characters. */
|
||||
export type Revealed = { content: number; reasoning: number }
|
||||
|
||||
/** What a display row can only learn beyond the message itself. Every lookup is optional. */
|
||||
export type DisplayLookups = {
|
||||
/** The workspace an attachment's download link points into. */
|
||||
workspace?: string
|
||||
/** How much of a pending assistant row the pacing has put on screen. */
|
||||
revealed?: (message: ChatMessage) => Revealed | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* What a failed tool call returned, as the card's error: the card shows the error in place of
|
||||
* the result. A job failure is stored as `{ message, name, stack }`; an MCP failure as plain text.
|
||||
@@ -104,9 +137,11 @@ const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/
|
||||
export function toDisplayMessages(
|
||||
messages: readonly ChatMessage[],
|
||||
busy = false,
|
||||
stopped: ReadonlySet<string> = new Set()
|
||||
stopped: ReadonlySet<string> = new Set(),
|
||||
lookups: DisplayLookups = {}
|
||||
): DisplayMessage[] {
|
||||
let userIndex = 0
|
||||
const stepNames = showsStepNames(messages)
|
||||
let latestUser = -1
|
||||
for (let i = messages.length - 1; i >= 0 && latestUser < 0; i--) {
|
||||
if (messages[i].role === 'user') latestUser = i
|
||||
@@ -119,17 +154,23 @@ export function toDisplayMessages(
|
||||
!(busy && i === latestUser) &&
|
||||
!stopped.has(message.id) &&
|
||||
!(message.serverId && stopped.has(message.serverId))
|
||||
const { images, contextElements } = attachmentLanes(lookups.workspace, message.attachments)
|
||||
return [
|
||||
{
|
||||
role: 'user',
|
||||
index,
|
||||
content: message.content,
|
||||
// Drives the shared Retry button.
|
||||
error: (settled && turnFailed(messages, i)) || undefined
|
||||
error: (settled && turnFailed(messages, i)) || undefined,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
contextElements: contextElements.length > 0 ? contextElements : undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
case 'tool': {
|
||||
// The model's call and what the tool sent back, as the stream carried them or the
|
||||
// worker stored them on the row. A row stored without them shows its name and job.
|
||||
const toolName = message.tool?.name
|
||||
const parameters = parseToolPayload(message.tool?.arguments)
|
||||
const result = parseToolPayload(message.tool?.result)
|
||||
const failed = message.success === false
|
||||
@@ -148,17 +189,17 @@ export function toDisplayMessages(
|
||||
tool_call_id: message.id,
|
||||
// The card's header is the row's text, which the server only words once the
|
||||
// tool has returned; until then the row says what is running.
|
||||
content:
|
||||
message.content || unfinished || (message.tool ? `Running ${message.tool.name}` : ''),
|
||||
content: message.content || unfinished || (toolName ? `Running ${toolName}` : ''),
|
||||
// Withheld for the copilot's two plan-mode names: `toolName` is what makes
|
||||
// ToolExecutionDisplay render a plan card, and an agent tool that happened to
|
||||
// share one would silently become one.
|
||||
toolName: isPlanCardTool(message.tool?.name) ? undefined : message.tool?.name,
|
||||
toolName: isPlanCardTool(toolName) ? undefined : toolName,
|
||||
parameters,
|
||||
result,
|
||||
showDetails: parameters !== undefined || result !== undefined,
|
||||
error: failed ? (toolErrorText(result) ?? message.content) : unfinished,
|
||||
isLoading: message.pending && message.tool?.status === 'running'
|
||||
isLoading: message.pending && message.tool?.status === 'running',
|
||||
jobId: message.jobId
|
||||
}
|
||||
// The tool card has no thinking section: the thinking that led to the call reads
|
||||
// as a card of its own, just before it.
|
||||
@@ -168,7 +209,7 @@ export function toDisplayMessages(
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning: message.reasoning,
|
||||
stepName: message.stepName,
|
||||
stepName: stepNames ? message.stepName : undefined,
|
||||
jobId: message.jobId,
|
||||
createdAt: message.createdAt
|
||||
},
|
||||
@@ -176,24 +217,37 @@ export function toDisplayMessages(
|
||||
]
|
||||
: [call]
|
||||
}
|
||||
default:
|
||||
default: {
|
||||
// The pacing's prefix while the message streams; the whole text once it has
|
||||
// settled, or the turn was stopped.
|
||||
const revealed = message.pending ? lookups.revealed?.(message) : undefined
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: message.content,
|
||||
content: revealed ? message.content.slice(0, revealed.content) : message.content,
|
||||
// Only the message a turn is still writing: a finalized reasoning-only
|
||||
// message must not look in progress.
|
||||
streaming: message.pending || undefined,
|
||||
reasoning: message.reasoning,
|
||||
stepName: message.stepName,
|
||||
reasoning: revealed
|
||||
? message.reasoning?.slice(0, revealed.reasoning)
|
||||
: message.reasoning,
|
||||
stepName: stepNames ? message.stepName : undefined,
|
||||
jobId: message.jobId,
|
||||
createdAt: message.createdAt
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The two paced lanes of one streaming message, and how much of each has been fed in. */
|
||||
type RevealLanes = {
|
||||
content: TypewriterReveal
|
||||
reasoning: TypewriterReveal
|
||||
fed: Revealed
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a flow run's conversation, as the `windmill-chat` SDK keeps it, through the
|
||||
* copilot's chat components. The turn is a flow job rather than an LLM call this host
|
||||
@@ -224,6 +278,7 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
this.#disposed = true
|
||||
this.#queue = emptyQueue()
|
||||
this.#unsubscribe()
|
||||
for (const id of Object.keys(this.#reveals)) this.#dropReveal(id)
|
||||
}
|
||||
|
||||
/** The latest `ChatState`, for what the interface reads beyond the seam (paging, loading). */
|
||||
@@ -234,6 +289,7 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
#onState(state: ChatState) {
|
||||
const previous = this.#state
|
||||
this.#state = state
|
||||
this.#paceReveals(state.messages)
|
||||
const landed = state.messages.filter(
|
||||
(m) => m.serverId && this.#stoppedTurns.has(m.id) && !this.#stoppedTurns.has(m.serverId)
|
||||
)
|
||||
@@ -243,13 +299,12 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
if (previous.conversationId !== state.conversationId) {
|
||||
// A conversation opens at its end, whatever the reader was doing in the last one.
|
||||
this.#automaticScroll = true
|
||||
// A conversation reopened later comes back from the server under other message ids.
|
||||
this.#sentAttachments.clear()
|
||||
// The queue was typed into the conversation that just went away; a message sent
|
||||
// after the switch would ride out of the wrong one, so it goes back to the composer.
|
||||
this.dequeueMessage()
|
||||
return
|
||||
}
|
||||
if (!isBusy(previous.status) && isBusy(state.status)) this.#turnsStarted++
|
||||
if (isBusy(previous.status) && !isBusy(state.status)) {
|
||||
// The turn settled. What was typed during it goes out once the turn is released,
|
||||
// not now: the chat publishes `idle` from inside its own `sendMessage`, which still
|
||||
@@ -263,9 +318,70 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth streaming. The chat appends each delta to the pending assistant message as it
|
||||
// arrives, in the coarse bursts the provider sends; what is shown is a prefix that a
|
||||
// typewriter advances per lane, so the bursts read as continuous typing. Plain fields
|
||||
// for the pacers, reactive counts for what they have put on screen.
|
||||
#reveals: Record<string, RevealLanes> = {}
|
||||
#revealed = $state<Record<string, Revealed>>({})
|
||||
|
||||
#paceReveals(messages: readonly ChatMessage[]) {
|
||||
const pending = new Set<string>()
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (message.role !== 'assistant' || !message.pending) continue
|
||||
pending.add(message.id)
|
||||
const lanes = (this.#reveals[message.id] ??= this.#newLanes(message.id))
|
||||
const content = message.content.slice(lanes.fed.content)
|
||||
const reasoning = (message.reasoning ?? '').slice(lanes.fed.reasoning)
|
||||
lanes.fed = { content: message.content.length, reasoning: (message.reasoning ?? '').length }
|
||||
lanes.content.push(content)
|
||||
lanes.reasoning.push(reasoning)
|
||||
// A row the turn has moved past — a tool card now follows it — is shown whole:
|
||||
// what the pacing still holds belongs above that card, not trickling in under it.
|
||||
if (i < messages.length - 1) {
|
||||
lanes.content.flush()
|
||||
lanes.reasoning.flush()
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(this.#reveals)) {
|
||||
if (!pending.has(id)) this.#dropReveal(id)
|
||||
}
|
||||
}
|
||||
|
||||
#newLanes(id: string): RevealLanes {
|
||||
const options = this.#options.revealOptions ?? {}
|
||||
const instant = options.instant ?? prefersInstantReveal()
|
||||
const lane = (kind: keyof Revealed) =>
|
||||
new TypewriterReveal({
|
||||
...options,
|
||||
instant,
|
||||
onReveal: (chunk) => {
|
||||
const current = this.#revealed[id] ?? { content: 0, reasoning: 0 }
|
||||
this.#revealed[id] = { ...current, [kind]: current[kind] + chunk.length }
|
||||
}
|
||||
})
|
||||
this.#revealed[id] = { content: 0, reasoning: 0 }
|
||||
return {
|
||||
content: lane('content'),
|
||||
reasoning: lane('reasoning'),
|
||||
fed: { content: 0, reasoning: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
#dropReveal(id: string) {
|
||||
this.#reveals[id]?.content.reset()
|
||||
this.#reveals[id]?.reasoning.reset()
|
||||
delete this.#reveals[id]
|
||||
delete this.#revealed[id]
|
||||
}
|
||||
|
||||
// Transcript
|
||||
displayMessages = $derived.by(() =>
|
||||
toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns)
|
||||
toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns, {
|
||||
workspace: this.#options.workspace?.(),
|
||||
revealed: (message) => this.#revealed[message.id]
|
||||
})
|
||||
)
|
||||
/** The user message of each turn stopped in this view, by message id and, once the chat has
|
||||
* read its row, row id: a reopened conversation or an older page rebuilds messages from rows,
|
||||
@@ -305,7 +421,15 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
instructions = ''
|
||||
// The user message lands in the transcript before `sendMessage` awaits anything.
|
||||
sendInFlight = false
|
||||
sendRequest = async (options: ChatSendRequestOptions = {}): Promise<boolean> => {
|
||||
/**
|
||||
* `replayInputs` are a failed turn's own run arguments, read back from its job. They
|
||||
* stand in for the composer's current inputs, so a retry runs the turn that failed
|
||||
* rather than a new one wearing its text.
|
||||
*/
|
||||
sendRequest = async (
|
||||
options: ChatSendRequestOptions = {},
|
||||
replayInputs?: Record<string, any>
|
||||
): Promise<boolean> => {
|
||||
const text = options.instructions?.trim() ?? ''
|
||||
let images = options.images ?? []
|
||||
let blobs = options.blobs ?? []
|
||||
@@ -322,16 +446,18 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
return false
|
||||
}
|
||||
const target = this.#options.attachmentsTarget?.()
|
||||
// The inputs modal does not ask for this input, so a required one is enforced here.
|
||||
if (target?.required && images.length === 0 && blobs.length === 0) {
|
||||
// The inputs modal does not ask for this input, so a required one is enforced here. A
|
||||
// replay carries the files its run already has, in `replayInputs`, and attaches none.
|
||||
if (!replayInputs && target?.required && images.length === 0 && blobs.length === 0) {
|
||||
sendUserToast('This chat needs a file with each message. Attach one to send.', true)
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
return false
|
||||
}
|
||||
const inputs = { ...(this.#options.additionalInputs?.() ?? {}) }
|
||||
// A replay sends the arguments its run had, attachment references included.
|
||||
const inputs = replayInputs ?? { ...(this.#options.additionalInputs?.() ?? {}) }
|
||||
// The attachments are this input's only editor: a value stored for it in the inputs
|
||||
// modal would otherwise ride along on every message.
|
||||
if (target) delete inputs[target.name]
|
||||
if (target && !replayInputs) delete inputs[target.name]
|
||||
// The composer caps files as they are attached, but a queue merged over several turns
|
||||
// arrives here as one send, and the chat refuses more than a single-file input holds.
|
||||
const cap = this.maxMessageAttachments
|
||||
@@ -359,44 +485,28 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
// already running, an upload that failed, Stop pressed while it ran — and the draft
|
||||
// is then handed back rather than dropped. The composer took it before calling, so
|
||||
// nothing else would.
|
||||
const sending = this.#chat.sendMessage(text, {
|
||||
inputs: this.#options.additionalInputs?.() ? inputs : undefined,
|
||||
attachments,
|
||||
attachmentsInput: target
|
||||
})
|
||||
// `sendMessage` shows the user message before its first await, so the last one is this
|
||||
// turn's. Its id is stable across the server sync, which lets Retry resend the files.
|
||||
const last = this.#chat.getState().messages.at(-1)
|
||||
const sentId =
|
||||
last?.role === 'user' && last.pending && last.content === text ? last.id : undefined
|
||||
if (sentId && (images.length > 0 || blobs.length > 0)) {
|
||||
this.#sentAttachments.set(sentId, { images, blobs })
|
||||
}
|
||||
const turn = sending.catch((e) => {
|
||||
if (sentId) this.#sentAttachments.delete(sentId)
|
||||
if (this.#disposed) return
|
||||
if (attachments.length > 0 && !isAbort(e)) {
|
||||
sendUserToast(
|
||||
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
// What was queued behind it comes back too, after it: the chat publishes `idle`
|
||||
// when it withdraws the turn, and a queue left in place would be flushed as if
|
||||
// the turn had run.
|
||||
this.dequeueMessage()
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
})
|
||||
const turn = this.#chat
|
||||
.sendMessage(text, {
|
||||
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
|
||||
attachments,
|
||||
attachmentsInput: target
|
||||
})
|
||||
.catch((e) => {
|
||||
if (this.#disposed) return
|
||||
if (attachments.length > 0 && !isAbort(e)) {
|
||||
sendUserToast(
|
||||
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
// What was queued behind it comes back too, after it: the chat publishes `idle`
|
||||
// when it withdraws the turn, and a queue left in place would be flushed as if
|
||||
// the turn had run.
|
||||
this.dequeueMessage()
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
})
|
||||
this.#turnDone = turn
|
||||
await turn
|
||||
// The files are kept only for a turn that failed, the one Retry is offered on: a base64
|
||||
// payload per sent file would otherwise pile up for as long as the panel lives.
|
||||
if (sentId) {
|
||||
const index = this.#state.messages.findIndex((m) => m.id === sentId)
|
||||
if (index === -1 || !turnFailed(this.#state.messages, index)) {
|
||||
this.#sentAttachments.delete(sentId)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
/** Settles when the chat has released the last turn this host started. */
|
||||
@@ -475,20 +585,62 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
|
||||
// Per-message actions
|
||||
storedImages = () => undefined
|
||||
/** The files each user message of this session went out with, for Retry. A message loaded
|
||||
* from history has none recorded here and retries with its text alone. */
|
||||
#sentAttachments = new Map<string, { images: AttachedImage[]; blobs: AttachedBlob[] }>()
|
||||
/** Send the user message at this transcript position again. The position is in
|
||||
* `displayMessages`, which holds more entries than the chat's messages. */
|
||||
retryRequest = (messageIndex: number) => {
|
||||
const message = this.displayMessages[messageIndex]
|
||||
if (!message || message.role !== 'user' || this.loading) return
|
||||
// A display message counts user messages in `index`; the files are kept by chat message id.
|
||||
const sent = this.#state.messages.filter((m) => m.role === 'user')[message.index]
|
||||
void this.sendRequest({
|
||||
instructions: message.content,
|
||||
...(sent ? this.#sentAttachments.get(sent.id) : undefined)
|
||||
})
|
||||
/** A retry reading back the turn it is about to replay. Deliberately not part of
|
||||
* `loading`, which renders Stop: there is no run yet to stop. */
|
||||
#readingReplayArgs = false
|
||||
/** Turns this chat has started, so a retry can tell whether one ran while it read the
|
||||
* arguments, whether or not it is still running. */
|
||||
#turnsStarted = 0
|
||||
/**
|
||||
* Run the turn at this position again with the arguments its job ran with, not the
|
||||
* composer's current inputs (`inputsShownInComposer` aside). The position is in
|
||||
* `displayMessages`, which holds more entries than the chat's messages. A purged job falls
|
||||
* back to the current inputs.
|
||||
*/
|
||||
retryRequest = async (messageIndex: number) => {
|
||||
const shown = this.displayMessages[messageIndex]
|
||||
if (!shown || shown.role !== 'user' || this.loading || this.#readingReplayArgs) return
|
||||
const message = this.#state.messages.filter((m) => m.role === 'user')[shown.index]
|
||||
if (!message) return
|
||||
const conversationId = this.#state.conversationId
|
||||
const turnsStarted = this.#turnsStarted
|
||||
const workspace = this.#options.workspace?.()
|
||||
let replayInputs: Record<string, any> | undefined
|
||||
if (message.jobId && workspace) {
|
||||
this.#readingReplayArgs = true
|
||||
try {
|
||||
const original = (await JobService.getJobArgs({ workspace, id: message.jobId })) as
|
||||
| Record<string, any>
|
||||
| undefined
|
||||
// `user_message` is the message itself, passed as the instructions below.
|
||||
const { user_message: _sent, ...rest } = original ?? {}
|
||||
const current = this.#options.additionalInputs?.() ?? {}
|
||||
for (const name of this.#options.inputsShownInComposer?.() ?? []) {
|
||||
if (name in current) rest[name] = current[name]
|
||||
else delete rest[name]
|
||||
}
|
||||
replayInputs = rest
|
||||
} catch (error) {
|
||||
// Only a job that is gone justifies running with other inputs; after a blip or
|
||||
// a 500 that would silently run a different turn.
|
||||
if ((error as { status?: number })?.status !== 404) {
|
||||
sendUserToast('Could not read what that turn ran with. Try again.', true)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
this.#readingReplayArgs = false
|
||||
}
|
||||
}
|
||||
// The reader may have moved on while the arguments were read: to another conversation,
|
||||
// where this turn does not belong, or by sending. Any turn started since counts, settled
|
||||
// or not: they wrote the chat's latest message, and running this one now would answer
|
||||
// something they have moved past.
|
||||
if (this.#disposed || this.#state.conversationId !== conversationId) return
|
||||
if (this.#turnsStarted !== turnsStarted) {
|
||||
sendUserToast('That chat started another turn. Retry once it finishes.', true)
|
||||
return
|
||||
}
|
||||
void this.sendRequest({ instructions: message.content }, replayInputs)
|
||||
}
|
||||
restartGeneration = () => {}
|
||||
handleUserQuestionAnswer = () => false
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
|
||||
import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte'
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
JobService: { getJobArgs: vi.fn() }
|
||||
}))
|
||||
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
|
||||
|
||||
import { JobService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
const getJobArgs = vi.mocked(JobService.getJobArgs)
|
||||
const toast = vi.mocked(sendUserToast)
|
||||
|
||||
beforeEach(() => {
|
||||
getJobArgs.mockReset()
|
||||
toast.mockReset()
|
||||
})
|
||||
|
||||
function message(partial: Partial<ChatMessage> & Pick<ChatMessage, 'role'>): ChatMessage {
|
||||
return {
|
||||
id: partial.id ?? `${partial.role}-${Math.random()}`,
|
||||
@@ -31,6 +47,10 @@ function idleState(partial: Partial<ChatState> = {}): ChatState {
|
||||
function fakeChat(initial: ChatState = idleState()) {
|
||||
let state = initial
|
||||
const listeners = new Set<(s: ChatState) => void>()
|
||||
const set = (patch: Partial<ChatState>) => {
|
||||
state = { ...state, ...patch }
|
||||
for (const listener of listeners) listener(state)
|
||||
}
|
||||
const chat = {
|
||||
getState: () => state,
|
||||
subscribe: (listener: (s: ChatState) => void) => {
|
||||
@@ -45,15 +65,14 @@ function fakeChat(initial: ChatState = idleState()) {
|
||||
loadConversations: vi.fn(async () => []),
|
||||
deleteConversation: vi.fn(async () => {}),
|
||||
loadOlderMessages: vi.fn(async () => {}),
|
||||
renameConversation: vi.fn(async () => {}),
|
||||
destroy: vi.fn()
|
||||
} satisfies Chat
|
||||
const set = (patch: Partial<ChatState>) => {
|
||||
state = { ...state, ...patch }
|
||||
for (const listener of listeners) listener(state)
|
||||
}
|
||||
return { chat, set }
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
describe('toDisplayMessages', () => {
|
||||
it('maps user, assistant and tool rows, marking the user message of a failed turn', () => {
|
||||
const rows = [
|
||||
@@ -77,17 +96,23 @@ describe('toDisplayMessages', () => {
|
||||
message({ role: 'assistant', content: 'boom', success: false })
|
||||
]
|
||||
const display = toDisplayMessages(rows)
|
||||
expect(display[0]).toEqual({ role: 'user', index: 0, content: 'hi', error: undefined })
|
||||
expect(display[0]).toEqual({
|
||||
role: 'user',
|
||||
index: 0,
|
||||
content: 'hi',
|
||||
error: undefined,
|
||||
images: undefined,
|
||||
contextElements: undefined
|
||||
})
|
||||
expect(display[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning: 'thinking',
|
||||
stepName: 'agent',
|
||||
jobId: 'job-1',
|
||||
createdAt: '2026-09-16T10:00:01Z',
|
||||
streaming: undefined
|
||||
})
|
||||
expect(display[2]).toEqual({ role: 'user', index: 1, content: 'again', error: true })
|
||||
expect(display[2]).toMatchObject({ role: 'user', index: 1, content: 'again', error: true })
|
||||
expect(display[3]).toMatchObject({
|
||||
role: 'tool',
|
||||
tool_call_id: 'tool-row',
|
||||
@@ -229,6 +254,81 @@ describe('toDisplayMessages', () => {
|
||||
isLoading: false
|
||||
})
|
||||
})
|
||||
|
||||
// A row stored before the worker kept the call has only its sentence: the card names the
|
||||
// tool and links its job, and has no details to open.
|
||||
it('builds a tool card from the row alone, with its job link', () => {
|
||||
const display = toDisplayMessages([
|
||||
message({
|
||||
role: 'tool',
|
||||
content: 'Used lookup tool',
|
||||
jobId: 'tool-job',
|
||||
tool: { name: 'lookup', status: 'success', arguments: '{"id":1}', result: '"ok"' }
|
||||
}),
|
||||
message({
|
||||
role: 'tool',
|
||||
content: 'Used search_docs tool',
|
||||
jobId: 'older-job',
|
||||
tool: { name: 'search_docs', status: 'success' }
|
||||
})
|
||||
])
|
||||
expect(display[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
parameters: { id: 1 },
|
||||
result: 'ok',
|
||||
showDetails: true,
|
||||
jobId: 'tool-job'
|
||||
})
|
||||
expect(display[1]).toMatchObject({
|
||||
toolName: 'search_docs',
|
||||
parameters: undefined,
|
||||
result: undefined,
|
||||
showDetails: false,
|
||||
jobId: 'older-job'
|
||||
})
|
||||
})
|
||||
|
||||
it('labels answers with their step only once the transcript names more than one', () => {
|
||||
const one = toDisplayMessages([
|
||||
message({ role: 'assistant', content: 'a', stepName: 'agent' }),
|
||||
message({ role: 'assistant', content: 'b', stepName: 'agent' })
|
||||
])
|
||||
expect(one.map((m) => (m.role === 'assistant' ? m.stepName : null))).toEqual([
|
||||
undefined,
|
||||
undefined
|
||||
])
|
||||
const two = toDisplayMessages([
|
||||
message({ role: 'assistant', content: 'a', stepName: 'agent' }),
|
||||
message({ role: 'assistant', content: 'b', stepName: 'reviewer' })
|
||||
])
|
||||
expect(two.map((m) => (m.role === 'assistant' ? m.stepName : null))).toEqual([
|
||||
'agent',
|
||||
'reviewer'
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the files a user message carried, read from the message itself', () => {
|
||||
const display = toDisplayMessages(
|
||||
[
|
||||
message({
|
||||
role: 'user',
|
||||
content: 'go',
|
||||
attachments: [
|
||||
{ input: 'user_attachments', s3: 'chat/u1/shot.png' },
|
||||
{ input: 'user_attachments', s3: 'chat/u1/notes.pdf' }
|
||||
]
|
||||
})
|
||||
],
|
||||
false,
|
||||
new Set(),
|
||||
{ workspace: 'ws' }
|
||||
)
|
||||
expect(display[0]).toMatchObject({
|
||||
role: 'user',
|
||||
images: [{ name: 'shot.png' }],
|
||||
contextElements: [{ title: 'notes.pdf' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('FlowChatViewHost', () => {
|
||||
@@ -265,10 +365,10 @@ describe('FlowChatViewHost', () => {
|
||||
host.queueMessage('second')
|
||||
expect(host.queuedMessage).toBe('first\nsecond')
|
||||
set({ status: 'idle' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flush()
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
|
||||
releaseTurn()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flush()
|
||||
expect(host.queuedMessage).toBe('')
|
||||
expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', {
|
||||
inputs: undefined,
|
||||
@@ -309,7 +409,7 @@ describe('FlowChatViewHost', () => {
|
||||
// A deployment starts while the turn is still running; the composer is disabled.
|
||||
deploying = true
|
||||
set({ status: 'idle' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flush()
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled()
|
||||
expect(prependText).toHaveBeenCalledWith('after deploy', [], [], [])
|
||||
expect(host.queuedMessage).toBe('')
|
||||
@@ -329,7 +429,7 @@ describe('FlowChatViewHost', () => {
|
||||
set({ status: 'idle' })
|
||||
host.dispose()
|
||||
releaseTurn()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await flush()
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -454,48 +554,30 @@ describe('FlowChatViewHost', () => {
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('retries a failed turn with the files it was sent with', async () => {
|
||||
const { chat, set } = fakeChat(idleState({ messages: [] }))
|
||||
chat.sendMessage.mockImplementationOnce(async () => {
|
||||
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
|
||||
it('retries a failed turn with the files its run had, uploading nothing', async () => {
|
||||
const { chat } = fakeChat(
|
||||
idleState({
|
||||
messages: [
|
||||
message({ role: 'user', content: 'read', jobId: 'flow-job' }),
|
||||
message({ role: 'assistant', content: 'boom', success: false })
|
||||
]
|
||||
})
|
||||
)
|
||||
getJobArgs.mockResolvedValueOnce({
|
||||
user_message: 'read',
|
||||
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
|
||||
} as any)
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
workspace: () => 'ws',
|
||||
attachmentsTarget: () => listInput
|
||||
})
|
||||
// The chat shows the user message before its first await.
|
||||
chat.sendMessage.mockImplementationOnce(async () => {})
|
||||
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
|
||||
const sending = host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
|
||||
set({
|
||||
messages: [
|
||||
message({ id: 'u1', role: 'user', content: 'read' }),
|
||||
message({ role: 'assistant', content: 'boom', success: false })
|
||||
]
|
||||
})
|
||||
await sending
|
||||
host.retryRequest(0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const [text, options] = chat.sendMessage.mock.calls[1] as any
|
||||
await host.retryRequest(0)
|
||||
const [text, options] = chat.sendMessage.mock.calls[0] as any
|
||||
expect(text).toBe('read')
|
||||
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp', 'contract.pdf'])
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('lets go of the files of a turn that succeeded', async () => {
|
||||
const { chat, set } = fakeChat(idleState({ messages: [] }))
|
||||
chat.sendMessage.mockImplementationOnce(async () => {
|
||||
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
|
||||
// The references the run already has, so the files are not uploaded a second time.
|
||||
expect(options.inputs).toEqual({
|
||||
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
|
||||
})
|
||||
chat.sendMessage.mockImplementationOnce(async () => {})
|
||||
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
|
||||
const sending = host.sendRequest({ instructions: 'read', blobs: [pdf] })
|
||||
set({
|
||||
messages: [
|
||||
message({ id: 'u1', role: 'user', content: 'read' }),
|
||||
message({ role: 'assistant', content: 'done' })
|
||||
]
|
||||
})
|
||||
await sending
|
||||
host.retryRequest(0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const [, options] = chat.sendMessage.mock.calls[1] as any
|
||||
expect(options.attachments).toEqual([])
|
||||
host.dispose()
|
||||
})
|
||||
@@ -590,4 +672,163 @@ describe('FlowChatViewHost', () => {
|
||||
set({ status: 'streaming' })
|
||||
expect(host.loading).toBe(false)
|
||||
})
|
||||
|
||||
describe('retry', () => {
|
||||
const failedTurn = () =>
|
||||
idleState({
|
||||
messages: [
|
||||
message({ role: 'user', content: 'go', jobId: 'flow-job' }),
|
||||
message({ role: 'assistant', content: 'boom', success: false })
|
||||
]
|
||||
})
|
||||
|
||||
it("replays the turn with the inputs its run had, not the composer's", async () => {
|
||||
const { chat } = fakeChat(failedTurn())
|
||||
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse', model: 'old' } as any)
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'brief', model: 'new' }),
|
||||
inputsShownInComposer: () => ['model']
|
||||
})
|
||||
await host.retryRequest(0)
|
||||
expect(getJobArgs).toHaveBeenCalledWith({ workspace: 'ws', id: 'flow-job' })
|
||||
// The composer's own control wins for what it shows; everything else replays.
|
||||
expect(chat.sendMessage).toHaveBeenCalledWith(
|
||||
'go',
|
||||
expect.objectContaining({ inputs: { tone: 'terse', model: 'new' } })
|
||||
)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('falls back to a plain resend once the job is purged', async () => {
|
||||
const { chat } = fakeChat(failedTurn())
|
||||
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 404 }))
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'brief' })
|
||||
})
|
||||
await host.retryRequest(0)
|
||||
expect(chat.sendMessage).toHaveBeenCalledWith(
|
||||
'go',
|
||||
expect.objectContaining({ inputs: { tone: 'brief' } })
|
||||
)
|
||||
expect(toast).not.toHaveBeenCalled()
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('does nothing but say so when the run cannot be read', async () => {
|
||||
const { chat } = fakeChat(failedTurn())
|
||||
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('down'), { status: 500 }))
|
||||
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
|
||||
await host.retryRequest(0)
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled()
|
||||
expect(toast).toHaveBeenCalledWith('Could not read what that turn ran with. Try again.', true)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('refuses once a turn started while the run was being read', async () => {
|
||||
const { chat, set } = fakeChat(failedTurn())
|
||||
let answer = (_: unknown) => {}
|
||||
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
|
||||
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
|
||||
const retried = host.retryRequest(0)
|
||||
set({ status: 'streaming' })
|
||||
answer({ user_message: 'go' })
|
||||
await retried
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled()
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
'That chat started another turn. Retry once it finishes.',
|
||||
true
|
||||
)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
// A quick turn can start and settle inside that read: the chat is idle again, but its
|
||||
// latest message is not the one this retry was clicked on.
|
||||
it('refuses once a turn ran and settled while the run was being read', async () => {
|
||||
const { chat, set } = fakeChat(failedTurn())
|
||||
let answer = (_: unknown) => {}
|
||||
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
|
||||
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
|
||||
const retried = host.retryRequest(0)
|
||||
set({ status: 'streaming' })
|
||||
set({ status: 'idle' })
|
||||
answer({ user_message: 'go' })
|
||||
await retried
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled()
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
'That chat started another turn. Retry once it finishes.',
|
||||
true
|
||||
)
|
||||
host.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('streaming reveal', () => {
|
||||
/** A scheduler the test steps by hand, and a clock it advances. */
|
||||
function manualReveal() {
|
||||
let time = 0
|
||||
const queue: (() => void)[] = []
|
||||
return {
|
||||
options: {
|
||||
instant: false,
|
||||
now: () => time,
|
||||
schedule: (cb: () => void) => (queue.push(cb), cb),
|
||||
cancel: (handle: unknown) => {
|
||||
const i = queue.indexOf(handle as () => void)
|
||||
if (i >= 0) queue.splice(i, 1)
|
||||
}
|
||||
},
|
||||
tick: (ms: number) => {
|
||||
time += ms
|
||||
const due = queue.splice(0)
|
||||
for (const cb of due) cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('paces a streaming answer and shows it whole once it settles', () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const reveal = manualReveal()
|
||||
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
|
||||
const streaming = message({ role: 'assistant', id: 'a1', content: '', pending: true })
|
||||
set({ messages: [{ ...streaming, content: 'The answer, in one burst of text.' }] })
|
||||
const shown = () => (host.displayMessages[0] as { content: string }).content
|
||||
// Nothing is revealed until the pacer's first frame, and that frame shows a slice.
|
||||
expect(shown()).toBe('')
|
||||
reveal.tick(16)
|
||||
expect(shown().length).toBeGreaterThan(0)
|
||||
expect(shown().length).toBeLessThan('The answer, in one burst of text.'.length)
|
||||
expect('The answer, in one burst of text.'.startsWith(shown())).toBe(true)
|
||||
// Settled: the whole text, whatever the pacer had got to.
|
||||
set({
|
||||
status: 'idle',
|
||||
messages: [{ ...streaming, content: 'The answer, in one burst of text.', pending: false }]
|
||||
})
|
||||
expect(shown()).toBe('The answer, in one burst of text.')
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('shows a paced row whole once a tool card follows it', () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const reveal = manualReveal()
|
||||
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
|
||||
const answer = message({
|
||||
role: 'assistant',
|
||||
id: 'a1',
|
||||
content: 'Let me look.',
|
||||
pending: true
|
||||
})
|
||||
set({ messages: [answer] })
|
||||
expect((host.displayMessages[0] as { content: string }).content).toBe('')
|
||||
set({
|
||||
messages: [
|
||||
answer,
|
||||
message({ role: 'tool', pending: true, tool: { name: 'lookup', status: 'running' } })
|
||||
]
|
||||
})
|
||||
expect((host.displayMessages[0] as { content: string }).content).toBe('Let me look.')
|
||||
host.dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { attachmentLanes } from './messageAttachments'
|
||||
|
||||
describe('attachmentLanes', () => {
|
||||
it('splits images into thumbnails and everything else into named file chips', () => {
|
||||
const { images, contextElements } = attachmentLanes('ws', [
|
||||
{ input: 'user_attachments', s3: 'chat/u1/shot.PNG', storage: 'secondary' },
|
||||
{ input: 'user_attachments', s3: 'chat/u1/0/notes.pdf', filename: 'Q3 notes.pdf' },
|
||||
{ input: 'report', s3: 'chat/u1/data.csv' }
|
||||
])
|
||||
expect(images.map((i) => i.name)).toEqual(['shot.PNG'])
|
||||
expect(images[0].dataUrl).toContain('/api/w/ws/job_helpers/download_s3_file?')
|
||||
expect(images[0].dataUrl).toContain('file_key=chat%2Fu1%2Fshot.PNG')
|
||||
expect(images[0].dataUrl).toContain('storage=secondary')
|
||||
expect(contextElements.map((c) => c.title)).toEqual(['Q3 notes.pdf', 'data.csv'])
|
||||
})
|
||||
|
||||
it('shows an image as a file chip when there is no workspace to link it in', () => {
|
||||
const { images, contextElements } = attachmentLanes(undefined, [
|
||||
{ input: 'user_attachments', s3: 'chat/u1/shot.png' }
|
||||
])
|
||||
expect(images).toEqual([])
|
||||
expect(contextElements.map((c) => c.title)).toEqual(['shot.png'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/** The files a user message carried, as the lanes its bubble renders: image thumbnails and file chips. */
|
||||
import type { ChatAttachment } from 'windmill-chat'
|
||||
import { base } from '$lib/base'
|
||||
import {
|
||||
createAttachedFileContextElement,
|
||||
type ContextElement
|
||||
} from '$lib/components/copilot/chat/context'
|
||||
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif']
|
||||
|
||||
export type MessageAttachments = { images: AttachedImage[]; contextElements: ContextElement[] }
|
||||
|
||||
function displayName(attachment: ChatAttachment): string {
|
||||
return attachment.filename || attachment.s3.split('/').pop() || attachment.s3
|
||||
}
|
||||
|
||||
function looksLikeImage(attachment: ChatAttachment): boolean {
|
||||
const name = displayName(attachment).toLowerCase()
|
||||
return IMAGE_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
/** Same-origin, cookie-authed GET, usable directly as an <img src>. */
|
||||
function downloadUrl(workspace: string, attachment: ChatAttachment): string {
|
||||
const params = new URLSearchParams({ file_key: attachment.s3 })
|
||||
if (attachment.storage) params.set('storage', attachment.storage)
|
||||
return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Without a workspace there is no link to build, so an image falls back to a file chip. */
|
||||
export function attachmentLanes(
|
||||
workspace: string | undefined,
|
||||
attachments: readonly ChatAttachment[] | undefined
|
||||
): MessageAttachments {
|
||||
const images: AttachedImage[] = []
|
||||
const contextElements: ContextElement[] = []
|
||||
for (const attachment of attachments ?? []) {
|
||||
const name = displayName(attachment)
|
||||
if (workspace && looksLikeImage(attachment)) {
|
||||
images.push({ dataUrl: downloadUrl(workspace, attachment), mediaType: 'image/png', name })
|
||||
} else {
|
||||
contextElements.push(
|
||||
createAttachedFileContextElement(name, `Attached file · ${attachment.s3}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
return { images, contextElements }
|
||||
}
|
||||
@@ -639,63 +639,74 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full flex flex-col',
|
||||
chatInputEnabled ? 'p-3 h-full' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
chatInputEnabled ? 'h-full min-h-0' : 'max-w-3xl p-6 min-h-[300px] justify-center',
|
||||
'mx-auto'
|
||||
)}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
<!-- The chat reaches the edges of the pane, so the notices above it carry their
|
||||
own padding. `contents` leaves the form layout exactly as it was. -->
|
||||
<!-- Top spacing hangs off the first notice, not the wrapper: `{#if}` leaves a
|
||||
comment anchor behind, so an empty wrapper is not `:empty` and its own
|
||||
padding would show as a gap above a chat with nothing to announce. -->
|
||||
<div
|
||||
class={chatInputEnabled ? 'flex flex-col px-3 [&>*:first-child]:mt-3' : 'contents'}
|
||||
>
|
||||
{#if flow?.path}
|
||||
<CiTestResults path={flow.path} kind="flow" />
|
||||
{/if}
|
||||
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
{#if flow?.archived}
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
{#if pinnedVersion !== undefined}
|
||||
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
|
||||
This is a historical version of the flow, not the latest.
|
||||
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
|
||||
View latest
|
||||
</a>
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
<!-- In chat mode the description belongs to the chat, which shows it under the
|
||||
empty transcript. -->
|
||||
{#if !chatInputEnabled && !emptyString(flow?.description)}
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown
|
||||
md={defaultIfEmptyString(flow?.description, 'No description')}
|
||||
noPadding
|
||||
/>
|
||||
</div>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
{#if deploymentInProgress}
|
||||
<div class="pb-4" transition:slide={{ duration: 150 }}>
|
||||
<HeaderBadge color="yellow">
|
||||
<Loader2 size={12} class="inline animate-spin mr-1" />
|
||||
Deployment in progress
|
||||
{#if deploymentJobId}
|
||||
<a
|
||||
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
|
||||
class="underline"
|
||||
target="_blank">view job</a
|
||||
>
|
||||
{/if}
|
||||
</HeaderBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
|
||||
<Alert type="error" title="Deployment failed">
|
||||
<p>
|
||||
This flow has not been deployed successfully because of the following errors:
|
||||
</p>
|
||||
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
|
||||
</Alert>
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chatInputEnabled}
|
||||
<!-- Chat Layout with Sidebar -->
|
||||
@@ -707,6 +718,7 @@
|
||||
inputSchema={flow?.schema}
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
frame="none"
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
|
||||
Reference in New Issue
Block a user