fix(flow chat): fix chat in edit mode + cleaner code (#7118)

* handle conversation for preview endpoints

* rm

* way better chat logic

* remove old logic

* no streaming in flow input

* pass conv id to preview func

* max width on input

* add info

* cleaning

* nits

* nits

* use streaming in preview
This commit is contained in:
centdix
2025-11-12 17:39:48 +01:00
committed by GitHub
parent 3baf646089
commit 30b52544f7
13 changed files with 334 additions and 363 deletions
+12
View File
@@ -7994,6 +7994,12 @@ paths:
schema:
type: boolean
- $ref: "#/components/parameters/NewJobId"
- name: memory_id
description: memory ID for chat-enabled flows
in: query
schema:
type: string
format: uuid
requestBody:
description: preview
@@ -8020,6 +8026,12 @@ paths:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: memory_id
description: memory ID for chat-enabled flows
in: query
schema:
type: string
format: uuid
requestBody:
description: preview
+28 -1
View File
@@ -6329,7 +6329,15 @@ async fn run_preview_flow_job(
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let (uuid, tx) = push(
let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false);
let flow_path = raw_flow.path.clone().unwrap_or_default();
let user_message = raw_flow
.args
.as_ref()
.and_then(|args| args.get("user_message"))
.cloned();
let (uuid, mut tx) = push(
&db,
tx,
&w_id,
@@ -6363,6 +6371,25 @@ async fn run_preview_flow_job(
None,
)
.await?;
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_id {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
}
// Handle conversation messages for chat-enabled flows
if chat_input_enabled {
handle_chat_conversation_messages(
&mut tx,
&authed,
&w_id,
&flow_path,
&run_query,
user_message.as_ref(),
)
.await?;
}
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()))
@@ -38,8 +38,7 @@
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import FlowChatInterface from './flows/conversations/FlowChatInterface.svelte'
import { randomUUID } from './flows/conversations/FlowChatManager.svelte'
import FlowChat from './flows/conversations/FlowChat.svelte'
interface Props {
previewMode: 'upTo' | 'whole'
@@ -95,9 +94,9 @@
let suspendStatus: StateStore<Record<string, { job: Job; nb: number }>> = $state({ val: {} })
let isOwner: boolean = $state(false)
export async function test(): Promise<string | undefined> {
export async function test(conversationId?: string): Promise<string | undefined> {
renderCount++
return await runPreview(previewArgs.val, undefined)
return await runPreview(previewArgs.val, undefined, conversationId)
}
const {
@@ -120,6 +119,16 @@
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
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
return (
lastModule?.value?.type === 'aiagent' &&
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
lastModule?.value?.input_transforms?.streaming?.value === true
)
})
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
const previewFlow = aiChatManager.flowAiChatHelpers?.getPreviewFlow()
if (previewMode === 'whole') {
@@ -139,7 +148,8 @@
let lastPreviewFlow: undefined | string = $state(undefined)
export async function runPreview(
args: Record<string, any>,
restartedFrom: RestartedFrom | undefined
restartedFrom: RestartedFrom | undefined,
conversationId?: string | undefined
) {
let newJobId: string | undefined = undefined
if (stepHistoryLoader?.flowJobInitial !== false) {
@@ -149,7 +159,7 @@
lastPreviewFlow = JSON.stringify(flowStore.val)
flowProgressBar?.reset()
const newFlow = extractFlow(previewMode)
newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom)
newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom, conversationId)
jobId = newJobId
isRunning = true
if (inputSelected) {
@@ -464,15 +474,14 @@
{#if render}
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-row justify-center w-full">
<FlowChatInterface
onRunFlow={async (userMessage, _conversationId) => {
await runPreview({ user_message: userMessage }, undefined)
<FlowChat
useStreaming={shouldUseStreaming}
onRunFlow={async (userMessage, conversationId) => {
await runPreview({ user_message: userMessage }, undefined, conversationId)
return jobId ?? ''
}}
createConversation={async () => {
const newConversationId = randomUUID()
return newConversationId
}}
hideSidebar={true}
path={$pathStore}
/>
</div>
{:else}
@@ -48,7 +48,7 @@
localModuleStates?: Record<string, GraphModuleState>
testModuleStates?: ModulesTestStates
isOwner?: boolean
onTestFlow?: () => Promise<string | undefined>
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
isRunning?: boolean
onCancelTestFlow?: () => void
onOpenPreview?: () => void
@@ -29,7 +29,7 @@
onDeployTrigger?: (trigger: Trigger) => void
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
onTestFlow?: () => Promise<string | undefined>
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
job?: Job
isOwner?: boolean
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
@@ -44,17 +44,16 @@
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import FlowChatInterface from '$lib/components/flows/conversations/FlowChatInterface.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
import { nextId } from '../flowModuleNextId'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { randomUUID } from '../conversations/FlowChatManager.svelte'
import FlowChat from '../conversations/FlowChat.svelte'
interface Props {
noEditor: boolean
disabled: boolean
onTestFlow?: () => Promise<string | undefined>
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
previewOpen: boolean
}
@@ -70,6 +69,15 @@
} = getContext<FlowEditorContext>('FlowEditorContext')
let chatInputEnabled = $derived(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
return (
lastModule?.value?.type === 'aiagent' &&
lastModule?.value?.input_transforms?.streaming?.type === 'static' &&
lastModule?.value?.input_transforms?.streaming?.value === true
)
})
let showChatModeWarning = $state(false)
let addPropertyV2: AddPropertyV2 | undefined = $state(undefined)
@@ -218,7 +226,7 @@
}
async function runPreview() {
await onTestFlow?.()
await onTestFlow?.(undefined)
}
function updatePreviewSchemaAndArgs(payload: any) {
@@ -371,9 +379,12 @@
firstStepInputs?.resetSelected(true)
}
async function runFlowWithMessage(message: string): Promise<string | undefined> {
async function runFlowWithMessage(
message: string,
conversationId: string
): Promise<string | undefined> {
previewArgs.val = { user_message: message }
const jobId = await onTestFlow?.()
const jobId = await onTestFlow?.(conversationId)
return jobId
}
@@ -488,13 +499,12 @@
{#if !disabled}
<div class="flex flex-col h-full">
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex-1 min-h-0">
<FlowChatInterface
<div class="flex flex-col h-full">
<FlowChat
onRunFlow={runFlowWithMessage}
createConversation={async () => {
const newConversationId = randomUUID()
return newConversationId
}}
path={$pathStore}
hideSidebar={true}
useStreaming={shouldUseStreaming}
/>
</div>
{:else}
@@ -0,0 +1,52 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { createFlowChatManager } from './FlowChatManager.svelte'
import FlowConversationsSidebar from './FlowConversationsSidebar.svelte'
import FlowChatInterface from './FlowChatInterface.svelte'
import { untrack } from 'svelte'
interface Props {
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>
useStreaming?: boolean
deploymentInProgress?: boolean
path: string
hideSidebar?: boolean
}
let {
onRunFlow,
deploymentInProgress = false,
useStreaming = false,
path,
hideSidebar = false
}: Props = $props()
const manager = createFlowChatManager()
// Initialize manager when component mounts
$effect(() => {
if ($workspaceStore) {
manager.initialize(onRunFlow, path, useStreaming)
}
return () => {
manager.cleanup()
}
})
// Initialize InfiniteList when component mounts or flowPath changes
$effect(() => {
if ($workspaceStore && path && manager.conversationListComponent) {
untrack(() => {
manager.setupInfiniteList()
})
}
})
</script>
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
{#if !hideSidebar}
<FlowConversationsSidebar {manager} />
{/if}
<FlowChatInterface {manager} {deploymentInProgress} />
</div>
@@ -1,75 +1,16 @@
<script lang="ts">
import { Button, Alert } from '$lib/components/common'
import { MessageCircle, Loader2, ArrowUp, Square } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import autosize from '$lib/autosize'
import FlowChatMessage from './FlowChatMessage.svelte'
import { createFlowChatManager } from './FlowChatManager.svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
interface Props {
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>
useStreaming?: boolean
refreshConversations?: () => Promise<void>
conversationId?: string
manager: FlowChatManager
deploymentInProgress?: boolean
createConversation: (options: { clearMessages?: boolean }) => Promise<string>
path?: string
}
let {
onRunFlow,
conversationId,
refreshConversations,
deploymentInProgress = false,
createConversation,
useStreaming = false,
path
}: Props = $props()
const manager = createFlowChatManager()
// Initialize manager when component mounts
$effect(() => {
if ($workspaceStore) {
manager.initialize(
{
onRunFlow,
createConversation,
refreshConversations,
conversationId,
useStreaming,
path
},
$workspaceStore
)
}
return () => {
manager.cleanup()
}
})
// Update conversation ID when it changes
$effect(() => {
manager.updateConversationId(conversationId)
})
// Public API for parent components
export function fillInputMessage(message: string) {
manager.fillInputMessage(message)
}
export function focusInput() {
manager.focusInput()
}
export function clearMessages() {
manager.clearMessages()
}
export async function loadConversationMessages(conversationId?: string) {
await manager.loadConversationMessages(conversationId)
}
let { manager, deploymentInProgress = false }: Props = $props()
</script>
<div class="flex flex-col h-full flex-1 min-w-0">
@@ -93,7 +34,7 @@
<p class="text-sm">Send a message to run the flow and see the results</p>
</div>
{:else}
<div class="w-full xl:max-w-7xl mx-auto space-y-4">
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each manager.messages as message (message.id)}
<FlowChatMessage {message} />
{/each}
@@ -108,9 +49,9 @@
</div>
<!-- Chat Input -->
<div class="p-2 bg-surface">
<div class="flex flex-row justify-center py-2 xl:max-w-7xl mx-auto w-full">
<div
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface"
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface-input w-full"
class:opacity-50={deploymentInProgress}
>
<textarea
@@ -119,10 +60,10 @@
use:autosize
onkeydown={manager.handleKeyDown}
placeholder="Type your message here..."
class="flex-1 min-h-[24px] max-h-32 resize-none !border-0 !bg-transparent text-sm placeholder-gray-400 !outline-none !ring-0 p-0 !shadow-none focus:!border-0 focus:!outline-none focus:!ring-0 focus:!shadow-none"
class="flex-1 min-h-[24px] max-h-32 resize-none !border-0 text-sm placeholder-gray-400 !outline-none !ring-0 p-0 !shadow-none focus:!border-0 focus:!outline-none focus:!ring-0 focus:!shadow-none"
rows={3}
></textarea>
<div class="flex-shrink-0 pr-2">
<div class="flex-shrink-0 pr-2 bg-surface-input">
{#if manager.isWaitingForResponse || manager.isLoading}
<Button
color="red"
@@ -1,21 +1,19 @@
import type { FlowConversationMessage } from '$lib/gen/types.gen'
import type { FlowConversation, FlowConversationMessage } from '$lib/gen/types.gen'
import { FlowConversationService, 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'
export interface ChatMessage extends FlowConversationMessage {
loading?: boolean
streaming?: boolean
}
export interface FlowChatManagerOptions {
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>
createConversation: (options: { clearMessages?: boolean }) => Promise<string>
refreshConversations?: () => Promise<void>
conversationId?: string
useStreaming?: boolean
path?: string
export interface ConversationWithDraft extends FlowConversation {
isDraft?: boolean
}
export function randomUUID() {
@@ -27,7 +25,7 @@ export function randomUUID() {
})
}
class FlowChatManager {
export class FlowChatManager {
// State
messages = $state<ChatMessage[]>([])
inputMessage = $state('')
@@ -42,33 +40,34 @@ class FlowChatManager {
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
#workspace = $state<string | undefined>(undefined)
// Options
#onRunFlow?: FlowChatManagerOptions['onRunFlow']
#createConversation?: FlowChatManagerOptions['createConversation']
#refreshConversations?: FlowChatManagerOptions['refreshConversations']
#conversationId = $state<string | undefined>(undefined)
#onRunFlow?: (userMessage: string, conversationId: string) => Promise<string | undefined>
#useStreaming = $state(false)
#path = $state<string | undefined>(undefined)
initialize(options: FlowChatManagerOptions, workspace: string) {
this.#onRunFlow = options.onRunFlow
this.#createConversation = options.createConversation
this.#refreshConversations = options.refreshConversations
this.#conversationId = options.conversationId
this.#useStreaming = options.useStreaming ?? false
this.#path = options.path
this.#workspace = workspace
initialize(
onRunFlow: (userMessage: string, conversationId: string) => Promise<string | undefined>,
path: string,
useStreaming: boolean = false
) {
this.#onRunFlow = onRunFlow
this.#path = path
this.#useStreaming = useStreaming
}
updateConversationId(conversationId: string | undefined) {
this.#conversationId = conversationId
this.selectedConversationId = conversationId
}
cleanup() {
@@ -97,15 +96,95 @@ class FlowChatManager {
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: get(workspaceStore)!,
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 FlowConversationService.deleteFlowConversation({
workspace: get(workspaceStore)!,
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) {
if (!get(workspaceStore)) {
return
}
try {
if (this.currentJobId) {
await JobService.cancelQueuedJob({
workspace: this.#workspace,
workspace: get(workspaceStore)!,
id: this.currentJobId,
requestBody: {}
})
@@ -124,10 +203,30 @@ class FlowChatManager {
await this.loadMessages(true, conversationId)
}
// Only used by InfiniteList
private async loadConversations(page: number, perPage: number) {
if (!get(workspaceStore) || !this.#path) return []
try {
const response = await FlowConversationService.listFlowConversations({
workspace: get(workspaceStore)!,
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.#conversationId
if (!this.#workspace || !conversationIdToUse) return
let conversationIdToUse = conversationId ?? this.selectedConversationId
if (!get(workspaceStore) || !conversationIdToUse) return
if (reset) {
if (this.#conversationsCache[conversationIdToUse]) {
@@ -145,7 +244,7 @@ class FlowChatManager {
const previousScrollHeight = this.messagesContainer?.scrollHeight || 0
const response = await FlowConversationService.listConversationMessages({
workspace: this.#workspace,
workspace: get(workspaceStore)!,
conversationId: conversationIdToUse,
page: pageToFetch,
perPage: this.#perPage
@@ -213,8 +312,8 @@ class FlowChatManager {
} finally {
// Do a final poll to get all messages from database
try {
if (this.#conversationId) {
await this.pollConversationMessages(this.#conversationId)
if (this.selectedConversationId) {
await this.pollConversationMessages(this.selectedConversationId)
}
} catch {}
this.cleanup()
@@ -252,12 +351,12 @@ class FlowChatManager {
}
private async pollConversationMessages(conversationId: string, isNewConversation?: boolean) {
if (!this.#workspace) return
if (!get(workspaceStore)) return
try {
const lastId = this.messages[this.messages.length - 1].id
const response = await FlowConversationService.listConversationMessages({
workspace: this.#workspace,
workspace: get(workspaceStore)!,
conversationId: conversationId,
page: 1,
perPage: 50,
@@ -265,7 +364,7 @@ class FlowChatManager {
})
if (isNewConversation) {
await this.#refreshConversations?.()
await this.refreshConversations()
}
const filteredResponse = response.filter((msg) => msg.message_type !== 'user')
@@ -314,9 +413,9 @@ class FlowChatManager {
this.stopPolling()
// Generate a new conversation ID if we don't have one
let currentConversationId = this.#conversationId
if (!this.#conversationId && this.#createConversation) {
const newConversationId = await this.#createConversation({ clearMessages: false })
let currentConversationId = this.selectedConversationId
if (!this.selectedConversationId) {
const newConversationId = await this.createConversation({ clearMessages: false })
currentConversationId = newConversationId
}
@@ -380,16 +479,14 @@ class FlowChatManager {
let isCompleted = false
try {
const jobId = await JobService.runFlowByPath({
workspace: this.#workspace!,
path: this.#path!,
requestBody: { user_message: messageContent },
memoryId: currentConversationId
})
// Encode the payload as base64
const jobId = await this.#onRunFlow?.(messageContent, currentConversationId)
if (!jobId) {
console.error('No jobId returned from onRunFlow')
return
}
// Build the EventSource URL
const streamUrl = `/api/w/${this.#workspace}/jobs_u/getupdate_sse/${jobId}`
const streamUrl = `/api/w/${get(workspaceStore)}/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')
@@ -479,8 +576,8 @@ class FlowChatManager {
if (data.completed) {
isCompleted = true
// Do a final poll to get all messages from database
if (this.#conversationId) {
await this.pollConversationMessages(this.#conversationId)
if (this.selectedConversationId) {
await this.pollConversationMessages(this.selectedConversationId)
}
this.cleanup()
}
@@ -518,7 +615,7 @@ class FlowChatManager {
this.currentJobId = jobId
if (isNewConversation) {
await this.#refreshConversations?.()
await this.refreshConversations()
}
// Start polling for intermediate messages in non-streaming mode too
@@ -8,126 +8,25 @@
PanelLeftOpen,
Loader2
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import { FlowConversationService, type FlowConversation } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { type FlowConversation } from '$lib/gen'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { FlowChatManager } from './FlowChatManager.svelte'
interface Props {
flowPath: string
selectedConversationId?: string
onNewConversation: (options: { clearMessages?: boolean }) => void
onSelectConversation: (conversationId: string, isDraft?: boolean) => void
onDeleteConversation: (conversationId: string) => void
manager: FlowChatManager
}
interface ConversationWithDraft extends FlowConversation {
isDraft?: boolean
}
let {
flowPath,
selectedConversationId,
onNewConversation,
onSelectConversation,
onDeleteConversation
}: Props = $props()
let isExpanded = $state(false)
let infiniteList: InfiniteList | undefined = $state()
let conversations = $state<ConversationWithDraft[]>([])
let deletingConversationId = $state<string | undefined>(undefined)
export async function refreshConversations() {
return await infiniteList?.loadData('forceRefresh')
}
export async function addNewConversation(conversationId: string, username: string) {
// Check if there's already a draft conversation
const existingDraft = conversations.find((c) => c.isDraft)
if (existingDraft) {
// Select the existing draft instead of creating a new one
onSelectConversation(existingDraft.id, true)
return existingDraft.id
}
// Create a new conversation object and add it to the top of the list
const newConversation: ConversationWithDraft = {
id: conversationId,
workspace_id: $workspaceStore!,
flow_path: flowPath,
title: 'New chat',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: username,
isDraft: true
}
// Prepend to conversations list
conversations = [newConversation, ...conversations]
return conversationId
}
async function loadConversations(page: number, perPage: number) {
if (!$workspaceStore || !flowPath) return []
try {
const response = await FlowConversationService.listFlowConversations({
workspace: $workspaceStore,
flowPath: flowPath,
page: page,
perPage: perPage
})
return response
} catch (error) {
console.error('Failed to load conversations:', error)
sendUserToast('Failed to load conversations', true)
return []
}
}
async function deleteConversation(conversationId: string) {
try {
deletingConversationId = conversationId
await FlowConversationService.deleteFlowConversation({
workspace: $workspaceStore!,
conversationId
})
onDeleteConversation(conversationId)
sendUserToast('Conversation deleted successfully')
} catch (error) {
console.error('Failed to delete conversation:', error)
sendUserToast('Failed to delete conversation', true)
throw error
} finally {
deletingConversationId = undefined
}
}
let { manager }: Props = $props()
function getConversationTitle(conversation: FlowConversation): string {
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
}
// Initialize InfiniteList when component mounts or flowPath changes
$effect(() => {
if ($workspaceStore && flowPath) {
if (infiniteList) {
untrack(() => {
infiniteList?.setLoader(loadConversations)
infiniteList?.setDeleteItemFn(deleteConversation)
})
}
}
})
</script>
<div
class="flex flex-col h-full bg-surface border-r border-gray-200 dark:border-gray-700 transition-all duration-300 {isExpanded
class="flex flex-col h-full bg-surface border-r border-gray-200 dark:border-gray-700 transition-all duration-300 {manager.isSidebarExpanded
? 'w-60'
: 'w-16'}"
>
@@ -137,10 +36,10 @@
<Button
size="sm"
color="light"
startIcon={{ icon: isExpanded ? PanelLeftClose : PanelLeftOpen }}
onclick={() => (isExpanded = !isExpanded)}
iconOnly={!isExpanded}
btnClasses="!justify-start"
startIcon={{ icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen }}
onclick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
iconOnly={!manager.isSidebarExpanded}
btnClasses={manager.isSidebarExpanded ? '!justify-start' : ''}
label="Conversations"
>
Conversations
@@ -149,10 +48,10 @@
size="sm"
color="light"
startIcon={{ icon: Plus }}
onclick={() => onNewConversation({ clearMessages: true })}
onclick={() => manager.createConversation({ clearMessages: true })}
title="Start new conversation"
iconOnly={!isExpanded}
btnClasses="!justify-start"
iconOnly={!manager.isSidebarExpanded}
btnClasses={manager.isSidebarExpanded ? '!justify-start' : ''}
label="New chat"
>
New chat
@@ -161,26 +60,28 @@
</div>
<!-- Conversations List -->
{#if !isExpanded}
{#if !manager.isSidebarExpanded}
<!-- Collapsed state - show single chat icon with badge -->
<div class="p-2 flex flex-col items-center mt-2">
<button
class="relative w-[23px] h-[23px] rounded-md center-center hover:bg-surface-hover transition-all duration-100 text-secondary hover:text-primary group"
onclick={() => (isExpanded = true)}
title="{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}"
onclick={() => (manager.isSidebarExpanded = true)}
title="{manager.conversations.length} conversation{manager.conversations.length !== 1
? 's'
: ''}"
>
<MessageCircle size={16} />
<CountBadge count={conversations.length} small={true} alwaysVisible={true} />
<CountBadge count={manager.conversations.length} small={true} alwaysVisible={true} />
</button>
</div>
{/if}
<!-- Always mount InfiniteList, but hide it when collapsed -->
<div class="flex-1 overflow-hidden" class:hidden={!isExpanded}>
<div class="flex-1 overflow-hidden" class:hidden={!manager.isSidebarExpanded}>
<InfiniteList
bind:this={infiniteList}
bind:items={conversations}
selectedItemId={selectedConversationId}
bind:this={manager.conversationListComponent}
bind:items={manager.conversations}
selectedItemId={manager.selectedConversationId}
noBorder={true}
rounded={false}
>
@@ -188,7 +89,7 @@
<div
class={twMerge(
'w-full p-1',
selectedConversationId === conversation.id
manager.selectedConversationId === conversation.id
? 'bg-blue-200/30 text-blue-500 dark:bg-blue-600/30 text-blue-400'
: ''
)}
@@ -196,30 +97,29 @@
<Button
color="transparent"
size="xs"
onclick={() => onSelectConversation(conversation.id, conversation.isDraft)}
onclick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
>
<span class="flex-1 text-left text-sm font-medium text-primary truncate">
{getConversationTitle(conversation)}
</span>
<button
class="ml-2 p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500 transition-all {hover ||
deletingConversationId === conversation.id
manager.deletingConversationId === conversation.id
? 'opacity-100'
: 'opacity-0'}"
disabled={deletingConversationId === conversation.id}
disabled={manager.deletingConversationId === conversation.id}
onclick={(e) => {
e.stopPropagation()
if (conversation.isDraft) {
// just remove first conversation as it is the draft
conversations = [...conversations.slice(1)]
onDeleteConversation(conversation.id)
manager.conversations = [...manager.conversations.slice(1)]
} else {
infiniteList?.deleteItem(conversation.id)
manager.conversationListComponent?.deleteItem(conversation.id)
}
}}
title="Delete conversation"
>
{#if deletingConversationId === conversation.id}
{#if manager.deletingConversationId === conversation.id}
<Loader2 size={14} class="animate-spin" />
{:else}
<Trash2 size={14} />
@@ -237,11 +137,11 @@
</InfiniteList>
</div>
{#if isExpanded}
{#if manager.isSidebarExpanded}
<!-- Footer -->
<div class="flex-shrink-0 p-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-primary">
{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}
{manager.conversations.length} conversation{manager.conversations.length !== 1 ? 's' : ''}
</p>
</div>
{/if}
@@ -50,14 +50,14 @@
flowPreviewContent?.test()
}
export async function runPreview(): Promise<string | undefined> {
export async function runPreview(conversationId?: string): Promise<string | undefined> {
if (!previewOpen) {
deferContent = true
await tick()
}
previewMode = 'whole'
flowPreviewContent?.refresh()
return await flowPreviewContent?.test()
return await flowPreviewContent?.test(conversationId)
}
export function cancelTest() {
@@ -154,7 +154,8 @@ export async function runFlowPreview(
args: Record<string, any>,
flow: OpenFlow & { tag?: string },
path: string,
restartedFrom: RestartedFrom | undefined
restartedFrom: RestartedFrom | undefined,
conversationId?: string | undefined
) {
const newFlow = flow
return await JobService.runFlowPreview({
@@ -165,7 +166,8 @@ export async function runFlowPreview(
path: path,
tag: newFlow.tag,
restarted_from: restartedFrom
}
},
memoryId: conversationId
})
}
@@ -17,8 +17,6 @@
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import FlowChatInterface from '$lib/components/flows/conversations/FlowChatInterface.svelte'
import FlowConversationsSidebar from '$lib/components/flows/conversations/FlowConversationsSidebar.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -65,7 +63,7 @@
initFlowGraphAssetsCtx
} from '$lib/components/flows/FlowAssetsHandler.svelte'
import { page } from '$app/state'
import { randomUUID } from '$lib/components/flows/conversations/FlowChatManager.svelte'
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
let flow: Flow | undefined = $state()
let can_write = false
@@ -393,68 +391,11 @@
}
}
let stepDetail: FlowModule | string | undefined = $state(undefined)
let flowChatInterface: FlowChatInterface | undefined = $state(undefined)
let flowConversationsSidebar: FlowConversationsSidebar | undefined = $state(undefined)
let rightPaneSelected = $state('saved_inputs')
let savedInputsV2: SavedInputsV2 | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
let selectedConversationId: string | undefined = $state(undefined)
let path = $derived(page.params.path ?? '')
async function handleNewConversation({ clearMessages = true }: { clearMessages?: boolean }) {
const newConversationId = randomUUID()
// Add the new conversation to the sidebar (returns id of draft or new conversation)
if (flowConversationsSidebar) {
const actualConversationId = await flowConversationsSidebar.addNewConversation(
newConversationId,
$userStore?.username || 'anonymous'
)
selectedConversationId = actualConversationId
} else {
selectedConversationId = newConversationId
}
// Clear messages in the chat interface
if (flowChatInterface && clearMessages) {
flowChatInterface.clearMessages()
}
flowChatInterface?.focusInput()
return newConversationId
}
async function handleSelectConversation(conversationId: string, isDraft?: boolean) {
selectedConversationId = conversationId
// Load conversation messages into chat interface
if (flowChatInterface) {
if (isDraft) {
// For draft conversations, just clear messages (don't try to load from backend)
flowChatInterface.clearMessages()
} else {
// For persisted conversations, load messages from backend
await flowChatInterface.loadConversationMessages(conversationId)
}
}
}
async function refreshConversations() {
if (flowConversationsSidebar) {
await flowConversationsSidebar.refreshConversations()
}
}
function handleDeleteConversation(conversationId: string) {
if (selectedConversationId === conversationId) {
selectedConversationId = undefined
// Clear chat interface since we deleted the selected conversation
if (flowChatInterface) {
flowChatInterface.clearMessages()
}
}
}
$effect(() => {
const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli')
if (cliTrigger) {
@@ -612,28 +553,12 @@
{#if chatInputEnabled}
<!-- Chat Layout with Sidebar -->
<div
class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1"
>
<FlowConversationsSidebar
bind:this={flowConversationsSidebar}
flowPath={flow?.path ?? ''}
{selectedConversationId}
onNewConversation={handleNewConversation}
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
/>
<FlowChatInterface
bind:this={flowChatInterface}
onRunFlow={runFlowForChat}
useStreaming={shouldUseStreaming}
{refreshConversations}
conversationId={selectedConversationId}
{deploymentInProgress}
createConversation={handleNewConversation}
{path}
/>
</div>
<FlowChat
onRunFlow={runFlowForChat}
{deploymentInProgress}
path={flow?.path ?? ''}
useStreaming={shouldUseStreaming}
/>
{:else}
<!-- Normal Mode: Form Layout -->
<div class="flex flex-col align-left">
@@ -733,10 +658,6 @@
args={args ?? {}}
bind:inputSelected
on:selected_args={(e) => {
if (chatInputEnabled) {
flowChatInterface?.fillInputMessage(e.detail.user_message)
return
}
const nargs = JSON.parse(JSON.stringify(e.detail))
args = nargs
}}