refactor: run the flow chat UI on the windmill-chat sdk

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 09:47:26 +02:00
co-authored by Claude Fable 5.1
parent e8c02c04cd
commit 77740b278e
24 changed files with 413 additions and 920 deletions
+2
View File
@@ -9,6 +9,8 @@ on:
push:
paths:
- "frontend/**"
# The flow chat compiles the chat SDK's source in (svelte.config.js alias).
- "chat-sdk/src/**"
- ".github/workflows/frontend-check.yml"
jobs:
+2
View File
@@ -73,6 +73,8 @@ COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
# The flow chat imports the chat SDK's source (svelte.config.js alias `windmill-chat`).
COPY /chat-sdk/src /chat-sdk/src
RUN cd /backend/windmill-api && . ./build_openapi.sh
COPY /backend/parsers/windmill-parser-wasm/pkg/ /backend/parsers/windmill-parser-wasm/pkg/
+1
View File
@@ -185,6 +185,7 @@ await chat.sendMessage('Hello')
| `fetch`, `storage` | Replacements for the globals, for tests and unusual runtimes. |
| `pageSize` | Messages and conversations per page of server history. Default 50. |
| `onFinish`, `onError` | Called when a turn has its answer, or could not run at all. |
| `run` | Runs the flow for a turn yourself and returns the job id, instead of the deployed flow at `flowPath` (Windmill's editor chats with an undeployed flow through a preview run this way). Pass `memory_id` = the conversation id. |
## State
+3 -2
View File
@@ -107,13 +107,14 @@ export class WindmillChatApi {
/**
* One server-sent-events connection to a job's updates. The server closes it after
* `TIMEOUT_SSE_STREAM` (a `timeout` event); resume by calling again with the last
* `stream_offset`, never by re-running the flow.
* `stream_offset`, never by re-running the flow. `poll_delay_ms` is the fastest
* poll an enterprise server offers; others ignore it.
*/
async *streamJob(
jobId: string,
options: { streamOffset?: number; signal?: AbortSignal } = {}
): AsyncGenerator<JobUpdateEvent> {
const query: Record<string, string> = { fast: 'true', only_result: 'true' }
const query: Record<string, string> = { fast: 'true', only_result: 'true', poll_delay_ms: '50' }
if (options.streamOffset !== undefined) {
query.stream_offset = String(options.streamOffset)
}
+27 -15
View File
@@ -133,11 +133,11 @@ class ChatImpl implements Chat {
this.#rememberConversation()
try {
turn.jobId = await this.#api.runFlow(
this.#config.flowPath,
{ ...this.#config.inputs, ...options.inputs, user_message: content },
{ memoryId: conversationId, signal: turn.controller.signal }
)
const args = { ...this.#config.inputs, ...options.inputs, user_message: content }
const context = { memoryId: conversationId, conversationId, signal: turn.controller.signal }
turn.jobId = this.#config.run
? await this.#config.run(args, context)
: await this.#api.runFlow(this.#config.flowPath, args, context)
const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {}
let result: unknown
try {
@@ -421,7 +421,7 @@ class ChatImpl implements Chat {
if (this.#state.history === 'server') {
turn.jobIds = await this.#turnJobIds(turn)
if (!this.#turnActive(turn)) return
const reconciled = await this.#reconcileTurn(turn)
const reconciled = await this.#reconcileTurn(turn, result)
if (!this.#turnActive(turn)) return
if (reconciled) {
this.#set({ status: 'idle' })
@@ -466,7 +466,11 @@ class ChatImpl implements Chat {
* to history. Whether a row counts is read from the message list, not from what
* this read returned: the turn's polling may have merged the answer already.
*/
async #reconcileTurn(turn: Turn): Promise<boolean> {
async #reconcileTurn(turn: Turn, result: unknown): Promise<boolean> {
// An agent writes a row per round, so an earlier row of the turn is not its
// answer: when the result says what the answer is, that row has to have landed.
const expected = isErrorResult(result) ? undefined : extractChatAnswer(result)?.trim()
const answered = () => this.#answered(turn, expected)
for (let attempt = 1; attempt <= RECONCILE_ATTEMPTS; attempt++) {
let rows: FlowConversationMessage[]
try {
@@ -479,32 +483,40 @@ class ChatImpl implements Chat {
if (isAbortError(e)) throw e
if (this.#fallBackToLocal(e)) return false
const refused = e instanceof WindmillApiError && (e.status === 401 || e.status === 403)
if (refused || attempt === RECONCILE_ATTEMPTS) return this.#answered(turn)
if (refused || attempt === RECONCILE_ATTEMPTS) return answered()
await sleep(RECONCILE_DELAY_MS, turn.controller.signal)
continue
}
if (!this.#turnActive(turn)) return true
this.#mergeRows(rows)
if (this.#answered(turn) && !this.#state.messages.some((m) => m.pending && m.content)) break
if (answered() && !this.#state.messages.some((m) => m.pending && m.content)) break
if (attempt < RECONCILE_ATTEMPTS) await sleep(RECONCILE_DELAY_MS, turn.controller.signal)
}
if (!this.#turnActive(turn)) return true
this.#set({ messages: finalized(this.#state.messages) })
return this.#answered(turn)
return answered()
}
/**
* A persisted assistant message written by one of the turn's jobs follows the
* turn's user message. Tool rows alone are not an answer, and neither is a row
* from an earlier turn whose job outlived `stop()` (a token without `jobs:write`
* cannot cancel it), which can land after this turn's user row.
* turn's user message, carrying the expected answer when one is known. Tool rows
* alone are not an answer, and neither is a row from an earlier turn whose job
* outlived `stop()` (a token without `jobs:write` cannot cancel it), which can
* land after this turn's user row.
*/
#answered(turn: Turn): boolean {
#answered(turn: Turn, expected: string | undefined): boolean {
const messages = this.#state.messages
const from = messages.findIndex((m) => m.id === turn.userMessageId)
const ownJob = (m: ChatMessage) =>
turn.jobIds === undefined || (m.jobId !== undefined && turn.jobIds.has(m.jobId))
return messages.some((m, i) => i > from && m.role === 'assistant' && m.seq !== undefined && ownJob(m))
return messages.some(
(m, i) =>
i > from &&
m.role === 'assistant' &&
m.seq !== undefined &&
ownJob(m) &&
(expected === undefined || m.content.trim() === expected)
)
}
/**
+2
View File
@@ -13,6 +13,7 @@ export interface ResolvedConfig {
storage: StorageLike | undefined
storageKey: string | undefined
pageSize: number
run: ChatOptions['run']
onFinish: ChatOptions['onFinish']
onError: ChatOptions['onError']
}
@@ -75,6 +76,7 @@ export function resolveConfig(options: ChatOptions): ResolvedConfig {
storage: options.storage,
storageKey: options.storageKey,
pageSize: options.pageSize ?? 50,
run: options.run,
onFinish: options.onFinish,
onError: options.onError
}
+20 -4
View File
@@ -14,24 +14,40 @@ export type FollowEvent =
* connection resumes from the last `stream_offset`, so no delta is repeated and
* the flow is never re-run. `onOffset` reports each offset so a caller can
* resume later from another connection (see the AI SDK transport).
*
* The offset indexes the stream of one sub-job (`flow_stream_job_id`, the flow's
* streaming step). A retried step gets a new one, so when the id changes the
* offset is dropped and the connection reopened from that sub-job's start.
*/
export async function* followJob(
api: WindmillChatApi,
jobId: string,
options: { signal?: AbortSignal; streamOffset?: number; onOffset?: (offset: number) => void } = {}
): AsyncGenerator<FollowEvent> {
const parser = createStreamEventParser()
let parser = createStreamEventParser()
let offset = options.streamOffset
let streamJobId: string | undefined
while (true) {
let timedOut = false
let reopen = false
for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) {
if (update.type === 'ping') continue
if (update.type === 'timeout') {
timedOut = true
reopen = true
break
}
if (update.type === 'error') throw new Error(update.error)
if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`)
if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) {
const switched = streamJobId !== undefined && offset !== undefined
streamJobId = update.flow_stream_job_id
if (switched) {
// This connection skipped the new sub-job's first chunks: start it over.
offset = undefined
parser = createStreamEventParser()
reopen = true
break
}
}
if (update.stream_offset !== undefined) {
offset = update.stream_offset
options.onOffset?.(offset)
@@ -49,6 +65,6 @@ export async function* followJob(
if (options.signal?.aborted) throw abortError()
// The server closes the connection after its timeout; a dropped connection looks
// the same minus the event. Either way the offset lets the next one resume.
if (!timedOut) await sleep(RECONNECT_DELAY_MS, options.signal)
if (!reopen) await sleep(RECONNECT_DELAY_MS, options.signal)
}
}
+2
View File
@@ -34,6 +34,8 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
() =>
createChat({
...options,
// Sent per message from the latest render instead, so a removed key stays removed.
inputs: undefined,
token:
typeof options.token === 'function'
? () => {
+7
View File
@@ -99,6 +99,13 @@ export interface ChatOptions {
storageKey?: string
/** Messages fetched per page of server history. */
pageSize?: number
/**
* Runs the flow for a turn and returns the job id, instead of the deployed flow at
* `flowPath`. `args` carries `user_message` and the extra inputs; the run must set
* `memory_id` to the conversation id for the conversation and its memory to line up.
* Windmill's own editor uses this to chat with an undeployed flow through a preview run.
*/
run?: (args: Record<string, unknown>, turn: { conversationId: string; signal: AbortSignal }) => Promise<string>
/** Called once a turn has its answer (a failed flow included: its error is the answer). */
onFinish?: (turn: { conversationId: string; jobId?: string; messages: ChatMessage[] }) => void
/** Called when a turn could not run or be followed; `state.error` holds the same error. */
+87
View File
@@ -114,6 +114,63 @@ describe('createChat with local history', () => {
])
})
test('a custom run replaces the deployed flow call and still follows the job', async () => {
const { fetch, calls } = fetchMock((c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'from preview' } }])
: undefined
)
const seen: unknown[] = []
const chat = createChat(
options(
{
token: 'tok',
inputs: { tone: 'kind' },
run: async (args, turn) => {
seen.push({ args, conversationId: turn.conversationId, aborted: turn.signal.aborted })
return 'job-1'
}
},
fetch
)
)
await chat.sendMessage('hi')
expect(seen).toEqual([{ args: { tone: 'kind', user_message: 'hi' }, conversationId: chat.getState().conversationId, aborted: false }])
expect(calls.filter((c) => c.method === 'POST')).toHaveLength(0)
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'from preview'])
})
test('re-attaches from the start when the streaming step is retried under a new sub-job', async () => {
let streams = 0
const { fetch, calls } = fetchMock(run, (c) => {
if (c.url.pathname !== streamPath) return undefined
streams++
if (streams === 1) {
return sse([
{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'first try ' }), stream_offset: 1, flow_stream_job_id: 'agent-1' },
// The retried step streams under a new sub-job; the offset above indexes the old one.
{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'y ' }), stream_offset: 2, flow_stream_job_id: 'agent-2' }
])
}
return sse([
{
type: 'update',
new_result_stream: ndjson({ type: 'token_delta', content: 'second try' }),
stream_offset: 1,
flow_stream_job_id: 'agent-2',
completed: true,
only_result: 'second try'
}
])
})
const chat = createChat(options({ token: 'tok' }, fetch))
await chat.sendMessage('hi')
const streamCalls = calls.filter((c) => c.url.pathname === streamPath)
expect(streamCalls).toHaveLength(2)
expect(streamCalls[1].url.searchParams.get('stream_offset')).toBeNull()
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'first try second try'])
})
test('resumes after a stream timeout from the last offset without re-running the flow', async () => {
let streamCalls = 0
const { fetch, calls } = fetchMock(run, (c) => {
@@ -443,6 +500,36 @@ describe('createChat with server history', () => {
])
})
test('an earlier round of a non-streaming agent is not its answer', async () => {
let reads = 0
const { fetch } = fetchMock(
run,
(c) =>
c.url.pathname === streamPath
? sse([{ type: 'update', completed: true, only_result: { output: 'Final answer', messages: [] } }])
: undefined,
(c) => (c.url.pathname.endsWith('/jobs_u/get/job-1') ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) : undefined),
(c) =>
c.url.pathname.endsWith('/messages')
? json(
++reads === 1
? [messageRow(91, 'user', 'hi'), messageRow(92, 'assistant', 'Let me check', { job_id: 'step-1' }), messageRow(93, 'tool', 'Used lookup tool', { job_id: 'tool-1' })]
: [messageRow(94, 'assistant', 'Final answer', { job_id: 'step-1' })]
)
: undefined,
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
)
const chat = createChat(options({}, fetch))
await chat.sendMessage('hi')
expect(reads).toBe(2)
expect(chat.getState().messages.map((m) => [m.role, m.content, m.serverId])).toEqual([
['user', 'hi', 'row-91'],
['assistant', 'Let me check', 'row-92'],
['tool', 'Used lookup tool', 'row-93'],
['assistant', 'Final answer', 'row-94']
])
})
test('a tool row alone is not the answer of a turn that streamed no text', async () => {
let reads = 0
const { fetch } = fetchMock(
+6
View File
@@ -63,6 +63,12 @@ describe('useWindmillChat', () => {
void hook.sendMessage('hi', { inputs: { extra: true } }).catch(() => {})
await new Promise((r) => setTimeout(r, 20))
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ docId: 'second', extra: true, user_message: 'hi' })
hook.chat.destroy()
// A key the latest render no longer passes is gone from the next message.
const cleared = render({ ...base, fetch, token: 'tok', inputs: {} })
void cleared.sendMessage('again').catch(() => {})
await new Promise((r) => setTimeout(r, 20))
expect(calls.filter((c) => c.method === 'POST')[1]?.body).toEqual({ user_message: 'again' })
unmount()
})
+2
View File
@@ -33,6 +33,8 @@ COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
# The flow chat imports the chat SDK's source (svelte.config.js alias `windmill-chat`).
COPY /chat-sdk/src /chat-sdk/src
RUN cd /backend/windmill-api && . ./build_openapi.sh
COPY /backend/parsers/windmill-parser-wasm/pkg/ /backend/parsers/windmill-parser-wasm/pkg/
+2
View File
@@ -33,6 +33,8 @@ COPY /backend/oauth_connect.json /backend/oauth_connect.json
COPY /openflow.openapi.yaml /openflow.openapi.yaml
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
COPY /system_prompts/auto-generated /system_prompts/auto-generated
# The flow chat imports the chat SDK's source (svelte.config.js alias `windmill-chat`).
COPY /chat-sdk/src /chat-sdk/src
RUN cd /backend/windmill-api && . ./build_openapi.sh
COPY /backend/parsers/windmill-parser-wasm/pkg/ /backend/parsers/windmill-parser-wasm/pkg/
@@ -44,7 +44,6 @@
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import { agentStreamingEnabled } from './flows/agentFormFields'
interface Props {
previewMode: 'upTo' | 'whole'
@@ -161,13 +160,6 @@
let loadingHistory = $state(false)
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
if (previewMode === 'whole') {
return flowStore.val
@@ -470,7 +462,6 @@
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-row justify-center w-full mb-6">
<FlowChat
useStreaming={shouldUseStreaming}
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
await runPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
@@ -4,7 +4,6 @@ import {
AGENT_FIELD_BY_KEY,
AGENT_FIELDS,
agentFieldIsSet,
agentStreamingEnabled,
initialVisibleAgentFields
} from './agentFormFields'
@@ -81,46 +80,3 @@ describe('initialVisibleAgentFields', () => {
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
})
})
// Three chat surfaces decide whether to consume a stream from this, and the worker decides whether
// to send one from `streaming.unwrap_or(true)`. They agree only while absent means on here.
describe('agentStreamingEnabled', () => {
const step = (input_transforms: Record<string, any>, rest: Record<string, any> = {}) => ({
type: 'aiagent',
input_transforms,
...rest
})
it('reads an unwritten field as streaming', () => {
expect(agentStreamingEnabled(step({}))).toBe(true)
// What the API returns for the `{"type":"static"}` placeholder the schema backfill seeds.
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: null } }))).toBe(true)
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: true } }))).toBe(true)
})
it('only an explicit false holds the answer back', () => {
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: false } }))).toBe(false)
})
it('reads off what the step cannot answer for', () => {
// An image answer never streams, whatever `streaming` says.
expect(
agentStreamingEnabled(
step({
streaming: { type: 'static', value: true },
output_type: { type: 'static', value: 'image' }
})
)
).toBe(false)
// A linked step carries no brain: the agent's own `streaming: false` is invisible here.
expect(agentStreamingEnabled(step({}, { agent: 'u/admin/a' }))).toBe(false)
// An expression has no value until the run it would decide is already under way, on either
// of the two fields the answer depends on.
expect(
agentStreamingEnabled(step({ streaming: { type: 'javascript', expr: 'flow_input.s' } }))
).toBe(false)
expect(
agentStreamingEnabled(step({ output_type: { type: 'javascript', expr: 'flow_input.o' } }))
).toBe(false)
})
})
@@ -177,31 +177,6 @@ export function agentFieldIsSet(
return true
}
/**
* Whether a run of this step would stream its answer, mirroring the worker's
* `has_stream = user_wants_streaming && is_text_output`. Absence means on
* (`args.streaming.unwrap_or(true)`), so an unwritten field streams.
*
* A caller that reads this wrong does not merely mislabel the run: a chat surface that opens a
* stream for an answer the worker sends in one piece re-runs the flow when its connection times
* out. So the rule is that anything this cannot settle from the step alone reads as off, the cost
* of being wrong that way being a live answer arriving at the end instead of as it is written.
* Unsettled means either of the two fields holding an expression, whose value exists only once the
* run it decides is already under way, or a linked agent, whose brain lives in the resource where
* this has no sight of it at all.
*/
export function agentStreamingEnabled(value: Record<string, any> | undefined): boolean {
if (value?.agent) return false
const transforms = value?.input_transforms as Record<string, InputTransform | any> | undefined
const settled = (t: InputTransform | any | undefined) => t == undefined || t.type === 'static'
const outputType = transforms?.output_type
const streaming = transforms?.streaming
if (!settled(outputType) || !settled(streaming)) return false
// An image answer never streams, whatever `streaming` says.
if (outputType?.value === 'image') return false
return streaming?.value !== false
}
/**
* Whether the current schema carries this field at all. A linked step's schema is reduced to the
* flow-local inputs, which is what collapses its form to the Messages group on its own.
@@ -48,7 +48,6 @@
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
import { agentStreamingEnabled } from '../agentFormFields'
import { nextId } from '../flowModuleNextId'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import FlowChat from '../conversations/FlowChat.svelte'
@@ -97,12 +96,6 @@
)
let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled))
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
let showChatModeWarning = $state(false)
let showAdditionalInputs = $state(false)
let chatInputsEditTab = $state(false)
@@ -756,7 +749,6 @@
onRunFlow={runFlowWithMessage}
path={$pathStore}
hideSidebar={true}
useStreaming={shouldUseStreaming}
inputSchema={flowStore.val.schema}
/>
</div>
@@ -1,18 +1,23 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { createFlowChatManager } from './FlowChatManager.svelte'
import { sendUserToast } from '$lib/toast'
import { createChat, type Chat, type ChatState } from 'windmill-chat'
import FlowConversationsSidebar from './FlowConversationsSidebar.svelte'
import FlowChatInterface from './FlowChatInterface.svelte'
import { getContext, untrack } from 'svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
interface Props {
/**
* Runs the flow for one turn and returns the job id: the deployed flow on the
* flow page, a preview run in the editor. The run must carry `memory_id` =
* `conversationId`, which is what ties the job to the conversation.
*/
onRunFlow: (
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
) => Promise<string | undefined>
useStreaming?: boolean
deploymentInProgress?: boolean
path: string
hideSidebar?: boolean
@@ -22,34 +27,43 @@
let {
onRunFlow,
deploymentInProgress = false,
useStreaming = false,
path,
hideSidebar = false,
inputSchema = undefined
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
// The editor may act on a workspace other than the nav store's (AI-session live editor).
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
const manager = createFlowChatManager()
manager.operatingWorkspace = () => flowEditorContext?.opWorkspace?.()
let chat = $state<Chat | undefined>(undefined)
let chatState = $state<ChatState | undefined>(undefined)
let sidebar = $state<FlowConversationsSidebar | undefined>(undefined)
// Initialize manager when component mounts
$effect(() => {
if ($workspaceStore) {
manager.initialize(onRunFlow, path, useStreaming)
}
const ws = workspace
const flowPath = path
if (!ws || !flowPath) return
const created = createChat({
flowPath,
workspace: ws,
baseUrl: window.location.origin,
history: 'server',
run: async ({ user_message, ...inputs }, { conversationId }) => {
const jobId = await onRunFlow(String(user_message), conversationId, inputs)
if (!jobId) throw new Error('the flow did not start')
// The server creates the conversation with the run, so the sidebar can list
// it now, whatever becomes of the turn.
sidebar?.conversationStarted(conversationId)
return jobId
},
onError: (error) => sendUserToast('Failed to run flow: ' + error.message, true)
})
const unsubscribe = created.subscribe((s) => (chatState = s))
chat = created
return () => {
manager.cleanup()
}
})
// Initialize InfiniteList when component mounts or flowPath changes
$effect(() => {
if ($workspaceStore && path && manager.conversationListComponent) {
untrack(() => {
manager.setupInfiniteList()
})
unsubscribe()
created.destroy()
}
})
@@ -69,8 +83,17 @@
</script>
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
{#if !hideSidebar}
<FlowConversationsSidebar {manager} />
{#if chat && chatState}
{#if !hideSidebar}
<FlowConversationsSidebar bind:this={sidebar} {chat} {chatState} />
{/if}
<FlowChatInterface
{chat}
{chatState}
{deploymentInProgress}
{additionalInputsSchema}
{path}
{workspace}
/>
{/if}
<FlowChatInterface {manager} {deploymentInProgress} {additionalInputsSchema} {path} />
</div>
@@ -3,19 +3,76 @@
import { MessageCircle, Loader2, Settings2 } from 'lucide-svelte'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { type DynamicInput } from '$lib/utils'
import { tick, untrack } from 'svelte'
import type { Chat, ChatState } from 'windmill-chat'
interface Props {
manager: FlowChatManager
chat: Chat
chatState: ChatState
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
path: string
workspace?: string
}
let { manager, deploymentInProgress = false, additionalInputsSchema, path }: Props = $props()
let {
chat,
chatState,
deploymentInProgress = false,
additionalInputsSchema,
path,
workspace = undefined
}: Props = $props()
let inputMessage = $state('')
let inputElement = $state<HTMLTextAreaElement | undefined>(undefined)
let messagesContainer = $state<HTMLDivElement | undefined>(undefined)
let loadingOlder = false
const busy = $derived(chatState.status === 'submitted' || chatState.status === 'streaming')
// Deriveds notify only when their value changes; `chatState` itself is a new
// object on every token, and following it would drag a reader who scrolled up
// back to the end on each one.
const messageCount = $derived(chatState.messages.length)
const conversationId = $derived(chatState.conversationId)
const loadingMessages = $derived(chatState.loadingMessages)
// Follow the conversation: new messages and a conversation switch scroll to the
// end, older pages loaded at the top keep the viewport where it was.
$effect(() => {
messageCount
conversationId
loadingMessages
untrack(() => {
if (loadingOlder) return
tick().then(() => {
if (messagesContainer) messagesContainer.scrollTop = messagesContainer.scrollHeight
})
})
})
async function handleScroll() {
if (
!messagesContainer ||
!chatState.hasMoreMessages ||
chatState.loadingMessages ||
loadingOlder
)
return
if (messagesContainer.scrollTop > 10) return
loadingOlder = true
const previousHeight = messagesContainer.scrollHeight
try {
await chat.loadOlderMessages()
await tick()
messagesContainer.scrollTop = messagesContainer.scrollHeight - previousHeight
} finally {
loadingOlder = false
}
}
// Derive helperScript for dynamic inputs from schema
const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => {
@@ -63,11 +120,17 @@
showInputsModal = false
}
function handleSendMessage() {
async function handleSendMessage() {
const text = inputMessage.trim()
if (!text || busy || deploymentInProgress) return
const inputs = additionalInputsSchema
? (loadInputsFromStorage() ?? additionalInputsValues)
: undefined
manager.sendMessage(inputs)
inputMessage = ''
// A failure is reported through the chat's `onError` and as a failed message.
await chat.sendMessage(text, { inputs }).catch(() => {})
await tick()
inputElement?.focus()
}
function openInputsModal() {
@@ -93,7 +156,7 @@
schema={additionalInputsSchema}
bind:args={additionalInputsValues}
helperScript={dynamicInputHelperScript}
workspace={manager.operatingWorkspace?.()}
{workspace}
/>
{#snippet actions()}
<Button onClick={handleModalConfirm} variant="accent">Save</Button>
@@ -104,18 +167,18 @@
<div class="flex flex-col h-full flex-1 min-w-0">
<!-- Messages Container -->
<div
bind:this={manager.messagesContainer}
bind:this={messagesContainer}
class="flex-1 min-h-0 overflow-y-auto p-4 bg-background"
onscroll={manager.handleScroll}
onscroll={handleScroll}
>
{#if deploymentInProgress}
<Alert type="warning" title="Deployment in progress" size="xs" />
{/if}
{#if manager.isLoadingMessages}
{#if chatState.loadingMessages && chatState.messages.length === 0}
<div class="flex items-center justify-center h-full">
<Loader2 size={32} class="animate-spin" />
</div>
{:else if manager.messages.length === 0}
{:else if chatState.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>
@@ -123,16 +186,15 @@
</div>
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each manager.messages as message (message.id)}
{#each chatState.messages as message (message.id)}
<ChatMessage
role={message.message_type}
role={message.role}
content={message.content}
loading={message.loading}
success={message.success}
stepName={message.step_name}
stepName={message.stepName}
/>
{/each}
{#if manager.isWaitingForResponse}
{#if busy}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Processing...</span>
@@ -148,7 +210,7 @@
<div class="flex items-center justify-end w-full">
<div class="relative">
<Button
size="xs"
unifiedSize="xs"
variant="default"
startIcon={{ icon: Settings2 }}
title="Inputs"
@@ -164,9 +226,9 @@
{/if}
<div class="w-full" class:opacity-50={deploymentInProgress}>
<ChatInput
bind:value={manager.inputMessage}
bind:bindTextarea={manager.inputElement}
disabled={manager.isLoading || deploymentInProgress}
bind:value={inputMessage}
bind:bindTextarea={inputElement}
disabled={busy || deploymentInProgress}
onSend={handleSendMessage}
onKeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
@@ -174,8 +236,8 @@
handleSendMessage()
}
}}
showCancelButton={manager.isWaitingForResponse || manager.isLoading}
onCancel={() => manager.cancelCurrentJob()}
showCancelButton={busy}
onCancel={() => chat.stop()}
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
</div>
@@ -1,713 +0,0 @@
import type { FlowConversation, FlowConversationMessage } from '$lib/gen/types.gen'
import { FlowConversationsService, JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { waitJob } from '$lib/components/waitJob'
import { tick } from 'svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { workspaceStore, userStore } from '$lib/stores'
import { get } from 'svelte/store'
import { parseStreamDeltas } from '$lib/components/chat/utils'
import { randomUUID } from '$lib/utils/uuid'
export interface ChatMessage extends FlowConversationMessage {
loading?: boolean
streaming?: boolean
}
export interface ConversationWithDraft extends FlowConversation {
isDraft?: boolean
}
// Per-turn stream state, kept across SSE reconnects to the same job.
interface StreamTurnState {
accumulatedContent: string
assistantMessageId: string
// Last offset the server reported; sent back on reconnect so the stream resumes
// after the deltas already rendered rather than replaying from the start.
// It indexes the stream of `streamJobId` only.
streamOffset: number | undefined
streamJobId: string | undefined
}
export class FlowChatManager {
// State
messages = $state<ChatMessage[]>([])
inputMessage = $state('')
isLoading = $state(false)
isLoadingMessages = $state(false)
isWaitingForResponse = $state(false)
messagesContainer = $state<HTMLDivElement | undefined>(undefined)
inputElement = $state<HTMLTextAreaElement | undefined>(undefined)
page = $state(1)
hasMoreMessages = $state(false)
loadingMoreMessages = $state(false)
currentEventSource = $state<EventSource | undefined>(undefined)
pollingInterval = $state<ReturnType<typeof setInterval> | undefined>(undefined)
currentJobId = $state<string | undefined>(undefined)
conversations = $state<ConversationWithDraft[]>([])
deletingConversationId = $state<string | undefined>(undefined)
isSidebarExpanded = $state(false)
selectedConversationId = $state<string | undefined>(undefined)
conversationListComponent = $state<InfiniteList | undefined>(undefined)
// Private state
#conversationsCache = $state<Record<string, ChatMessage[]>>({})
#scrollTimeout: ReturnType<typeof setTimeout> | undefined = undefined
#perPage = 50
// Options
#onRunFlow?: (
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
) => Promise<string | undefined>
#useStreaming = $state(false)
#path = $state<string | undefined>(undefined)
// When the flow editor runs as an AI-session live editor, it acts on a workspace
// that can differ from the nav store. FlowChat.svelte wires this to
// FlowEditorContext.opWorkspace so workspace-scoped calls hit the acting workspace.
operatingWorkspace?: () => string | undefined
#workspace(): string | undefined {
return this.operatingWorkspace?.() ?? get(workspaceStore)
}
initialize(
onRunFlow: (
userMessage: string,
conversationId: string,
additionalInputs?: Record<string, any>
) => Promise<string | undefined>,
path: string,
useStreaming: boolean = false
) {
this.#onRunFlow = onRunFlow
this.#path = path
this.#useStreaming = useStreaming
}
updateConversationId(conversationId: string | undefined) {
this.selectedConversationId = conversationId
}
cleanup() {
if (this.currentEventSource) {
this.currentEventSource.close()
this.currentEventSource = undefined
}
this.stopPolling()
this.isLoading = false
this.isWaitingForResponse = false
this.currentJobId = undefined
}
// Public methods for component to call
fillInputMessage(message: string) {
this.inputMessage = message
}
focusInput() {
this.inputElement?.focus()
}
clearMessages() {
this.messages = []
this.inputMessage = ''
this.page = 1
}
async createConversation({ clearMessages = true }: { clearMessages?: boolean }) {
// Check if there's already a draft conversation
const existingDraft = this.conversations.find((c) => c.isDraft)
if (existingDraft) {
// Select the existing draft instead of creating a new one
this.selectedConversationId = existingDraft.id
this.clearMessages()
return existingDraft.id
}
const newConversationId = randomUUID()
this.selectedConversationId = newConversationId
// Create a new conversation object and add it to the top of the list
const newConversation: ConversationWithDraft = {
id: newConversationId,
workspace_id: this.#workspace()!,
flow_path: this.#path!,
title: 'New chat',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: get(userStore)!.username!,
isDraft: true
}
// Prepend to conversations list
this.conversations = [newConversation, ...this.conversations]
// Clear messages in the chat interface
if (clearMessages) {
this.clearMessages()
}
this.focusInput()
return newConversationId
}
setupInfiniteList() {
this.conversationListComponent?.setLoader((page, perPage) =>
this.loadConversations(page, perPage)
)
this.conversationListComponent?.setDeleteItemFn((id) => this.deleteConversation(id))
}
async selectConversation(conversationId: string, isDraft?: boolean) {
this.selectedConversationId = conversationId
// Load conversation messages into chat interface
if (isDraft) {
// For draft conversations, just clear messages (don't try to load from backend)
this.clearMessages()
} else {
// For persisted conversations, load messages from backend
await this.loadConversationMessages(conversationId)
}
}
async refreshConversations() {
await this.conversationListComponent?.loadData('forceRefresh')
}
// Only used by InfiniteList
private async deleteConversation(conversationId: string) {
try {
this.deletingConversationId = conversationId
await FlowConversationsService.deleteFlowConversation({
workspace: this.#workspace()!,
conversationId
})
if (this.selectedConversationId === conversationId) {
this.selectedConversationId = undefined
this.clearMessages()
}
sendUserToast('Conversation deleted successfully')
} catch (error) {
console.error('Failed to delete conversation:', error)
sendUserToast('Failed to delete conversation', true)
throw error
} finally {
this.deletingConversationId = undefined
}
}
async cancelCurrentJob() {
if (!this.#workspace()) {
return
}
try {
if (this.currentJobId) {
await JobService.cancelQueuedJob({
workspace: this.#workspace()!,
id: this.currentJobId,
requestBody: {}
})
sendUserToast(`Job ${this.currentJobId} cancelled`)
}
} catch (error) {
console.error('Error cancelling job:', error)
sendUserToast('Could not cancel job', true)
} finally {
this.cleanup()
}
}
async loadConversationMessages(conversationId?: string) {
this.page = 1
await this.loadMessages(true, conversationId)
}
// Only used by InfiniteList
private async loadConversations(page: number, perPage: number) {
if (!this.#workspace() || !this.#path) return []
try {
const response = await FlowConversationsService.listFlowConversations({
workspace: this.#workspace()!,
flowPath: this.#path,
page: page,
perPage: perPage
})
return response
} catch (error) {
console.error('Failed to load conversations:', error)
sendUserToast('Failed to load conversations', true)
return []
}
}
// Message loading
private async loadMessages(reset: boolean, conversationId?: string) {
let conversationIdToUse = conversationId ?? this.selectedConversationId
if (!this.#workspace() || !conversationIdToUse) return
if (reset) {
if (this.#conversationsCache[conversationIdToUse]) {
this.messages = this.#conversationsCache[conversationIdToUse]
return
}
this.isLoadingMessages = true
} else {
this.loadingMoreMessages = true
}
const pageToFetch = reset ? 1 : this.page + 1
try {
const previousScrollHeight = this.messagesContainer?.scrollHeight || 0
const response = await FlowConversationsService.listConversationMessages({
workspace: this.#workspace()!,
conversationId: conversationIdToUse,
page: pageToFetch,
perPage: this.#perPage
})
if (reset) {
this.#conversationsCache[conversationIdToUse] = response
this.messages = response
this.isLoadingMessages = false
await new Promise((resolve) => setTimeout(resolve, 100))
this.scrollToBottom()
} else {
this.messages = [...response, ...this.messages]
this.page = pageToFetch
// Restore scroll position
await new Promise((resolve) => setTimeout(resolve, 50))
if (this.messagesContainer) {
this.messagesContainer.scrollTop =
this.messagesContainer.scrollHeight - previousScrollHeight
}
}
this.hasMoreMessages = response.length === this.#perPage
} catch (error) {
console.error('Failed to load messages:', error)
sendUserToast('Failed to load messages: ' + error)
} finally {
this.isLoadingMessages = false
this.loadingMoreMessages = false
}
}
handleScroll = () => {
if (this.#scrollTimeout) clearTimeout(this.#scrollTimeout)
this.#scrollTimeout = setTimeout(() => {
if (!this.messagesContainer || !this.hasMoreMessages || this.loadingMoreMessages) return
if (this.messagesContainer.scrollTop <= 10) {
this.loadMessages(false)
}
}, 200)
}
scrollToBottom() {
if (this.messagesContainer) {
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight
}
}
private scrollToUserMessage(messageId: string) {
if (!this.messagesContainer) return
const messageElement = this.messagesContainer.querySelector(`[data-message-id="${messageId}"]`)
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
private getLastPersistedMessageSeq() {
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i]
if (!message.id.startsWith('temp-')) {
return message.created_seq
}
}
return undefined
}
// Polling
private async pollJobResult(jobId: string) {
try {
await waitJob(jobId, this.#workspace())
} catch (error) {
console.error('Error polling job result:', error)
} finally {
// Do a final poll to get all messages from database
try {
if (this.selectedConversationId) {
await this.pollConversationMessages(this.selectedConversationId, {
removeTempMessages: true
})
}
} catch {}
this.cleanup()
}
}
private async pollConversationMessages(
conversationId: string,
options?: { isNewConversation?: boolean; removeTempMessages?: boolean }
) {
if (!this.#workspace()) return
try {
const lastSeq = this.getLastPersistedMessageSeq()
const response = await FlowConversationsService.listConversationMessages({
workspace: this.#workspace()!,
conversationId: conversationId,
page: 1,
perPage: 50,
afterSeq: lastSeq
})
if (options?.isNewConversation) {
await this.refreshConversations()
}
const filteredResponse = response.filter((msg) => msg.message_type !== 'user')
for (const msg of filteredResponse) {
if (!this.messages.find((m) => m.id === msg.id)) {
this.messages = [...this.messages, msg]
}
}
// Only remove temporary messages when explicitly requested (e.g., after job completion)
// During streaming, we keep temp messages to avoid them disappearing due to race conditions
if (options?.removeTempMessages) {
this.messages = this.messages.filter(
(msg) => !msg.id.startsWith('temp-') || msg.message_type === 'user'
)
}
} catch (error) {
console.error('Polling error:', error)
}
}
private startPolling(conversationId: string, isNewConversation?: boolean) {
if (this.pollingInterval) return
this.pollingInterval = setInterval(() => {
this.pollConversationMessages(conversationId, { isNewConversation })
}, 500) // Poll every 0.5 seconds
setTimeout(
() => {
this.stopPolling()
},
2 * 60 * 1000
) // Stop polling after 2 minutes
}
private stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = undefined
}
}
// Message sending
async sendMessage(additionalInputs?: Record<string, any>) {
if (!this.inputMessage.trim() || this.isLoading) return
const isNewConversation = this.messages.length === 0
// Reset state for new message
this.stopPolling()
// Generate a new conversation ID if we don't have one
let currentConversationId = this.selectedConversationId
if (!this.selectedConversationId) {
const newConversationId = await this.createConversation({ clearMessages: false })
currentConversationId = newConversationId
}
if (!currentConversationId) {
console.error('No conversation ID found')
return
}
// Invalidate the conversation cache
delete this.#conversationsCache[currentConversationId]
const userMessage: ChatMessage = {
id: `temp-${randomUUID()}`,
content: this.inputMessage.trim(),
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'user',
conversation_id: currentConversationId
}
this.messages = [...this.messages, userMessage]
const messageContent = this.inputMessage.trim()
this.inputMessage = ''
this.isLoading = true
this.isWaitingForResponse = true
try {
await tick()
this.scrollToUserMessage(userMessage.id)
if (this.#useStreaming && this.#path) {
await this.handleStreamingMessage(
messageContent,
currentConversationId,
isNewConversation,
additionalInputs
)
} else {
await this.handlePollingMessage(
messageContent,
currentConversationId,
isNewConversation,
additionalInputs
)
}
} catch (error) {
console.error('Error running flow:', error)
sendUserToast('Failed to run flow: ' + error, true)
} finally {
if (!this.#useStreaming) {
this.isLoading = false
}
}
await tick()
this.focusInput()
}
private async handleStreamingMessage(
messageContent: string,
currentConversationId: string,
isNewConversation: boolean,
additionalInputs?: Record<string, any>
) {
// Close any existing EventSource
if (this.currentEventSource) {
this.currentEventSource.close()
}
try {
const jobId = await this.#onRunFlow?.(messageContent, currentConversationId, additionalInputs)
if (!jobId) {
console.error('No jobId returned from onRunFlow')
return
}
this.currentJobId = jobId
this.startPolling(currentConversationId, isNewConversation)
this.#followJob(jobId, currentConversationId, {
accumulatedContent: '',
assistantMessageId: '',
streamOffset: undefined,
streamJobId: undefined
})
} catch (error) {
console.error('Stream connection error:', error)
sendUserToast('Failed to connect to stream', true)
this.cleanup()
}
}
// Opens an SSE connection on an already-running job. The server closes every
// stream after TIMEOUT_SSE_STREAM, so a timeout re-enters here with the same
// job and turn state rather than starting a new run.
#followJob(jobId: string, currentConversationId: string, turn: StreamTurnState) {
const streamUrl = `/api/w/${this.#workspace()}/jobs_u/getupdate_sse/${jobId}`
const url = new URL(streamUrl, window.location.origin)
url.searchParams.set('poll_delay_ms', '50')
url.searchParams.set('fast', 'true')
url.searchParams.set('only_result', 'true')
if (turn.streamOffset !== undefined) {
url.searchParams.set('stream_offset', turn.streamOffset.toString())
}
const eventSource = new EventSource(url.toString())
this.currentEventSource = eventSource
let isCompleted = false
eventSource.onmessage = async (event) => {
try {
const data = JSON.parse(event.data)
const type = data.type
if (type === 'timeout') {
eventSource.close()
this.currentEventSource = undefined
this.#followJob(jobId, currentConversationId, turn)
return
}
// Handle ping - just ignore
if (type === 'ping') {
return
}
// Handle error
if (type === 'error') {
eventSource.close()
this.currentEventSource = undefined
console.error('SSE error:', data)
sendUserToast('Stream error: ' + (data.error || 'Unknown error'), true)
this.cleanup()
return
}
// Handle not found
if (type === 'not_found') {
eventSource.close()
this.currentEventSource = undefined
console.error('Job not found')
sendUserToast('Job not found', true)
this.cleanup()
return
}
if (type === 'update') {
if (data.flow_stream_job_id) {
this.currentJobId = data.flow_stream_job_id
if (data.flow_stream_job_id !== turn.streamJobId) {
const offsetFromOtherJob =
turn.streamJobId !== undefined && turn.streamOffset !== undefined
turn.streamJobId = data.flow_stream_job_id
if (offsetFromOtherJob) {
// The offset indexes the previous sub-job's stream (a retried last step
// gets a new one), so this connection skipped the new job's first chunks.
// Drop this delta and re-attach from the start of the new sub-job.
turn.streamOffset = undefined
eventSource.close()
this.currentEventSource = undefined
this.#followJob(jobId, currentConversationId, turn)
return
}
}
}
if (data.stream_offset !== undefined) {
turn.streamOffset = data.stream_offset
}
// Process new stream content
if (data.new_result_stream) {
// Stop polling since we are receiving last step streaming
this.stopPolling()
const { type, content: newContent, success } = parseStreamDeltas(data.new_result_stream)
turn.accumulatedContent += newContent
// Create tool message if type is tool_result
if (type === 'tool_result') {
// set last message streaming to false
this.messages = this.messages.map((msg) =>
msg.id === this.messages[this.messages.length - 1].id
? { ...msg, streaming: false }
: msg
)
this.messages = [
...this.messages,
{
id: 'temp-' + randomUUID(),
content: newContent,
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'tool',
conversation_id: currentConversationId,
job_id: '',
loading: false,
streaming: false,
success
}
]
// Reset assistant message ID since we are creating a tool message
turn.assistantMessageId = ''
turn.accumulatedContent = ''
}
// Create message on first content
else if (
type === 'message' &&
turn.assistantMessageId.length === 0 &&
turn.accumulatedContent.length > 0
) {
turn.assistantMessageId = 'temp-' + randomUUID()
this.messages = [
...this.messages,
{
id: turn.assistantMessageId,
content: turn.accumulatedContent,
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'assistant',
conversation_id: currentConversationId,
job_id: '',
loading: false,
streaming: true
}
]
} else {
// Update existing message
this.messages = this.messages.map((msg) =>
msg.id === turn.assistantMessageId
? { ...msg, content: turn.accumulatedContent }
: msg
)
}
}
// Handle completion
if (data.completed) {
isCompleted = true
// Do a final poll to get all messages from database
if (this.selectedConversationId) {
await this.pollConversationMessages(this.selectedConversationId, {
removeTempMessages: true
})
}
this.cleanup()
}
}
} catch (error) {
console.error('Error processing stream event:', error)
}
}
eventSource.onerror = (error) => {
if (isCompleted) return
console.error('EventSource error:', error)
sendUserToast('Stream error occurred', true)
this.cleanup()
}
}
private async handlePollingMessage(
messageContent: string,
currentConversationId: string,
isNewConversation: boolean,
additionalInputs?: Record<string, any>
) {
const jobId = await this.#onRunFlow?.(messageContent, currentConversationId, additionalInputs)
if (!jobId) {
console.error('No jobId returned from onRunFlow')
return
}
// Store the current job ID so it can be cancelled
this.currentJobId = jobId
if (isNewConversation) {
await this.refreshConversations()
}
// Start polling for intermediate messages in non-streaming mode too
this.startPolling(currentConversationId)
this.pollJobResult(jobId)
}
}
export const createFlowChatManager = () => new FlowChatManager()
@@ -1,26 +1,72 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
import { type FlowConversation } from '$lib/gen'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { sendUserToast } from '$lib/toast'
import { twMerge } from 'tailwind-merge'
import { FlowChatManager } from './FlowChatManager.svelte'
import { fade } from 'svelte/transition'
import { untrack } from 'svelte'
import type { Chat, ChatState, Conversation } from 'windmill-chat'
interface Props {
manager: FlowChatManager
chat: Chat
chatState: ChatState
}
let { manager }: Props = $props()
let { chat, chatState }: Props = $props()
function getConversationTitle(conversation: FlowConversation): string {
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
let expanded = $state(false)
let list = $state<InfiniteList | undefined>(undefined)
let items = $state<Conversation[]>([])
let deletingId = $state<string | undefined>(undefined)
// A conversation exists on the server only once its first turn ran, so "New chat"
// shows a draft row until then.
let draft = $state(false)
$effect(() => {
const l = list
const c = chat
if (!l) return
untrack(() => {
l.setLoader((page, perPage) => c.loadConversations({ page, perPage }))
l.setDeleteItemFn(async (id: string) => {
deletingId = id
try {
await c.deleteConversation(id)
sendUserToast('Conversation deleted successfully')
} catch (error) {
console.error('Failed to delete conversation:', error)
sendUserToast('Failed to delete conversation', true)
throw error
} finally {
deletingId = undefined
}
})
})
})
/** The container reports a started turn: a conversation's first one creates its server entry. */
export async function conversationStarted(conversationId: string) {
if (items.some((c) => c.id === conversationId)) return
draft = false
await list?.loadData('forceRefresh')
}
const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId))
function newChat() {
chat.newConversation()
draft = true
}
function getConversationTitle(conversation: Conversation): string {
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
}
</script>
<div
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {manager.isSidebarExpanded
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {expanded
? 'w-60'
: 'w-[44px]'}"
>
@@ -31,11 +77,11 @@
unifiedSize="md"
variant="subtle"
startIcon={{
icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen,
icon: expanded ? PanelLeftClose : PanelLeftOpen,
classes: 'ml-[2px]'
}}
onClick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
iconOnly={!manager.isSidebarExpanded}
onClick={() => (expanded = !expanded)}
iconOnly={!expanded}
btnClasses={'justify-start transition-all duration-150'}
title="Conversations"
>
@@ -45,9 +91,9 @@
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={() => manager.createConversation({ clearMessages: true })}
onClick={newChat}
title="Start new conversation"
iconOnly={!manager.isSidebarExpanded}
iconOnly={!expanded}
btnClasses={'justify-start transition-all duration-150 whitespace-nowrap'}
>
<div transition:fade={{ duration: 100 }}> New chat </div>
@@ -56,50 +102,69 @@
</div>
<!-- Conversations List -->
{#if !manager.isSidebarExpanded}
{#if !expanded}
<!-- Collapsed state - show single chat icon with badge -->
<div class="p-1">
<Button
unifiedSize="md"
startIcon={{ icon: MessageCircle }}
onClick={() => (manager.isSidebarExpanded = true)}
title="{manager.conversations.length} conversation{manager.conversations.length !== 1
? 's'
: ''}"
onClick={() => (expanded = true)}
title="{items.length} conversation{items.length !== 1 ? 's' : ''}"
variant="subtle"
btnClasses="w-fit px-2 relative"
>
<CountBadge
count={manager.conversations.length}
small
alwaysVisible={true}
class="right-[3px] top-[3px]"
/>
<CountBadge count={items.length} small alwaysVisible={true} class="right-[3px] top-[3px]" />
</Button>
</div>
{/if}
<!-- Always mount InfiniteList, but hide it when collapsed -->
<div
class="flex-1 overflow-hidden transition-all duration-150 p-1"
class:hidden={!manager.isSidebarExpanded}
>
<div class="flex-1 overflow-hidden transition-all duration-150 p-1" class:hidden={!expanded}>
{#if draftShown && expanded}
<div class="w-full pb-1" transition:fade={{ duration: 100, delay: 30 }}>
<Button
unifiedSize="md"
variant="subtle"
selected={true}
btnClasses="transition-all duration-150 group"
>
<span class="flex-1 text-left truncate">New chat</span>
<Button
wrapperClasses="ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e?.stopPropagation()
draft = false
chat.newConversation()
}}
title="Discard draft"
destructive
unifiedSize="xs"
variant="subtle"
iconOnly
startIcon={{ icon: Trash2 }}
/>
</Button>
</div>
{/if}
<InfiniteList
bind:this={manager.conversationListComponent}
bind:items={manager.conversations}
selectedItemId={manager.selectedConversationId}
bind:this={list}
bind:items
selectedItemId={chatState.conversationId}
noBorder={true}
rounded={false}
preventXOverflow={true}
>
{#snippet customRow({ item: conversation, hover })}
{#if manager.isSidebarExpanded}
{#snippet customRow({ item: conversation })}
{#if expanded}
<div class={twMerge('w-full pb-1')} transition:fade={{ duration: 100, delay: 30 }}>
<Button
unifiedSize="md"
variant="subtle"
onClick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
selected={manager.selectedConversationId === conversation.id}
onClick={() => {
draft = false
chat.selectConversation(conversation.id)
}}
selected={chatState.conversationId === conversation.id}
btnClasses="transition-all duration-150 group"
>
<span class="flex-1 text-left truncate">
@@ -108,23 +173,18 @@
<Button
wrapperClasses={twMerge(
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
manager.deletingConversationId === conversation.id ? 'opacity-100' : ' '
deletingId === conversation.id ? 'opacity-100' : ' '
)}
disabled={manager.deletingConversationId === conversation.id}
disabled={deletingId === conversation.id}
onClick={(e) => {
e?.stopPropagation()
if (conversation.isDraft) {
// just remove first conversation as it is the draft
manager.conversations = [...manager.conversations.slice(1)]
} else {
manager.conversationListComponent?.deleteItem(conversation.id)
}
list?.deleteItem(conversation.id)
}}
title="Delete conversation"
destructive
unifiedSize="xs"
variant="subtle"
loading={manager.deletingConversationId === conversation.id}
loading={deletingId === conversation.id}
iconOnly
startIcon={{ icon: Trash2 }}
/>
@@ -134,9 +194,11 @@
{/snippet}
{#snippet empty()}
<div class="p-4 text-center">
<p class="text-sm text-secondary mb-2">No conversations yet</p>
</div>
{#if !draftShown}
<div class="p-4 text-center">
<p class="text-sm text-secondary mb-2">No conversations yet</p>
</div>
{/if}
{/snippet}
</InfiniteList>
</div>
@@ -84,7 +84,6 @@
onEditInForkClick
} from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { agentStreamingEnabled } from '$lib/components/flows/agentFormFields'
let flow: Flow | undefined = $state()
let can_write = $state(false)
@@ -523,12 +522,6 @@
let showEditButtons = $state(false)
let mainButtons = $derived(getMainButtons(flow, args))
let chatInputEnabled = $derived(flow?.value?.chat_input_enabled ?? false)
let shouldUseStreaming = $derived.by(() => {
const modules = flow?.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -701,7 +694,6 @@
onRunFlow={runFlowForChat}
{deploymentInProgress}
path={flow?.path ?? ''}
useStreaming={shouldUseStreaming}
inputSchema={flow?.schema}
/>
{:else}
+4 -1
View File
@@ -40,7 +40,10 @@ const config = {
},
alias: {
$system_prompts: '../system_prompts/auto-generated',
$oauth_connect_registry: '../backend/oauth_connect.json'
$oauth_connect_registry: '../backend/oauth_connect.json',
// The flow chat runs on the published SDK's source, so the product and the
// package share one implementation (vite.config.js allows serving it).
'windmill-chat': '../chat-sdk/src/index.ts'
}
},
+8
View File
@@ -1,6 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { existsSync, readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { searchForWorkspaceRoot } from 'vite'
import mkcert from 'vite-plugin-mkcert'
const file = fileURLToPath(new URL('package.json', import.meta.url))
@@ -205,6 +206,13 @@ const config = {
],
port: parseInt(process.env.FRONTEND_PORT) || 3000,
cors: { origin: '*' },
// `windmill-chat` (svelte.config.js alias) lives outside the frontend root.
fs: {
allow: [
searchForWorkspaceRoot(process.cwd()),
fileURLToPath(new URL('../chat-sdk', import.meta.url))
]
},
proxy: {
'^/\\.well-known/.*': {
target: remoteUrl,