mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
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:
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+22
@@ -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"
|
||||
}
|
||||
+1
-3
@@ -59,9 +59,7 @@
|
||||
"failure",
|
||||
"command",
|
||||
"approval",
|
||||
"preprocessor",
|
||||
"schedule_handler_old",
|
||||
"dynamic_skip"
|
||||
"preprocessor"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
@@ -7707,6 +7707,9 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
run_query_params:
|
||||
type: object
|
||||
description: Runnable query parameters
|
||||
required:
|
||||
- args
|
||||
- component
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-45
@@ -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>
|
||||
|
||||
+9
-1
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user