From 2385dc262d8ea4369bd6b568e4bdf583132a1ecd Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:39:35 +0100 Subject: [PATCH] feat(app): add chat component (#7199) * draft * fix * use user message * correctly stream * add send trigger * add control * fix on success trigger * add warning about expected input * nit * styling * fix stream * styling * dry * dry * nit * better logic * css * cleaning * use managed by component input * fix * fix managed inputs * handle memory * cleaning * cleaning * cleaning * update sqlx * use id --- ...1f7f387f5055c47f493271d26731336257384.json | 10 +- ...7f49c589465194548d88e5ae30872846b70a9.json | 22 ++ ...a90d9206c45d92a0423c0bc2396d0d66a0b0d.json | 4 +- ...51312794c22d6337be897bc618585fa5b27f3.json | 15 + ...f5eae7bb8ed0f5c6e54ca3f49a991c53ed50d.json | 15 - backend/windmill-api/openapi.yaml | 3 + backend/windmill-api/src/apps.rs | 19 +- .../windmill-api/src/flow_conversations.rs | 2 + backend/windmill-api/src/jobs.rs | 19 +- .../windmill-common/src/flow_conversations.rs | 19 + .../apps/components/componentManagedFields.ts | 97 +++++ .../apps/components/display/AppChat.svelte | 338 ++++++++++++++++++ .../helpers/RunnableComponent.svelte | 33 +- .../components/helpers/RunnableWrapper.svelte | 1 + .../apps/components/helpers/eval.ts | 7 +- .../components/helpers/executeRunnable.ts | 7 +- .../editor/component/ComponentInner.svelte | 12 + .../apps/editor/component/components.ts | 72 +++- .../apps/editor/component/default-codes.ts | 13 + .../components/apps/editor/component/sets.ts | 1 + .../componentsPanel/componentControlUtils.ts | 8 + .../apps/editor/componentsPanel/cssUtils.ts | 27 +- .../componentsPanel/quickStyleProperties.ts | 8 + .../InlineScriptEditor.svelte | 54 +-- .../settingsPanel/ComponentPanel.svelte | 24 +- .../inputEditor/RunnableInputEditor.svelte | 10 +- .../apps/editor/settingsPanel/script/utils.ts | 9 +- frontend/src/lib/components/apps/types.ts | 1 + frontend/src/lib/components/apps/utils.ts | 6 + .../src/lib/components/chat/ChatInput.svelte | 87 +++++ .../lib/components/chat/ChatMessage.svelte | 127 +++++++ frontend/src/lib/components/chat/utils.ts | 30 ++ .../conversations/FlowChatInterface.svelte | 62 ++-- .../conversations/FlowChatManager.svelte.ts | 34 +- .../conversations/FlowChatMessage.svelte | 93 ----- 35 files changed, 1026 insertions(+), 263 deletions(-) create mode 100644 backend/.sqlx/query-086fdf726b88e9f4fd9750bf9dd7f49c589465194548d88e5ae30872846b70a9.json create mode 100644 backend/.sqlx/query-348d64dc9f81f04538c5fec98df51312794c22d6337be897bc618585fa5b27f3.json delete mode 100644 backend/.sqlx/query-f5568a691ec5931634cf986f806f5eae7bb8ed0f5c6e54ca3f49a991c53ed50d.json create mode 100644 frontend/src/lib/components/apps/components/componentManagedFields.ts create mode 100644 frontend/src/lib/components/apps/components/display/AppChat.svelte create mode 100644 frontend/src/lib/components/chat/ChatInput.svelte create mode 100644 frontend/src/lib/components/chat/ChatMessage.svelte create mode 100644 frontend/src/lib/components/chat/utils.ts delete mode 100644 frontend/src/lib/components/flows/conversations/FlowChatMessage.svelte diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-086fdf726b88e9f4fd9750bf9dd7f49c589465194548d88e5ae30872846b70a9.json b/backend/.sqlx/query-086fdf726b88e9f4fd9750bf9dd7f49c589465194548d88e5ae30872846b70a9.json new file mode 100644 index 0000000000..2348defd19 --- /dev/null +++ b/backend/.sqlx/query-086fdf726b88e9f4fd9750bf9dd7f49c589465194548d88e5ae30872846b70a9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM flow_conversation WHERE id = $1) as \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "086fdf726b88e9f4fd9750bf9dd7f49c589465194548d88e5ae30872846b70a9" +} diff --git a/backend/.sqlx/query-0a7132202ecf6c4c10340921644a90d9206c45d92a0423c0bc2396d0d66a0b0d.json b/backend/.sqlx/query-0a7132202ecf6c4c10340921644a90d9206c45d92a0423c0bc2396d0d66a0b0d.json index bc15928e38..ecaf828737 100644 --- a/backend/.sqlx/query-0a7132202ecf6c4c10340921644a90d9206c45d92a0423c0bc2396d0d66a0b0d.json +++ b/backend/.sqlx/query-0a7132202ecf6c4c10340921644a90d9206c45d92a0423c0bc2396d0d66a0b0d.json @@ -59,9 +59,7 @@ "failure", "command", "approval", - "preprocessor", - "schedule_handler_old", - "dynamic_skip" + "preprocessor" ] } } diff --git a/backend/.sqlx/query-348d64dc9f81f04538c5fec98df51312794c22d6337be897bc618585fa5b27f3.json b/backend/.sqlx/query-348d64dc9f81f04538c5fec98df51312794c22d6337be897bc618585fa5b27f3.json new file mode 100644 index 0000000000..46d591b6a1 --- /dev/null +++ b/backend/.sqlx/query-348d64dc9f81f04538c5fec98df51312794c22d6337be897bc618585fa5b27f3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status\n SET flow_status = jsonb_set(\n flow_status,\n '{memory_id}',\n to_jsonb($2::uuid)\n )\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "348d64dc9f81f04538c5fec98df51312794c22d6337be897bc618585fa5b27f3" +} diff --git a/backend/.sqlx/query-f5568a691ec5931634cf986f806f5eae7bb8ed0f5c6e54ca3f49a991c53ed50d.json b/backend/.sqlx/query-f5568a691ec5931634cf986f806f5eae7bb8ed0f5c6e54ca3f49a991c53ed50d.json deleted file mode 100644 index 68461c0c98..0000000000 --- a/backend/.sqlx/query-f5568a691ec5931634cf986f806f5eae7bb8ed0f5c6e54ca3f49a991c53ed50d.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status \n SET flow_status = jsonb_set(\n flow_status,\n '{memory_id}',\n to_jsonb($2::uuid)\n )\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "f5568a691ec5931634cf986f806f5eae7bb8ed0f5c6e54ca3f49a991c53ed50d" -} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 48b2a2a08a..49d85ec411 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7707,6 +7707,9 @@ paths: type: array items: type: string + run_query_params: + type: object + description: Runnable query parameters required: - args - component diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index c7b1bd18bc..4849824b0a 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -10,6 +10,7 @@ use std::{collections::HashMap, sync::Arc}; use crate::{ auth::OptTokened, db::{ApiAuthed, DB}, + jobs::RunJobQuery, resources::get_resource_value_interpolated_internal, users::{require_owner_of_path, OptAuthed}, utils::{check_scopes, WithStarredInfoQuery}, @@ -1654,6 +1655,8 @@ pub struct ExecuteApp { pub force_viewer_static_fields: Option, pub force_viewer_one_of_fields: Option, pub force_viewer_allow_user_resources: Option, + /// Runnable query parameters (e.g., memory_id for chat-enabled flows) + pub run_query_params: Option, } fn digest(code: &str) -> String { @@ -1894,6 +1897,12 @@ async fn execute_component( ) .await?; + let is_flow = payload + .path + .as_ref() + .map(|p| p.starts_with("flow/")) + .unwrap_or(false); + let (job_payload, tag, on_behalf_of) = match (payload.path, payload.raw_code, payload.id) { // flow or script: (Some(path), None, None) => get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?, @@ -1920,7 +1929,7 @@ async fn execute_component( let end_user_email = opt_authed.as_ref().map(|a| a.email.clone()); - let (uuid, tx) = push( + let (uuid, mut tx) = push( &db, tx, &w_id, @@ -1954,6 +1963,14 @@ async fn execute_component( None, ) .await?; + + // Apply runnable query parameters if provided + if let Some(ref run_query) = payload.run_query_params { + if is_flow { + crate::jobs::process_flow_run_query_params(&mut tx, uuid, run_query).await?; + } + } + tx.commit().await?; Ok(uuid.to_string()) diff --git a/backend/windmill-api/src/flow_conversations.rs b/backend/windmill-api/src/flow_conversations.rs index 0cbdb7aa55..4854615c18 100644 --- a/backend/windmill-api/src/flow_conversations.rs +++ b/backend/windmill-api/src/flow_conversations.rs @@ -134,6 +134,7 @@ pub async fn get_or_create_conversation_with_id( } else { title.to_string() }; + // Create new conversation with provided ID let conversation = sqlx::query_as!( FlowConversation, @@ -148,6 +149,7 @@ pub async fn get_or_create_conversation_with_id( ) .fetch_one(&mut **tx) .await?; + Ok(conversation) } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index dcb2b6cd08..86e16a3bb0 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -1740,7 +1740,7 @@ pub struct ListableCompletedJob { pub labels: Option, } -#[derive(Deserialize, Clone, Default)] +#[derive(Debug, Deserialize, Clone, Default)] pub struct RunJobQuery { pub scheduled_for: Option>, pub scheduled_in_secs: Option, @@ -3947,7 +3947,7 @@ async fn set_flow_memory_id( memory_id: Uuid, ) -> error::Result<()> { sqlx::query!( - "UPDATE v2_job_status + "UPDATE v2_job_status SET flow_status = jsonb_set( flow_status, '{memory_id}', @@ -3962,6 +3962,19 @@ async fn set_flow_memory_id( Ok(()) } +/// Apply flow-specific query parameters after job creation +pub async fn process_flow_run_query_params( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + job_id: Uuid, + run_query: &RunJobQuery, +) -> error::Result<()> { + // Set memory_id if provided (for agent memory) + if let Some(memory_id) = run_query.memory_id { + set_flow_memory_id(tx, job_id, memory_id).await?; + } + Ok(()) +} + async fn handle_chat_conversation_messages( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, authed: &ApiAuthed, @@ -5670,7 +5683,7 @@ pub async fn stream_job( version, run_query, args, - None + None, ) .await? .0 diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 8826f85b94..472c9c264c 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -15,6 +15,8 @@ pub enum MessageType { } /// Add a message to a conversation using an existing transaction +/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown) +/// This allows memory_id to be used for agent memory without requiring a conversation pub async fn add_message_to_conversation_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, conversation_id: Uuid, @@ -24,6 +26,23 @@ pub async fn add_message_to_conversation_tx( step_name: Option<&str>, success: bool, ) -> Result<()> { + // Check if conversation exists first + let conversation_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM flow_conversation WHERE id = $1) as \"exists!\"", + conversation_id + ) + .fetch_one(&mut **tx) + .await? + .exists; + + if !conversation_exists { + tracing::warn!( + "Conversation {} does not exist. Skipping message insertion. This is expected when flows are called from apps (memory_id is used for agent memory only).", + conversation_id + ); + return Ok(()); + } + // Insert the message sqlx::query!( "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success) diff --git a/frontend/src/lib/components/apps/components/componentManagedFields.ts b/frontend/src/lib/components/apps/components/componentManagedFields.ts new file mode 100644 index 0000000000..0a9f39854d --- /dev/null +++ b/frontend/src/lib/components/apps/components/componentManagedFields.ts @@ -0,0 +1,97 @@ +/** + * Component-Managed Fields Registry + * + * This module provides a centralized registry of fields that are automatically + * managed by components at runtime, rather than being configured by users. + * + * These fields should never be treated as static fields in force_viewer_static_fields, + * as their values are determined dynamically by user interactions (pagination, + * sorting, search, chat input, etc.) + */ + +import type { AppInput } from '$lib/components/apps/inputType' + +/** + * Registry of component types to their managed field names + */ +export const COMPONENT_MANAGED_FIELDS: Record = { + aggridinfinitecomponent: ['offset', 'limit', 'orderBy', 'isDesc', 'search'], + aggridinfinitecomponentee: ['offset', 'limit', 'orderBy', 'isDesc', 'search'], + chatcomponent: ['user_message'] +} + +/** + * Get the list of managed fields for a given component type + */ +export function getManagedFields(componentType: string): string[] { + return COMPONENT_MANAGED_FIELDS[componentType] ?? [] +} + +/** + * Check if a field is managed by a specific component type + */ +export function isFieldManagedByComponent(componentType: string, fieldName: string): boolean { + const managedFields = getManagedFields(componentType) + return managedFields.includes(fieldName) +} + +/** + * Convert component-managed fields from static to evalv2 type + * This ensures they are properly handled at runtime and not added to force_viewer_static_fields + */ +export function convertManagedFieldsToEvalv2( + componentType: string, + componentId: string, + fields: Record +): Record { + const managedFieldNames = getManagedFields(componentType) + + if (managedFieldNames.length === 0) { + return fields + } + + const convertedFields = { ...fields } + + for (const fieldName of managedFieldNames) { + if (convertedFields[fieldName]) { + // Determine the expression based on component type + let expr: string + + if ( + componentType === 'aggridinfinitecomponent' || + componentType === 'aggridinfinitecomponentee' + ) { + // AgGrid components use params.{fieldName} + expr = `${componentId}.params.${fieldName}` + } else if (componentType === 'chatcomponent') { + // Chat component uses userMessage output + expr = `${componentId}.userMessage` + } else { + // Default pattern for future components + expr = `${componentId}.${fieldName}` + } + + // Convert to evalv2 type while preserving fieldType + convertedFields[fieldName] = { + type: 'evalv2', + expr, + fieldType: convertedFields[fieldName].fieldType ?? 'string', + connections: [] + } as AppInput + } + } + + return convertedFields +} + +/** + * Get all managed field names across all component types + * Useful for generic checks + */ +export function getAllManagedFieldNames(): string[] { + const allFields = new Set() + Object.values(COMPONENT_MANAGED_FIELDS).forEach((fields) => { + fields.forEach((field) => allFields.add(field)) + }) + return Array.from(allFields) +} diff --git a/frontend/src/lib/components/apps/components/display/AppChat.svelte b/frontend/src/lib/components/apps/components/display/AppChat.svelte new file mode 100644 index 0000000000..49e6f5d2a1 --- /dev/null +++ b/frontend/src/lib/components/apps/components/display/AppChat.svelte @@ -0,0 +1,338 @@ + + + + +{#each Object.keys(components['chatcomponent'].initialData.configuration) as key (key)} + +{/each} + +{#each Object.keys(css ?? {}) as key (key)} + +{/each} + + + {#if render} +
+ +
+ {#if messages.length === 0} +
+ No messages yet. Start a conversation! +
+ {:else} +
+ {#each messages as message (message.id)} + + {/each} + {#if loading} +
+ + Processing... +
+ {/if} +
+ {/if} +
+ + +
+ +
+
+ {/if} +
diff --git a/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte b/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte index d66675bbe8..0cba5da33e 100644 --- a/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte +++ b/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte @@ -197,6 +197,7 @@ result_stream?: string }) { setResult(nresult_stream, id, false) + dispatch('streamupdate', { id, result_stream: nresult_stream }) }, cancel({ id }: { id: string }) { onCancel?.() @@ -442,7 +443,8 @@ $appPath, id, await buildRequestBody(dynamicArgsOverride), - inlineScriptOverride + inlineScriptOverride, + extraQueryParams ) if (isEditor) { addJob(uuid) @@ -473,35 +475,36 @@ } export async function buildRequestBody(dynamicArgsOverride: Record | undefined) { - const nonStaticRunnableInputs = dynamicArgsOverride ?? {} - const staticRunnableInputs = {} + const nonStaticRunnableInputs: Record = dynamicArgsOverride ?? {} + const staticRunnableInputs: Record = {} const allowUserResources: string[] = [] + for (const k of Object.keys(fields ?? {})) { - let field = fields[k] - if (field?.type == 'static' && fields[k]) { + const field = fields[k] + + if ( + isEditor && + ['user', 'evalv2', 'connected'].includes(field.type) && + 'allowUserResources' in field && + field.allowUserResources + ) { + allowUserResources.push(k) + } + + if (field?.type == 'static') { if (isEditor) { staticRunnableInputs[k] = field.value } } else if (field?.type == 'user') { nonStaticRunnableInputs[k] = args?.[k] - if (isEditor && field.allowUserResources) { - allowUserResources.push(k) - } } else if (field?.type == 'eval' || (field?.type == 'evalv2' && inputValues[k])) { const ctxMatch = field?.expr?.match(ctxRegex) if (ctxMatch) { nonStaticRunnableInputs[k] = '$ctx:' + ctxMatch[1] } else { - // console.log('k', k) nonStaticRunnableInputs[k] = await inputValues[k]?.computeExpr() } - if (isEditor && field?.type == 'evalv2' && field.allowUserResources) { - allowUserResources.push(k) - } } else { - if (isEditor && field?.type == 'connected' && field.allowUserResources) { - allowUserResources.push(k) - } nonStaticRunnableInputs[k] = runnableInputValues[k] } } diff --git a/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte b/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte index 9751363445..9553655799 100644 --- a/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte +++ b/frontend/src/lib/components/apps/components/helpers/RunnableWrapper.svelte @@ -360,6 +360,7 @@ on:cancel on:recompute on:argsChanged + on:streamupdate on:resultSet={(e) => { const res = e.detail if ($initialized?.runnableInitialized?.[fullId] === undefined) { diff --git a/frontend/src/lib/components/apps/components/helpers/eval.ts b/frontend/src/lib/components/apps/components/helpers/eval.ts index bb737040bd..d8cf4ea1ea 100644 --- a/frontend/src/lib/components/apps/components/helpers/eval.ts +++ b/frontend/src/lib/components/apps/components/helpers/eval.ts @@ -31,7 +31,7 @@ function create_context_function_template( ) { let hasReturnAsLastLine = noReturn || eval_string.split('\n').some((x) => x.startsWith('return ')) return ` -return async function (context, state, createProxy, goto, setTab, recompute, globalRecompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll, clearFiles, showToast, waitJob, askNewResource, downloadFile) { +return async function (context, state, createProxy, goto, setTab, recompute, globalRecompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll, clearFiles, sendMessage, showToast, waitJob, askNewResource, downloadFile) { "use strict"; ${ contextKeys && contextKeys.length > 0 @@ -68,6 +68,7 @@ type WmFunctor = ( invalidate, validateAll, clearFiles, + sendMessage, showToast, waitJob, askNewResource, @@ -123,6 +124,7 @@ export async function eval_like( waitJob?: (jobId: string) => void askNewResource?: () => void setGroupValue?: (key: string, value: any) => void + sendMessage?: (message: string) => void } >, worldStore: World | undefined, @@ -246,6 +248,9 @@ export async function eval_like( (id) => { controlComponents[id]?.clearFiles?.() }, + (id, message) => { + controlComponents[id]?.sendMessage?.(message) + }, (message, error) => { sendUserToast(message, error) }, diff --git a/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts b/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts index 397f690ae2..367ec0795f 100644 --- a/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts +++ b/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts @@ -11,7 +11,8 @@ export async function executeRunnable( path: string, id: string, requestBody: ExecuteComponentData['requestBody'], - inlineScriptOverride?: InlineScript + inlineScriptOverride?: InlineScript, + queryParams?: Record ) { let appPath = defaultIfEmptyString(path, `u/${username ?? 'unknown'}/newapp`) if (runnable?.type === 'runnableByName') { @@ -40,6 +41,10 @@ export async function executeRunnable( requestBody['version'] = version } + if (queryParams && Object.keys(queryParams).length > 0) { + requestBody['run_query_params'] = queryParams + } + const uuid = await AppService.executeComponent({ workspace, path: appPath, diff --git a/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte b/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte index 14b19bf719..40261e9260 100644 --- a/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte +++ b/frontend/src/lib/components/apps/editor/component/ComponentInner.svelte @@ -52,6 +52,7 @@ import AppDateInput from '../../components/inputs/AppDateInput.svelte' import AppSelect from '../../components/inputs/AppSelect.svelte' import AppBarChart from '../../components/display/AppBarChart.svelte' + import AppChat from '../../components/display/AppChat.svelte' import AppDivider from '../../components/layout/AppDivider.svelte' import AppRangeInput from '../../components/inputs/AppRangeInput.svelte' import AppTabs from '../../components/layout/AppTabs.svelte' @@ -110,6 +111,7 @@ 'carousellistcomponent', 'chartjscomponent', 'chartjscomponentv2', + 'chatcomponent', 'checkboxcomponent', 'codeinputcomponent', 'conditionalwrapper', @@ -340,6 +342,16 @@ xData={component.xData} {render} /> + {:else if component.type === 'chatcomponent'} + {:else if component.type === 'checkboxcomponent'} +export type ChatComponent = BaseComponent<'chatcomponent'> & RecomputeOthersSource export type JobIdDisplayComponent = BaseComponent<'jobiddisplaycomponent'> export type LogComponent = BaseComponent<'logcomponent'> export type JobIdLogComponent = BaseComponent<'jobidlogcomponent'> @@ -338,6 +340,7 @@ export type RecomputeAllComponent = BaseComponent<'recomputeallcomponent'> export type TypedComponent = | DBExplorerComponent | DisplayComponent + | ChatComponent | LogComponent | JobIdLogComponent | FlowStatusComponent @@ -461,6 +464,23 @@ export type AppComponentConfig = { */ initialData: InitialAppComponent customCss: ComponentCustomCSS + /** + * Optional configuration for runnable inputs validation + */ + runnableInputsInfo?: { + /** + * Function to validate runnable inputs and return a warning if needed + * @param fields - The fields object from componentInput.fields + * @returns Warning object with type, title, and message, or undefined if valid + */ + validate?: (fields: Record) => + | { + type: 'warning' | 'error' | 'info' + title: string + message: string + } + | undefined + } } export type PresetComponentConfig = { @@ -1128,6 +1148,56 @@ export const components = { } } }, + chatcomponent: { + name: 'Chat', + icon: MessageSquare, + documentationLink: `${documentationBaseUrl}/chat`, + dims: '3:8-6:12' as AppComponentDimensions, + customCss: { + container: { class: '', style: '' }, + messagesContainer: { class: '', style: '' }, + inputContainer: { class: '', style: '' }, + userMessage: { class: '', style: '' }, + assistantMessage: { class: '', style: '' }, + input: { class: '', style: '' }, + button: { class: '', style: '' } + }, + runnableInputsInfo: { + validate: (fields) => { + const fieldNames = Object.keys(fields) + const hasUserMessage = fieldNames.includes('user_message') + + if (!hasUserMessage) { + return { + type: 'warning' as const, + title: 'Chat input configuration', + message: + 'The chat component requires a user_message parameter to work. Please add it to your event handler.' + } + } + + return undefined + } + }, + initialData: { + componentInput: { + type: 'runnable', + fieldType: 'any', + fields: {}, + runnable: undefined + }, + recomputeIds: true, + configuration: { + placeholder: { + type: 'static', + fieldType: 'text', + value: 'Type a message...' + }, + onSuccess: onSuccessClick, + onError: onErrorClick + } + } + }, jobidlogcomponent: { name: 'Log by Job Id', icon: Monitor, diff --git a/frontend/src/lib/components/apps/editor/component/default-codes.ts b/frontend/src/lib/components/apps/editor/component/default-codes.ts index 51b3353724..6a00a47cd9 100644 --- a/frontend/src/lib/components/apps/editor/component/default-codes.ts +++ b/frontend/src/lib/components/apps/editor/component/default-codes.ts @@ -765,5 +765,18 @@ return { }`, python3: `def main(): return [{"foo": 1}, {"foo": 2}, {"foo": 3}]` + }, + chatcomponent: { + deno: `export async function main(user_message: string) { + // Process the user message here + // You can call APIs, query databases, use AI models, etc. + + return "Hi, how are you?"; +}`, + python3: `def main(user_message: str): + # Process the user message here + # You can call APIs, query databases, use AI models, etc. + + return "Hi, how are you?"` } } as const diff --git a/frontend/src/lib/components/apps/editor/component/sets.ts b/frontend/src/lib/components/apps/editor/component/sets.ts index c67beee550..53e551e51a 100644 --- a/frontend/src/lib/components/apps/editor/component/sets.ts +++ b/frontend/src/lib/components/apps/editor/component/sets.ts @@ -73,6 +73,7 @@ const display: ComponentSet = { 'htmlcomponent', 'mardowncomponent', 'pdfcomponent', + 'chatcomponent', 'displaycomponent', 'jobidlogcomponent', 'jobidflowstatuscomponent', diff --git a/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts b/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts index a74198b562..ad7e0b7641 100644 --- a/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts +++ b/frontend/src/lib/components/apps/editor/componentsPanel/componentControlUtils.ts @@ -83,6 +83,12 @@ const validateAll = { example: 'validateAll(id: string, key: string)' } +const sendMessage = { + title: 'sendMessage', + description: 'Programmatically send a message to the chat component', + example: 'sendMessage(id: string, message: string)' +} + export function getComponentControl(type: keyof typeof components): Array { switch (type) { case 'tabscomponent': @@ -135,6 +141,8 @@ export function getComponentControl(type: keyof typeof components): Array { - if (newFields[field]?.type !== 'evalv2') { - newFields[field] = { - type: 'evalv2', - expr: `${id}.params.${field}`, - fieldType: newFields[field]?.fieldType ?? 'string' - } - } - }) - } + // Convert component-managed fields to evalv2 type using centralized utility + // This ensures that even if fields were somehow changed, they remain as evalv2 + const convertedFields = convertManagedFieldsToEvalv2(componentType ?? '', id, newFields) + Object.assign(newFields, convertedFields) } async function loadSchemaAndInputsByName() { diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/ComponentPanel.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/ComponentPanel.svelte index fd18def723..e93c507e41 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/ComponentPanel.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/ComponentPanel.svelte @@ -1,5 +1,6 @@ + +
+ + {#if showCancelButton && onCancel} +
diff --git a/frontend/src/lib/components/chat/ChatMessage.svelte b/frontend/src/lib/components/chat/ChatMessage.svelte new file mode 100644 index 0000000000..f811abd0a2 --- /dev/null +++ b/frontend/src/lib/components/chat/ChatMessage.svelte @@ -0,0 +1,127 @@ + + +
+ {#if stepName} +
+ {stepName} +
+ {/if} + + {#if role === 'user'} +

{content}

+ {:else if loading} +
+ + Processing... +
+ {:else if content} + {#if s3Object} +
+ +
+ {:else if enableMarkdown} +
+ {#if role === 'tool'} + {#if success !== false} + + {:else} + + {/if} + {/if} +
+ +
+
+ {:else} +

{content}

+ {/if} + {:else} +

No result

+ {/if} +
diff --git a/frontend/src/lib/components/chat/utils.ts b/frontend/src/lib/components/chat/utils.ts new file mode 100644 index 0000000000..60f59fce10 --- /dev/null +++ b/frontend/src/lib/components/chat/utils.ts @@ -0,0 +1,30 @@ +export function parseStreamDeltas(streamData: string): { + content: string + type?: string + success?: boolean +} { + const lines = streamData.trim().split('\n') + let content = '' + let type = 'message' + let success = true + + for (const line of lines) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) + if (parsed.type === 'tool_result') { + type = 'tool_result' + success = parsed.success + const toolName = parsed.function_name + content = success ? `Used ${toolName} tool` : `Failed to use ${toolName} tool` + } + if (parsed.type === 'token_delta' && parsed.content) { + content += parsed.content + } + } catch (e) { + console.error('Failed to parse stream line:', line, e) + } + } + + return { content, type, success } +} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 7bbad19d25..99e3c84f88 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -1,8 +1,8 @@ - -
- {#if message.step_name} -
{message.step_name}
- {/if} - - {#if message.message_type === 'user'} -

{message.content}

- {:else if message.loading} -
- - Processing... -
- {:else if message.content} - {#if s3Object} -
- -
- {:else} -
- {#if message.message_type === 'tool'} - {#if message.success !== false} - - {:else} - - {/if} - {/if} -
- -
-
- {/if} - {:else} -

No result

- {/if} -