mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: render flow chat mode through the AI session chat components
Flow chat mode drew its own bubbles from chat/ChatMessage and chat/ChatInput while the AI session chat rendered the same kinds of message far better a directory away. FlowChatViewHost maps a flow run's conversation onto ChatViewHost, so both now render through AIChatDisplay. Flow chat gains thinking blocks, workspace-path links, collapsible tool cards, the typing indicator, scroll-to-latest, and queueing a message typed while the run is in flight. FlowChatManager keeps its conversations, SSE and polling untouched; the sidebar, the inputs modal and its localStorage are unchanged. An assistant message can carry the flow step that produced it, rendered only when a conversation holds more than one distinct step — with a single agent the label repeats on every message and says nothing. It is counted over the transcript rather than the flow's current steps, since a conversation outlives edits to the flow. AssistantMessage also renders a windmill_s3_object result through DisplayResult, as chat/ChatMessage did. FlowChatInterface claims a min-height once there are messages: the editor's Test-flow panel stacks the chat above the job result in an auto-height column, where the transcript's absolute scroller would otherwise resolve to zero. chat/ChatMessage and chat/ChatInput stay for AppChat and its customCss hooks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hw1yzWHyqqZmQrviUEhr5b
This commit is contained in:
co-authored by
Claude Opus 5
parent
4ac845dde6
commit
c8350480df
@@ -13,6 +13,7 @@
|
||||
workspaceItemRegistry
|
||||
} from './workspaceItems.svelte'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
|
||||
interface Props {
|
||||
message: DisplayMessage
|
||||
@@ -58,6 +59,20 @@
|
||||
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
|
||||
}
|
||||
|
||||
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
|
||||
|
||||
// A flow step can return a file rather than text; the raw JSON would be
|
||||
// unreadable, so hand it to the result viewer instead of the markdown renderer.
|
||||
const s3Object = $derived.by(() => {
|
||||
if (!message.content.startsWith('{')) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(message.content)
|
||||
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const candidatePaths = $derived(extractCandidatePaths(message.content))
|
||||
const rendererPlugin = {
|
||||
renderer: {
|
||||
@@ -111,7 +126,13 @@
|
||||
</ChatCollapsibleCard>
|
||||
{/if}
|
||||
|
||||
{#if message.content}
|
||||
{#if stepName}
|
||||
<div class="text-2xs text-tertiary font-medium mb-1">{stepName}</div>
|
||||
{/if}
|
||||
|
||||
{#if s3Object}
|
||||
<DisplayResult result={s3Object} workspaceId={$workspaceStore} noControls={true} />
|
||||
{:else if message.content}
|
||||
<div class="w-full space-y-2 {markdownProse.sm}">
|
||||
<Markdown md={message.content} {plugins} />
|
||||
</div>
|
||||
|
||||
@@ -629,6 +629,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
* would look like it is still streaming forever.
|
||||
*/
|
||||
streaming?: boolean
|
||||
/** Flow step that produced this message, when the conversation is a flow run
|
||||
* rather than a copilot turn. Rendered as a label above the content. */
|
||||
stepName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
path: string
|
||||
hideSidebar?: boolean
|
||||
inputSchema?: Record<string, any>
|
||||
/** Wider centered column, for the full-page chat. */
|
||||
wideLayout?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,7 +27,8 @@
|
||||
useStreaming = false,
|
||||
path,
|
||||
hideSidebar = false,
|
||||
inputSchema = undefined
|
||||
inputSchema = undefined,
|
||||
wideLayout = false
|
||||
}: Props = $props()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -72,5 +75,11 @@
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar {manager} />
|
||||
{/if}
|
||||
<FlowChatInterface {manager} {deploymentInProgress} {additionalInputsSchema} {path} />
|
||||
<FlowChatInterface
|
||||
{manager}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{path}
|
||||
{wideLayout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { MessageCircle, Loader2, Settings2 } from 'lucide-svelte'
|
||||
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
|
||||
import ChatInput from '$lib/components/chat/ChatInput.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte'
|
||||
import { FlowChatManager } from './FlowChatManager.svelte'
|
||||
import { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
|
||||
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { type DynamicInput } from '$lib/utils'
|
||||
@@ -13,9 +14,16 @@
|
||||
deploymentInProgress?: boolean
|
||||
additionalInputsSchema?: Record<string, any>
|
||||
path: string
|
||||
wideLayout?: boolean
|
||||
}
|
||||
|
||||
let { manager, deploymentInProgress = false, additionalInputsSchema, path }: Props = $props()
|
||||
let {
|
||||
manager,
|
||||
deploymentInProgress = false,
|
||||
additionalInputsSchema,
|
||||
path,
|
||||
wideLayout = false
|
||||
}: Props = $props()
|
||||
|
||||
// Derive helperScript for dynamic inputs from schema
|
||||
const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => {
|
||||
@@ -63,19 +71,24 @@
|
||||
showInputsModal = false
|
||||
}
|
||||
|
||||
function handleSendMessage() {
|
||||
const inputs = additionalInputsSchema
|
||||
? (loadInputsFromStorage() ?? additionalInputsValues)
|
||||
: undefined
|
||||
manager.sendMessage(inputs)
|
||||
}
|
||||
|
||||
function openInputsModal() {
|
||||
const stored = loadInputsFromStorage()
|
||||
if (stored) additionalInputsValues = stored
|
||||
showInputsModal = true
|
||||
}
|
||||
|
||||
const chatHost = new FlowChatViewHost(manager, () =>
|
||||
additionalInputsSchema ? (loadInputsFromStorage() ?? additionalInputsValues) : undefined
|
||||
)
|
||||
setChatViewHost(chatHost)
|
||||
|
||||
// A message typed mid-run is held by the host; send it once the run settles.
|
||||
$effect(() => {
|
||||
if (!chatHost.loading && chatHost.queuedMessage) {
|
||||
chatHost.flushQueuedMessage()
|
||||
}
|
||||
})
|
||||
|
||||
const hasMissingRequired = $derived.by(() => {
|
||||
if (!additionalInputsSchema?.required?.length) return false
|
||||
const values = additionalInputsValues ?? {}
|
||||
@@ -101,83 +114,65 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col h-full flex-1 min-w-0">
|
||||
<!-- Messages Container -->
|
||||
<div
|
||||
bind:this={manager.messagesContainer}
|
||||
class="flex-1 min-h-0 overflow-y-auto p-4 bg-background"
|
||||
onscroll={manager.handleScroll}
|
||||
>
|
||||
{#if deploymentInProgress}
|
||||
<Alert type="warning" title="Deployment in progress" size="xs" />
|
||||
{/if}
|
||||
{#snippet emptyHint()}
|
||||
<div class="flex-1 text-center text-tertiary flex items-center justify-center flex-col">
|
||||
{#if manager.isLoadingMessages}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<Loader2 size={32} class="animate-spin" />
|
||||
</div>
|
||||
{:else if manager.messages.length === 0}
|
||||
<div class="text-center text-tertiary flex items-center justify-center flex-col h-full">
|
||||
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg font-medium">Start a conversation</p>
|
||||
<p class="text-sm">Send a message to run the flow and see the results</p>
|
||||
</div>
|
||||
<Loader2 size={32} class="animate-spin" />
|
||||
{:else}
|
||||
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
|
||||
{#each manager.messages as message (message.id)}
|
||||
<ChatMessage
|
||||
role={message.message_type}
|
||||
content={message.content}
|
||||
loading={message.loading}
|
||||
success={message.success}
|
||||
stepName={message.step_name}
|
||||
/>
|
||||
{/each}
|
||||
{#if manager.isWaitingForResponse}
|
||||
<div class="flex items-center gap-2 text-tertiary">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
<span class="text-sm">Processing...</span>
|
||||
</div>
|
||||
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg font-medium">Start a conversation</p>
|
||||
<p class="text-sm">Send a message to run the flow and see the results</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet inputPreface()}
|
||||
{#if additionalInputsSchema}
|
||||
<div class="flex items-center justify-end w-full mb-1">
|
||||
<div class="relative">
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Settings2 }}
|
||||
title="Inputs"
|
||||
onClick={openInputsModal}
|
||||
>
|
||||
Inputs
|
||||
</Button>
|
||||
{#if hasMissingRequired}
|
||||
<span class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="flex flex-col items-center p-2 xl:max-w-7xl mx-auto w-full gap-2">
|
||||
{#if additionalInputsSchema}
|
||||
<div class="flex items-center justify-end w-full">
|
||||
<div class="relative">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Settings2 }}
|
||||
title="Inputs"
|
||||
onClick={openInputsModal}
|
||||
>
|
||||
Inputs
|
||||
</Button>
|
||||
{#if hasMissingRequired}
|
||||
<span class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full" class:opacity-50={deploymentInProgress}>
|
||||
<ChatInput
|
||||
bind:value={manager.inputMessage}
|
||||
bind:bindTextarea={manager.inputElement}
|
||||
disabled={manager.isLoading || deploymentInProgress}
|
||||
onSend={handleSendMessage}
|
||||
onKeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}}
|
||||
showCancelButton={manager.isWaitingForResponse || manager.isLoading}
|
||||
onCancel={() => manager.cancelCurrentJob()}
|
||||
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- The transcript scroller fills its flex row, which needs a height to resolve
|
||||
against. Not every host gives one (the editor's Test-flow panel stacks the
|
||||
chat above the job result in an auto-height column), so once there are
|
||||
messages to scroll, claim one. -->
|
||||
<div
|
||||
class="flex flex-col h-full flex-1 min-w-0"
|
||||
class:min-h-96={chatHost.displayMessages.length > 0}
|
||||
>
|
||||
<AIChatDisplay
|
||||
messages={chatHost.displayMessages}
|
||||
bind:scrollElement={manager.messagesContainer}
|
||||
onTranscriptScroll={manager.handleScroll}
|
||||
pastChats={[]}
|
||||
diffMode={false}
|
||||
selectedContext={[]}
|
||||
availableContext={[]}
|
||||
hideHeader
|
||||
hideModeSelector
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
{inputPreface}
|
||||
placeholder="Send a message to run the flow"
|
||||
disabled={deploymentInProgress}
|
||||
disabledMessage="Deployment in progress"
|
||||
loadPastChat={() => {}}
|
||||
deletePastChat={() => {}}
|
||||
saveAndClear={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type {
|
||||
ChatSendRequestOptions,
|
||||
ChatViewHost
|
||||
} from '$lib/components/copilot/chat/chatViewHost'
|
||||
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
import type { ChatMessage, FlowChatManager } from './FlowChatManager.svelte'
|
||||
import { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
|
||||
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
|
||||
|
||||
function toDisplayMessage(
|
||||
message: ChatMessage,
|
||||
userIndex: number,
|
||||
showStepNames: boolean
|
||||
): DisplayMessage {
|
||||
switch (message.message_type) {
|
||||
case 'user':
|
||||
return { role: 'user', index: userIndex, content: message.content }
|
||||
case 'tool':
|
||||
// Both producers of a tool row — the agent executor and the frontend's own
|
||||
// stream parser — write the whole message as a one-line description of the
|
||||
// call ("Used web_search tool"). There is no result to reveal, so the row
|
||||
// is the label and nothing else. `toolName` stays unset: it drives the
|
||||
// copilot's plan-card detection, which a flow step summary must not trip.
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: message.id,
|
||||
content: message.content,
|
||||
isLoading: message.loading
|
||||
}
|
||||
default:
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: message.content,
|
||||
streaming: message.streaming,
|
||||
stepName: showStepNames ? (message.step_name ?? undefined) : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a flow run's conversation through the AI session chat components. The
|
||||
* turn itself is a flow job, so everything the copilot's own loop owns — context
|
||||
* elements, attachments, autonomy, model choice — is absent here, and the chrome
|
||||
* driving it hides itself (see ChatViewHost).
|
||||
*/
|
||||
export class FlowChatViewHost implements ChatViewHost {
|
||||
#manager: FlowChatManager
|
||||
#additionalInputs: () => Record<string, any> | undefined
|
||||
|
||||
constructor(
|
||||
manager: FlowChatManager,
|
||||
additionalInputs: () => Record<string, any> | undefined = () => undefined
|
||||
) {
|
||||
this.#manager = manager
|
||||
this.#additionalInputs = additionalInputs
|
||||
}
|
||||
|
||||
// The step name says which AI agent step wrote a message, so it only tells the
|
||||
// reader anything once a conversation holds more than one. Counted over the
|
||||
// transcript rather than over the flow's current steps: a conversation outlives
|
||||
// edits to the flow, so it can carry labels from a shape the flow no longer has.
|
||||
#showStepNames = $derived.by(
|
||||
() => new Set(this.#manager.messages.map((m) => m.step_name).filter(Boolean)).size > 1
|
||||
)
|
||||
|
||||
displayMessages = $derived.by(() => {
|
||||
let userIndex = 0
|
||||
const showStepNames = this.#showStepNames
|
||||
return this.#manager.messages.map((message) =>
|
||||
toDisplayMessage(message, message.message_type === 'user' ? userIndex++ : -1, showStepNames)
|
||||
)
|
||||
})
|
||||
messages: readonly unknown[] = []
|
||||
contextTokens = 0
|
||||
loading = $derived.by(() => this.#manager.isLoading || this.#manager.isWaitingForResponse)
|
||||
loadingLabel = undefined
|
||||
compacting = false
|
||||
currentReply = ''
|
||||
currentReasoning = ''
|
||||
currentReasoningActive = false
|
||||
reasoningHiddenIndicatorLabel = undefined
|
||||
|
||||
#automaticScroll = $state(true)
|
||||
get automaticScroll() {
|
||||
return this.#automaticScroll
|
||||
}
|
||||
enableAutomaticScroll = () => {
|
||||
this.#automaticScroll = true
|
||||
}
|
||||
disableAutomaticScroll = () => {
|
||||
this.#automaticScroll = false
|
||||
}
|
||||
|
||||
instructions = ''
|
||||
// The flow run is the send: it is in flight for as long as the job is.
|
||||
get sendInFlight() {
|
||||
return this.#manager.isLoading
|
||||
}
|
||||
sendRequest = async (options: ChatSendRequestOptions = {}) => {
|
||||
const text = options.instructions?.trim()
|
||||
if (!text) return false
|
||||
this.#manager.inputMessage = text
|
||||
await this.#manager.sendMessage(this.#additionalInputs())
|
||||
return true
|
||||
}
|
||||
cancel = () => {
|
||||
void this.#manager.cancelCurrentJob()
|
||||
}
|
||||
setAiChatInput = () => {}
|
||||
|
||||
// A message typed while the flow is running waits here and goes out when the
|
||||
// run finishes (see flushQueuedMessage).
|
||||
queuedMessage = $state('')
|
||||
queuedContext = undefined
|
||||
queuedImages = []
|
||||
queuedFiles = []
|
||||
queueMessage = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed
|
||||
}
|
||||
dequeueMessage = () => {
|
||||
this.queuedMessage = ''
|
||||
}
|
||||
/** Send whatever was typed during the run. Called once the run settles. */
|
||||
flushQueuedMessage = () => {
|
||||
const queued = this.queuedMessage
|
||||
if (!queued) return
|
||||
this.queuedMessage = ''
|
||||
void this.sendRequest({ instructions: queued })
|
||||
}
|
||||
setComposerStaged = () => {}
|
||||
clearComposerStaged = () => {}
|
||||
attachmentBytesExcluding = () => 0
|
||||
|
||||
storedImages = () => undefined
|
||||
retryRequest = () => {}
|
||||
restartGeneration = () => {}
|
||||
handleUserQuestionAnswer = () => false
|
||||
handleToolConfirmation = () => {}
|
||||
|
||||
mode = undefined
|
||||
isSessionChat = false
|
||||
supportsModelSettings = false
|
||||
supportsMessageEditing = false
|
||||
tools = []
|
||||
autonomyMode = AIAutonomyMode.DEFAULT
|
||||
setAutonomyMode = () => {}
|
||||
autoAcceptEditsActive = false
|
||||
autoAcceptEditsAvailable = false
|
||||
autoAcceptToolConfirmationsAvailable = false
|
||||
planModeAvailable = false
|
||||
attachedFiles = new AttachedFilesStore()
|
||||
artifacts = new SessionArtifactsStore()
|
||||
}
|
||||
@@ -705,6 +705,7 @@
|
||||
path={flow?.path ?? ''}
|
||||
useStreaming={shouldUseStreaming}
|
||||
inputSchema={flow?.schema}
|
||||
wideLayout
|
||||
/>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
|
||||
Reference in New Issue
Block a user