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
This commit is contained in:
centdix
2025-11-24 18:39:35 +01:00
committed by GitHub
parent 6c674792c7
commit 2385dc262d
35 changed files with 1026 additions and 263 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -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"
}
@@ -59,9 +59,7 @@
"failure",
"command",
"approval",
"preprocessor",
"schedule_handler_old",
"dynamic_skip"
"preprocessor"
]
}
}
@@ -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"
}
@@ -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"
}
+3
View File
@@ -7707,6 +7707,9 @@ paths:
type: array
items:
type: string
run_query_params:
type: object
description: Runnable query parameters
required:
- args
- component
+18 -1
View File
@@ -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<StaticFields>,
pub force_viewer_one_of_fields: Option<OneOfFields>,
pub force_viewer_allow_user_resources: Option<AllowUserResources>,
/// Runnable query parameters (e.g., memory_id for chat-enabled flows)
pub run_query_params: Option<RunJobQuery>,
}
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())
@@ -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)
}
+16 -3
View File
@@ -1740,7 +1740,7 @@ pub struct ListableCompletedJob {
pub labels: Option<serde_json::Value>,
}
#[derive(Deserialize, Clone, Default)]
#[derive(Debug, Deserialize, Clone, Default)]
pub struct RunJobQuery {
pub scheduled_for: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_in_secs: Option<i64>,
@@ -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
@@ -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)
@@ -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<string, string[]> = {
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<string, AppInput>
): Record<string, AppInput> {
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<string>()
Object.values(COMPONENT_MANAGED_FIELDS).forEach((fields) => {
fields.forEach((field) => allFields.add(field))
})
return Array.from(allFields)
}
@@ -0,0 +1,338 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppInput } from '../../inputType'
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
import type RunnableComponent from '../helpers/RunnableComponent.svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { twMerge } from 'tailwind-merge'
import { Loader2 } from 'lucide-svelte'
import { initCss } from '../../utils'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { parseStreamDeltas } from '$lib/components/chat/utils'
import { randomUUID } from '$lib/components/flows/conversations/FlowChatManager.svelte'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
}
interface Props {
id: string
componentInput: AppInput | undefined
configuration: RichConfigurations
customCss?: ComponentCustomCSS<'chatcomponent'> | undefined
recomputeIds?: string[] | undefined
render: boolean
errorHandledByComponent?: boolean
}
let {
id,
componentInput,
configuration,
customCss = undefined,
recomputeIds = undefined,
render,
errorHandledByComponent = $bindable(false)
}: Props = $props()
const { worldStore, app, componentControl } = getContext<AppViewerContext>('AppViewerContext')
// Initialize outputs
let outputs = initOutput($worldStore, id, {
result: undefined as any,
loading: false,
jobId: undefined as string | undefined,
messages: [] as Message[],
userMessage: '' as string // Output for evalv2 field
})
// Resolve configuration
let resolvedConfig = $state(
initConfig(components['chatcomponent'].initialData.configuration, configuration)
)
// Initialize CSS
let css = $state(initCss($app.css?.chatcomponent, customCss))
// State
let runnableComponent: RunnableComponent | undefined = $state()
let runnableWrapper: RunnableWrapper | undefined = $state()
let loading = $state(false)
let result: any = $state(undefined)
let messages: Message[] = $state([])
let inputValue = $state('')
let messagesContainer: HTMLDivElement | undefined = $state()
// Streaming state management
let currentStreamingMessageIndex: number | undefined = $state(undefined)
let accumulatedContent = $state('')
// Generate stable memory_id for chat session (for agent memory persistence)
let chatMemoryId = $state(randomUUID())
// Register component control for programmatic access
$componentControl[id] = {
sendMessage: (message: string) => {
if (message && !loading) {
inputValue = message
handleSend()
}
}
}
// Auto-scroll to bottom when messages change
$effect(() => {
if (messages.length > 0 && messagesContainer) {
setTimeout(() => {
messagesContainer?.scrollTo({
top: messagesContainer.scrollHeight,
behavior: 'smooth'
})
}, 50)
}
})
// Handle streaming updates
function handleStreamUpdate(e: CustomEvent<{ id: string; result_stream: string }>) {
const streamContent = e.detail.result_stream
const parsed = parseStreamDeltas(streamContent)
if (parsed.content) {
accumulatedContent = parsed.content
} else {
accumulatedContent = streamContent
}
// Update or create streaming message
if (currentStreamingMessageIndex !== undefined) {
messages = messages.map((msg, idx) =>
idx === currentStreamingMessageIndex ? { ...msg, content: accumulatedContent } : msg
)
} else {
const assistantMessage: Message = {
id: randomUUID(),
role: 'assistant',
content: accumulatedContent
}
messages = [...messages, assistantMessage]
currentStreamingMessageIndex = messages.length - 1
}
}
// Handle job completion
function handleJobComplete(e: CustomEvent<{ id: string; result: any }>) {
const finalResult = e.detail.result
// Extract final content
let finalContent = ''
if (typeof finalResult === 'string') {
finalContent = finalResult
} else if (finalResult && typeof finalResult === 'object' && 'output' in finalResult) {
finalContent =
typeof finalResult.output === 'string'
? finalResult.output
: JSON.stringify(finalResult.output, null, 2)
} else {
finalContent = JSON.stringify(finalResult, null, 2)
}
// If we were streaming, update the message with final result to ensure completeness
if (currentStreamingMessageIndex !== undefined && finalContent) {
messages = messages.map((msg, idx) =>
idx === currentStreamingMessageIndex ? { ...msg, content: finalContent } : msg
)
}
// If not streaming, create new message
else if (finalContent.length > 0) {
messages = [
...messages,
{
id: randomUUID(),
role: 'assistant',
content: finalContent
}
]
}
// Finalize streaming
currentStreamingMessageIndex = undefined
accumulatedContent = ''
}
// Handle job error
function handleJobError(e: CustomEvent<{ id: string; error: any }>) {
const error = e.detail.error
// Add error message
messages = [
...messages,
{
id: randomUUID(),
role: 'assistant',
content: `Error: ${error.message || JSON.stringify(error)}`
}
]
// Reset streaming state
currentStreamingMessageIndex = undefined
accumulatedContent = ''
}
// Handle send message
async function handleSend() {
if (!inputValue.trim() || loading) return
const userMessage = inputValue.trim()
inputValue = ''
// Add user message to chat
const newUserMessage: Message = {
id: randomUUID(),
role: 'user',
content: userMessage
}
messages = [...messages, newUserMessage]
// Reset streaming state for new message
currentStreamingMessageIndex = undefined
accumulatedContent = ''
// Update output so evalv2 field can reference it
outputs.userMessage.set(userMessage)
// Trigger the runnable
if (!runnableComponent) {
runnableWrapper?.handleSideEffect(true)
} else {
await runnableComponent?.runComponent()
}
}
// Handle enter key
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
$effect(() => {
errorHandledByComponent = resolvedConfig?.onError?.selected !== 'errorOverlay'
})
</script>
<InitializeComponent {id} />
{#each Object.keys(components['chatcomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.chatcomponent}
/>
{/each}
<RunnableWrapper
bind:this={runnableWrapper}
bind:runnableComponent
bind:loading
bind:result
{componentInput}
{id}
{recomputeIds}
{outputs}
doOnSuccess={resolvedConfig.onSuccess}
doOnError={resolvedConfig.onError}
on:streamupdate={handleStreamUpdate}
on:done={handleJobComplete}
on:doneError={handleJobError}
{errorHandledByComponent}
autoRefresh={false}
{render}
extraQueryParams={{ memory_id: chatMemoryId }}
>
{#if render}
<div
class={twMerge(
'flex flex-col h-full border rounded-lg bg-surface',
css?.container?.class,
'wm-chat-container'
)}
style={css?.container?.style}
>
<!-- Messages Container -->
<div
bind:this={messagesContainer}
class={twMerge(
'flex-1 overflow-y-auto p-4 bg-background',
css?.messagesContainer?.class,
'wm-chat-messages'
)}
style={css?.messagesContainer?.style}
>
{#if messages.length === 0}
<div class="flex items-center justify-center h-full text-tertiary text-sm">
No messages yet. Start a conversation!
</div>
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each messages as message (message.id)}
<ChatMessage
role={message.role}
content={message.content}
enableMarkdown={true}
enableS3Display={true}
customCss={{
userMessage: css?.userMessage,
assistantMessage: css?.assistantMessage
}}
/>
{/each}
{#if loading}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Processing...</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Input Container -->
<div
class={twMerge('border-t p-3', css?.inputContainer?.class, 'wm-chat-input')}
style={css?.inputContainer?.style}
>
<ChatInput
bind:value={inputValue}
placeholder={resolvedConfig.placeholder}
disabled={loading}
onSend={handleSend}
onKeydown={handleKeydown}
customCss={{
input: css?.input,
button: css?.button
}}
/>
</div>
</div>
{/if}
</RunnableWrapper>
@@ -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<string, any> | undefined) {
const nonStaticRunnableInputs = dynamicArgsOverride ?? {}
const staticRunnableInputs = {}
const nonStaticRunnableInputs: Record<string, any> = dynamicArgsOverride ?? {}
const staticRunnableInputs: Record<string, any> = {}
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]
}
}
@@ -360,6 +360,7 @@
on:cancel
on:recompute
on:argsChanged
on:streamupdate
on:resultSet={(e) => {
const res = e.detail
if ($initialized?.runnableInitialized?.[fullId] === undefined) {
@@ -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)
},
@@ -11,7 +11,8 @@ export async function executeRunnable(
path: string,
id: string,
requestBody: ExecuteComponentData['requestBody'],
inlineScriptOverride?: InlineScript
inlineScriptOverride?: InlineScript,
queryParams?: Record<string, any>
) {
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,
@@ -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'}
<AppChat
id={component.id}
configuration={component.configuration}
componentInput={component.componentInput}
customCss={component.customCss}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'checkboxcomponent'}
<AppCheckbox
id={component.id}
@@ -54,7 +54,8 @@ import {
RefreshCw,
ListCollapse,
GalleryThumbnails,
Code
Code,
MessageSquare
} from 'lucide-svelte'
import type {
Aligned,
@@ -208,6 +209,7 @@ export type AggridInfiniteComponentEe = BaseComponent<'aggridinfinitecomponentee
}
export type DisplayComponent = BaseComponent<'displaycomponent'>
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<T extends TypedComponent['type']> = {
*/
initialData: InitialAppComponent
customCss: ComponentCustomCSS<T>
/**
* 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<string, any>) =>
| {
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 <code>user_message</code> 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,
@@ -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
@@ -73,6 +73,7 @@ const display: ComponentSet = {
'htmlcomponent',
'mardowncomponent',
'pdfcomponent',
'chatcomponent',
'displaycomponent',
'jobidlogcomponent',
'jobidflowstatuscomponent',
@@ -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<ComponentFunction> {
switch (type) {
case 'tabscomponent':
@@ -135,6 +141,8 @@ export function getComponentControl(type: keyof typeof components): Array<Compon
return [setSelectedIndex]
case 'dbexplorercomponent':
return [recompute]
case 'chatcomponent':
return [sendMessage]
default:
if (components[type].initialData['componentInput']) {
return [recompute]
@@ -76,7 +76,11 @@ export const authorizedClassnames = [
'wm-conditional-tabs',
'wm-rich-result-header',
'wm-rich-result-container'
'wm-rich-result-container',
'wm-chat-container',
'wm-chat-messages',
'wm-chat-input'
// TODO: Select and mutltiselect
]
@@ -619,6 +623,27 @@ export const customisationByComponent: Customisation[] = [
],
variables: []
},
{
components: ['chatcomponent'],
selectors: [
{
selector: '.wm-chat-container',
comment: 'Main chat container',
customCssKey: 'container'
},
{
selector: '.wm-chat-messages',
comment: 'Messages container',
customCssKey: 'messagesContainer'
},
{
selector: '.wm-chat-input',
comment: 'Input container',
customCssKey: 'inputContainer'
}
],
variables: []
},
{
components: ['mardowncomponent'],
selectors: [
@@ -649,6 +649,14 @@ export const quickStyleProperties: Record<
header: [...containerDefaultProps, typographyGrouping],
container: containerDefaultProps
},
chatcomponent: {
container: containerDefaultProps,
messagesContainer: containerDefaultProps,
userMessage: containerDefaultProps,
assistantMessage: containerDefaultProps,
input: containerDefaultProps,
button: buttonDefaultProps
},
logcomponent: {
header: [...containerDefaultProps, typographyGrouping],
container: containerDefaultProps
@@ -25,6 +25,7 @@
import EditorSettings from '$lib/components/EditorSettings.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { convertManagedFieldsToEvalv2 } from '$lib/components/apps/components/componentManagedFields'
const {
runnableComponents,
@@ -127,55 +128,18 @@
fieldType: 'number'
}
}
} else if (
componentType === 'aggridinfinitecomponent' ||
componentType === 'aggridinfinitecomponentee'
) {
newFields['offset'] = {
type: 'evalv2',
expr: `${id}.params.offset`,
fieldType: 'number'
}
newFields['limit'] = {
type: 'evalv2',
expr: `${id}.params.limit`,
fieldType: 'number'
}
newFields['orderBy'] = {
type: 'evalv2',
expr: `${id}.params.orderBy`,
fieldType: 'string'
}
newFields['isDesc'] = {
type: 'evalv2',
expr: `${id}.params.isDesc`,
fieldType: 'boolean'
}
newFields['search'] = {
type: 'evalv2',
expr: `${id}.params.search`,
fieldType: 'string'
}
} else {
// Convert component-managed fields to evalv2 type using centralized utility
const convertedFields = convertManagedFieldsToEvalv2(componentType, id, newFields)
Object.assign(newFields, convertedFields)
}
}
function assertConnections(newFields) {
if (
componentType === 'aggridinfinitecomponent' ||
componentType === 'aggridinfinitecomponentee'
) {
const fields = ['offset', 'limit', 'orderBy', 'isDesc', 'search']
fields.forEach((field) => {
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() {
@@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { getContext } from 'svelte'
import type { AppEditorContext, AppViewerContext, GridItem, RichConfiguration } from '../../types'
import PanelSection from './common/PanelSection.svelte'
@@ -37,6 +38,7 @@
import ComponentPanelDataSource from './ComponentPanelDataSource.svelte'
import MenuItems from './MenuItems.svelte'
import DecisionTreeGraphEditor from './DecisionTreeGraphEditor.svelte'
import { getManagedFields } from '$lib/components/apps/components/componentManagedFields'
import GridAgChartsLicenseKe from './GridAgChartsLicenseKe.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ContextVariables from './ContextVariables.svelte'
@@ -147,6 +149,10 @@
? ccomponents[item?.data?.type]?.initialData?.componentInput
: undefined
const runnableInputsInfo = item?.data?.type
? ccomponents[item.data.type]?.runnableInputsInfo
: undefined
const hasInteraction = item.data.type ? isTriggerable(item.data.type) : false
let evalV2editor: EvalV2InputEditor | undefined = $state(undefined)
@@ -355,6 +361,19 @@
parameters this component is attached to.
</Tooltip>
</div>
<!-- Generic runnable inputs validation -->
{#if runnableInputsInfo?.validate}
{@const validation = runnableInputsInfo?.validate(
item.data.componentInput.fields ?? {}
)}
{#if validation}
<Alert type={validation.type} title={validation.title} size="xs" class="my-2">
{@html validation.message}
</Alert>
{/if}
{/if}
<InputsSpecsEditor
id={component.id}
shouldCapitalize={false}
@@ -367,10 +386,7 @@
acceptSelf={component.type === 'aggridinfinitecomponent' ||
component.type === 'aggridinfinitecomponentee' ||
component.type === 'steppercomponent'}
overridenByComponent={component.type === 'aggridinfinitecomponent' ||
component.type === 'aggridinfinitecomponentee'
? ['offset', 'limit', 'orderBy', 'isDesc', 'search']
: []}
overridenByComponent={getManagedFields(component.type)}
securedContext
/>
</div>
@@ -6,6 +6,7 @@
import RunnableSelector from '../mainInput/RunnableSelector.svelte'
import SelectedRunnable from '../SelectedRunnable.svelte'
import type { AppEditorContext, AppViewerContext } from '$lib/components/apps/types'
import { convertManagedFieldsToEvalv2 } from '$lib/components/apps/components/componentManagedFields'
interface Props {
appInput: ResultAppInput
@@ -26,7 +27,14 @@
fields: Record<string, StaticAppInput>
}) {
if (appInput.type === 'runnable') {
appInput = { ...appInput, runnable, fields }
// 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
const convertedFields = convertManagedFieldsToEvalv2(
appComponent.type,
appComponent.id,
fields
) as Record<string, StaticAppInput>
appInput = { ...appInput, runnable, fields: convertedFields }
$selectedComponentInEditor = appComponent.id
} else {
console.warn('Cannot pick runnable for non-runnable input')
@@ -33,7 +33,7 @@ export function isFrontend(runnable: Runnable): boolean {
}
export function isTriggerable(componentType: string): boolean {
return ['buttoncomponent', 'formbuttoncomponent', 'formcomponent', 'steppercomponent'].includes(
return ['buttoncomponent', 'formbuttoncomponent', 'formcomponent', 'steppercomponent', 'chatcomponent'].includes(
componentType
)
}
@@ -55,7 +55,12 @@ export function getAllTriggerEvents(
const isTriggerableComponent = isTriggerable(appComponent.type)
if (isTriggerableComponent) {
events.push('click')
// Use 'send' for chat component, 'click' for others
if (appComponent.type === 'chatcomponent') {
events.push('send')
} else {
events.push('click')
}
if (triggerOnAppLoad) {
events.push('start')
@@ -295,6 +295,7 @@ export type AppViewerContext = {
invalidate?: (key: string, error: string) => void
validateAll?: () => void
clearFiles?: () => void
sendMessage?: (message: string) => void
showToast?: (message: string, error?: boolean) => void
recompute?: () => void
askNewResource?: () => void
@@ -345,6 +345,12 @@ declare function validateAll(id: string): void;
*/
declare function clearFiles(id: string): void;
/** Send a message to a chat component
* @param id component's id
* @param message message to send
*/
declare function sendMessage(id: string, message: string): void;
/** Display a toast message
* @param message message to display
*/
@@ -0,0 +1,87 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { ArrowUp, Square } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
interface Props {
value: string
placeholder?: string
disabled?: boolean
onSend: () => void
onKeydown?: (e: KeyboardEvent) => void
customCss?: {
container?: { class?: string; style?: string }
input?: { class?: string; style?: string }
button?: { class?: string; style?: string }
}
bindTextarea?: HTMLTextAreaElement
showCancelButton?: boolean
onCancel?: () => void
cancelTitle?: string
sendTitle?: string
}
let {
value = $bindable(),
placeholder = 'Type your message here...',
disabled = false,
onSend,
onKeydown = undefined,
customCss = undefined,
bindTextarea = $bindable(undefined),
showCancelButton = false,
onCancel = undefined,
cancelTitle = 'Cancel execution',
sendTitle = 'Send message'
}: Props = $props()
function handleKeydown(e: KeyboardEvent) {
onKeydown?.(e)
}
</script>
<div
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface-input px-3 py-2 {customCss
?.container?.class ?? ''}"
style={customCss?.container?.style}
>
<textarea
bind:this={bindTextarea}
bind:value
use:autosize
onkeydown={handleKeydown}
onpointerdown={stopPropagation(bubble('pointerdown'))}
{placeholder}
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 {customCss
?.input?.class ?? ''}"
style={customCss?.input?.style}
rows={3}
></textarea>
{#if showCancelButton && onCancel}
<Button
color="red"
size="xs2"
btnClasses="!rounded-full !p-1.5 {customCss?.button?.class ?? ''}"
style={customCss?.button?.style}
startIcon={{ icon: Square }}
on:click={onCancel}
iconOnly
title={cancelTitle}
/>
{:else}
<Button
color="blue"
size="xs2"
btnClasses="!rounded-full !p-1.5 {customCss?.button?.class ?? ''}"
style={customCss?.button?.style}
startIcon={{ icon: ArrowUp }}
disabled={!value.trim() || disabled}
on:click={onSend}
iconOnly
title={sendTitle}
/>
{/if}
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { Markdown } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-svelte'
import CodeDisplay from '$lib/components/copilot/chat/script/CodeDisplay.svelte'
import LinkRenderer from '$lib/components/copilot/chat/LinkRenderer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
role: 'user' | 'assistant' | 'tool' | 'system'
content: string
loading?: boolean
success?: boolean
stepName?: string
enableMarkdown?: boolean
enableS3Display?: boolean
customCss?: {
userMessage?: { class?: string; style?: string }
assistantMessage?: { class?: string; style?: string }
}
}
let {
role,
content,
loading = false,
success = undefined,
stepName = undefined,
enableMarkdown = true,
enableS3Display = true,
customCss = undefined
}: Props = $props()
// Parse S3 objects if enabled
const s3Object = $derived.by(() => {
if (!enableS3Display || role !== 'assistant' || !content) return undefined
try {
const parsed = JSON.parse(content)
if (parsed?.type === 'windmill_s3_object' && parsed?.s3) {
return parsed
}
} catch (e) {
// Not JSON
}
return undefined
})
const messageClass = $derived.by(() => {
const base = 'max-w-[90%] min-w-0 rounded-lg w-fit break-words'
if (role === 'user') {
const userClass = `${base} ml-auto bg-surface-secondary p-3`
return customCss?.userMessage?.class
? `${userClass} ${customCss.userMessage.class}`
: userClass
}
// assistant, tool, and system messages use the same styling
const assistantClass = `${base} mr-auto bg-surface border ${success !== false ? 'border-gray-200 dark:border-gray-600' : '!border-red-500'}`
return customCss?.assistantMessage?.class
? `${assistantClass} ${customCss.assistantMessage.class}`
: assistantClass
})
const messageStyle = $derived.by(() => {
if (role === 'user') {
return customCss?.userMessage?.style
}
return customCss?.assistantMessage?.style
})
</script>
<div class={messageClass} style={messageStyle}>
{#if stepName}
<div
class="bg-surface-secondary text-2xs text-tertiary mb-2 font-medium py-1 px-2 rounded-t-lg"
>
{stepName}
</div>
{/if}
{#if role === 'user'}
<p class="whitespace-pre-wrap text-sm text-right">{content}</p>
{:else if loading}
<div class="flex items-center gap-2 text-tertiary px-3 py-3">
<Loader2 size={16} class="animate-spin" />
<span>Processing...</span>
</div>
{:else if content}
{#if s3Object}
<div class="px-3 pb-3 {!stepName ? 'pt-3' : ''}">
<DisplayResult result={s3Object} workspaceId={$workspaceStore} noControls={true} />
</div>
{:else if enableMarkdown}
<div
class="flex flex-row items-center gap-2 px-3 pb-3 text-sm {!stepName
? 'pt-3'
: ''} overflow-x-auto"
>
{#if role === 'tool'}
{#if success !== false}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{:else}
<AlertTriangle class="w-3.5 h-3.5 text-red-500" />
{/if}
{/if}
<div class="prose prose-sm dark:prose-invert break-words prose-headings:!text-base">
<Markdown
md={content}
plugins={[
gfmPlugin(),
{
renderer: {
pre: CodeDisplay,
a: LinkRenderer
}
}
]}
/>
</div>
</div>
{:else}
<p class="whitespace-pre-wrap text-sm px-3 pb-3 {!stepName ? 'pt-3' : ''}">{content}</p>
{/if}
{:else}
<p class="text-tertiary text-sm px-3 py-3">No result</p>
{/if}
</div>
+30
View File
@@ -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 }
}
@@ -1,8 +1,8 @@
<script lang="ts">
import { Button, Alert } from '$lib/components/common'
import { MessageCircle, Loader2, ArrowUp, Square } from 'lucide-svelte'
import autosize from '$lib/autosize'
import FlowChatMessage from './FlowChatMessage.svelte'
import { Alert } from '$lib/components/common'
import { MessageCircle, Loader2 } from 'lucide-svelte'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
interface Props {
@@ -36,7 +36,13 @@
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each manager.messages as message (message.id)}
<FlowChatMessage {message} />
<ChatMessage
role={message.message_type}
content={message.content}
loading={message.loading}
success={message.success}
stepName={message.step_name}
/>
{/each}
{#if manager.isWaitingForResponse}
<div class="flex items-center gap-2 text-tertiary">
@@ -50,43 +56,17 @@
<!-- Chat Input -->
<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-input w-full"
class:opacity-50={deploymentInProgress}
>
<textarea
bind:this={manager.inputElement}
<div class="w-full" class:opacity-50={deploymentInProgress}>
<ChatInput
bind:value={manager.inputMessage}
use:autosize
onkeydown={manager.handleKeyDown}
placeholder="Type your message here..."
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 bg-surface-input">
{#if manager.isWaitingForResponse || manager.isLoading}
<Button
color="red"
size="xs2"
btnClasses="!rounded-full !p-1.5"
startIcon={{ icon: Square }}
on:click={() => manager.cancelCurrentJob()}
iconOnly
title="Cancel execution"
/>
{:else}
<Button
color="blue"
size="xs2"
btnClasses="!rounded-full !p-1.5"
startIcon={{ icon: ArrowUp }}
disabled={!manager.inputMessage?.trim() || manager.isLoading || deploymentInProgress}
on:click={() => manager.sendMessage()}
iconOnly
title={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
{/if}
</div>
bind:bindTextarea={manager.inputElement}
disabled={manager.isLoading || deploymentInProgress}
onSend={() => manager.sendMessage()}
onKeydown={manager.handleKeyDown}
showCancelButton={manager.isWaitingForResponse || manager.isLoading}
onCancel={() => manager.cancelCurrentJob()}
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
</div>
</div>
</div>
@@ -6,6 +6,7 @@ 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'
export interface ChatMessage extends FlowConversationMessage {
loading?: boolean
@@ -87,6 +88,7 @@ export class FlowChatManager {
}
focusInput() {
console.log('focusInput', this.inputElement)
this.inputElement?.focus()
}
@@ -320,36 +322,6 @@ export class FlowChatManager {
}
}
private parseStreamDeltas(streamData: string): {
type: string
content: string
success: boolean
} {
let type = 'message'
const lines = streamData.trim().split('\n')
let content = ''
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'
const toolName = parsed.function_name
success = parsed.success
content = success ? `Used ${toolName} tool` : `Failed to use ${toolName} tool`
}
if (parsed.type === 'token_delta' && parsed.content) {
type = 'message'
content += parsed.content
}
} catch (e) {
console.error('Failed to parse stream line:', line, e)
}
}
return { type, content, success }
}
private async pollConversationMessages(conversationId: string, isNewConversation?: boolean) {
if (!get(workspaceStore)) return
@@ -513,7 +485,7 @@ export class FlowChatManager {
type,
content: newContent,
success
} = this.parseStreamDeltas(data.new_result_stream)
} = parseStreamDeltas(data.new_result_stream)
accumulatedContent += newContent
// Create tool message if type is tool_result
@@ -1,93 +0,0 @@
<script lang="ts">
import { Markdown } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-svelte'
import CodeDisplay from '$lib/components/copilot/chat/script/CodeDisplay.svelte'
import LinkRenderer from '$lib/components/copilot/chat/LinkRenderer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { type ChatMessage } from './FlowChatManager.svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
message: ChatMessage
}
let { message }: Props = $props()
// Parse content to detect S3 objects
const s3Object: any | undefined = $derived.by(() => {
if (message.message_type === 'assistant' && message.content) {
try {
const parsed = JSON.parse(message.content)
// Check if it's a Windmill S3 object with type discriminator
if (parsed?.type === 'windmill_s3_object' && parsed?.s3 && typeof parsed.s3 === 'string') {
return parsed
}
} catch (e) {
// Not JSON, treat as regular text
}
}
return undefined
})
const messageClass = $derived.by(() => {
const base = 'max-w-[90%] min-w-0 rounded-lg w-fit'
if (message.message_type === 'user') {
return `${base} ml-auto bg-surface-secondary p-3`
}
return `${base} mr-auto bg-surface border ${message.success !== false ? 'border-gray-200 dark:border-gray-600' : '!border-red-500'}`
})
</script>
<div class={messageClass} data-message-id={message.id}>
{#if message.step_name}
<div class="bg-surface-secondary text-2xs text-tertiary mb-2 font-medium py-1 px-2 rounded-t-lg"
>{message.step_name}</div
>
{/if}
{#if message.message_type === 'user'}
<p class="whitespace-pre-wrap text-sm break-words text-right">{message.content}</p>
{:else if message.loading}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span>Processing...</span>
</div>
{:else if message.content}
{#if s3Object}
<div class="px-3 pb-3 {!message.step_name ? 'pt-3' : ''}">
<DisplayResult result={s3Object} workspaceId={$workspaceStore} noControls={true} />
</div>
{:else}
<div
class="flex flex-row items-center gap-2 px-3 pb-3 text-sm {!message.step_name
? 'pt-3'
: ''} overflow-x-auto"
>
{#if message.message_type === 'tool'}
{#if message.success !== false}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{:else}
<AlertTriangle class="w-3.5 h-3.5 text-red-500" />
{/if}
{/if}
<div class="prose prose-sm dark:prose-invert break-words prose-headings:!text-base">
<Markdown
md={message.content}
plugins={[
gfmPlugin(),
{
renderer: {
pre: CodeDisplay,
a: LinkRenderer
}
}
]}
/>
</div>
</div>
{/if}
{:else}
<p class="text-tertiary text-sm">No result</p>
{/if}
</div>