diff --git a/backend/migrations/20260907131755_flow_conversation_is_test.down.sql b/backend/migrations/20260907131755_flow_conversation_is_test.down.sql new file mode 100644 index 0000000000..aa186105cf --- /dev/null +++ b/backend/migrations/20260907131755_flow_conversation_is_test.down.sql @@ -0,0 +1 @@ +ALTER TABLE flow_conversation DROP COLUMN is_test; diff --git a/backend/migrations/20260907131755_flow_conversation_is_test.up.sql b/backend/migrations/20260907131755_flow_conversation_is_test.up.sql new file mode 100644 index 0000000000..5730496ba5 --- /dev/null +++ b/backend/migrations/20260907131755_flow_conversation_is_test.up.sql @@ -0,0 +1,15 @@ +-- A chat run from the flow editor's test panel is stored exactly like one from the +-- deployed flow, so the two were indistinguishable once written. Marking them lets the +-- lists tell a trial apart from a real conversation. +ALTER TABLE flow_conversation ADD COLUMN is_test BOOLEAN NOT NULL DEFAULT false; + +-- Existing rows: a conversation whose messages came from a flowpreview job was a test. +-- Derived once here because the job is purged on retention, after which the origin of an +-- old conversation is unknowable. +UPDATE flow_conversation c +SET is_test = true +WHERE EXISTS ( + SELECT 1 FROM flow_conversation_message m + JOIN v2_job j ON j.id = m.job_id + WHERE m.conversation_id = c.id AND j.kind = 'flowpreview' +); diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index e85af5b83b..0c6cb01cc8 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -41,6 +41,9 @@ pub struct FlowConversationMessage { #[derive(Deserialize)] pub struct ListConversationsQuery { pub flow_path: Option, + /// Include conversations started from the editor's test panel. Off by default: a + /// deployed flow's chat should not surface someone's trial runs. + pub include_test: Option, } #[derive(Deserialize)] @@ -67,6 +70,7 @@ async fn list_conversations( "created_at", "updated_at", "created_by", + "is_test", ]) .and_where_eq("workspace_id", "?".bind(&w_id)); @@ -74,6 +78,10 @@ async fn list_conversations( sqlb.and_where_eq("flow_path", "?".bind(flow_path)); } + if !query.include_test.unwrap_or(false) { + sqlb.and_where_eq("is_test", "false"); + } + sqlb.order_by("updated_at", true) .limit(per_page as i64) .offset(offset as i64); @@ -101,7 +109,7 @@ async fn delete_conversation( // Verify the conversation exists and belongs to the user let conversation = sqlx::query_as!( FlowConversation, - "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by + "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test FROM flow_conversation WHERE id = $1 AND workspace_id = $2", conversation_id, diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index d17dcfa15f..46da7c6246 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -633,6 +633,7 @@ pub async fn handle_chat_conversation_messages( run_query: &RunJobQuery, user_message_raw: Option<&Box>, job_id: Uuid, + is_test: bool, ) -> error::Result<()> { let memory_id = run_query.memory_id.ok_or_else(|| { windmill_common::error::Error::BadRequest( @@ -660,6 +661,7 @@ pub async fn handle_chat_conversation_messages( &authed.username, &user_message, memory_id, + is_test, ) .await?; @@ -795,6 +797,7 @@ pub async fn run_flow<'c>( &run_query, args.args.get("user_message"), uuid, + false, ) .await?; } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3daa0b6133..af0bf87175 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -11746,6 +11746,11 @@ paths: in: query schema: type: string + - name: include_test + description: include conversations started from the flow editor's test panel + in: query + schema: + type: boolean responses: "200": description: flow conversations list @@ -26970,6 +26975,9 @@ components: created_by: type: string description: Username who created the conversation + is_test: + type: boolean + description: Started from the flow editor's test panel rather than a deployed run FlowConversationMessage: type: object diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 050887ce0f..7aad056fdd 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -9258,6 +9258,8 @@ async fn run_preview_flow_job( &run_query, user_message.as_ref(), uuid, + // Run from the editor's test panel: a trial, not a real conversation. + true, ) .await?; } diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 21b1f56389..e3bbe23f7d 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -26,6 +26,8 @@ pub struct FlowConversation { pub created_at: DateTime, pub updated_at: DateTime, pub created_by: String, + /// Started from the flow editor's test panel rather than a deployed run. + pub is_test: bool, } pub async fn get_or_create_conversation_with_id( @@ -35,11 +37,12 @@ pub async fn get_or_create_conversation_with_id( username: &str, title: &str, conversation_id: Uuid, + is_test: bool, ) -> Result { // Check if conversation already exists let existing_conversation = sqlx::query_as!( FlowConversation, - "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by + "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test FROM flow_conversation WHERE id = $1 AND workspace_id = $2", conversation_id, @@ -58,14 +61,15 @@ pub async fn get_or_create_conversation_with_id( // Create new conversation with provided ID let conversation = sqlx::query_as!( FlowConversation, - "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by", + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test", conversation_id, w_id, flow_path, username, - title + title, + is_test ) .fetch_one(&mut **tx) .await?; diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index acb9ac3780..b3cf72a417 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -481,7 +481,7 @@ ) return jobId ?? '' }} - hideSidebar={true} + showTestChats path={$pathStore} inputSchema={flowStore.val.schema} flowModules={flowStore.val.value?.modules} diff --git a/frontend/src/lib/components/ScrollFade.svelte b/frontend/src/lib/components/ScrollFade.svelte new file mode 100644 index 0000000000..8726ea95ab --- /dev/null +++ b/frontend/src/lib/components/ScrollFade.svelte @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/lib/components/chat/utils.ts b/frontend/src/lib/components/chat/utils.ts index 60f59fce10..f2fc7c3ac1 100644 --- a/frontend/src/lib/components/chat/utils.ts +++ b/frontend/src/lib/components/chat/utils.ts @@ -1,30 +1,89 @@ +/** + * The AI agent's streamed events, as the worker writes them. + * + * One SSE chunk can carry several lines, so parsing returns a list: a chunk holding a + * tool call and its result must not collapse to whichever came last. Mirrors + * `StreamingEvent` in backend/windmill-ai/src/types.rs (tagged `type`, snake_case). + */ +export type StreamEvent = + | { kind: 'token'; content: string } + | { kind: 'reasoning'; content: string } + | { kind: 'tool_call'; callId: string; name: string } + | { kind: 'tool_arguments'; callId: string; name: string; arguments: string } + | { kind: 'tool_execution'; callId: string; name: string } + | { kind: 'tool_result'; callId: string; name: string; result: string; success: boolean } + +export function parseStreamEvents(streamData: string): StreamEvent[] { + const events: StreamEvent[] = [] + for (const line of streamData.trim().split('\n')) { + if (!line.trim()) continue + let parsed: any + try { + parsed = JSON.parse(line) + } catch (e) { + console.error('Failed to parse stream line:', line, e) + continue + } + switch (parsed?.type) { + case 'token_delta': + if (parsed.content) events.push({ kind: 'token', content: parsed.content }) + break + case 'reasoning_token_delta': + if (parsed.content) events.push({ kind: 'reasoning', content: parsed.content }) + break + case 'tool_call': + events.push({ kind: 'tool_call', callId: parsed.call_id, name: parsed.function_name }) + break + case 'tool_call_arguments': + events.push({ + kind: 'tool_arguments', + callId: parsed.call_id, + name: parsed.function_name, + arguments: parsed.arguments ?? '' + }) + break + case 'tool_execution': + events.push({ kind: 'tool_execution', callId: parsed.call_id, name: parsed.function_name }) + break + case 'tool_result': + events.push({ + kind: 'tool_result', + callId: parsed.call_id, + name: parsed.function_name, + result: parsed.result ?? '', + success: parsed.success !== false + }) + break + } + } + return events +} + +/** One-line summary of a tool call, for a surface with no room for the call itself. */ +export function toolSummary(name: string, success: boolean): string { + return success ? `Used ${name} tool` : `Failed to use ${name} tool` +} + +/** + * Flattened view of a chunk, for callers that render a single running string. + * Keeps the shape AppChat has always consumed. + */ 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) + for (const event of parseStreamEvents(streamData)) { + if (event.kind === 'token') { + content += event.content + } else if (event.kind === 'tool_result') { + type = 'tool_result' + success = event.success + content = toolSummary(event.name, event.success) } } - return { content, type, success } } diff --git a/frontend/src/lib/components/copilot/ChatModelSettings.svelte b/frontend/src/lib/components/copilot/ChatModelSettings.svelte new file mode 100644 index 0000000000..aa1b7ce8e4 --- /dev/null +++ b/frontend/src/lib/components/copilot/ChatModelSettings.svelte @@ -0,0 +1,235 @@ + + +{#snippet trigger()} +
+ +
+{/snippet} + +{#snippet section(sec: ChoiceSection, item: MeltItem)} +
{sec.label}
+ {#if sec.loading} +
+ Loading... +
+ {:else if sec.options.length === 0} +
{sec.emptyMessage ?? 'Nothing to choose from'}
+ {:else} +
+ {#each sec.options as option (option.key)} + option.onSelect()}> + {option.label} + {#if option.hint} + {option.hint} + {/if} + {#if option.selected} + + {/if} + + {/each} +
+ {/if} +{/snippet} + +{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)} + {#each items.filter((row) => !row.hide) as row (row.displayName)} + {#if row.separatorTop} +
+ {/if} + {#if row.submenuItems} + + + {:else} + row.action?.(e)}> + {#if row.icon} + + {/if} + {row.displayName} + {#if row.selected} + + {/if} + + {/if} + {/each} +{/snippet} + +{#if config.readOnly} + {@render trigger()} +{:else} + + {#snippet buttonReplacement()} + {@render trigger()} + {/snippet} + {#snippet menu({ item, builders, close })} +
+ {#if config.topItems} +
+ {@render rows(config.topItems(close), item, builders)} +
+ {/if} + {#each config.sections ?? [] as sec (sec.label)} +
+ {@render section(sec, item)} +
+ {/each} + {#if reasoning} +
+ {#if capability.supported && stops.length > 1} + + effortSlider?.adjust(e)} + class="block group" + > + (stop === reasoning?.offToken ? 'off' : stop)} + /> + + {:else} + {}} + unsupportedReason="Not supported by this model" + /> + {/if} +
+ {/if} + {#if config.bottomItems} +
+ {@render rows(config.bottomItems(close), item, builders)} +
+ {/if} +
+ {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 601288cd34..1cf181f40b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -35,6 +35,7 @@ import ChatQuickActions from './ChatQuickActions.svelte' import ContextUsageIndicator from './ContextUsageIndicator.svelte' import AIChatModelSettings from './AIChatModelSettings.svelte' + import ScrollFade from '$lib/components/ScrollFade.svelte' import McpConnections from './McpConnections.svelte' import SkillsPicker from './SkillsPicker.svelte' import ChatMode from './ChatMode.svelte' @@ -837,6 +838,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if} + + {#if showScrollToLatest}
{/if} +
{#if showFlowPendingActionControls}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 57cf39a5d1..be0ced56e2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -3569,6 +3569,9 @@ export class AIChatManager implements ChatViewHost { { role: 'assistant', content: this.currentReply, + // Stamped as it lands. A chat restored from history predates this and + // simply shows no time rather than a made-up one. + createdAt: new Date().toISOString(), ...(this.currentReasoning ? { reasoning: this.currentReasoning, reasoningDurationMs } : {}), diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index d25315d1bb..01d163a669 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -126,7 +126,7 @@ {:else}
{#if message.role === 'assistant'} -
+
{:else if message.role === 'tool'}
- import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte' - import DropdownV2 from '$lib/components/DropdownV2.svelte' - import ReasoningEffortSlider from '../ReasoningEffortSlider.svelte' - import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte' - import MenuItem from '$lib/components/meltComponents/MenuItem.svelte' - import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte' - import Button from '$lib/components/common/button/Button.svelte' + /** + * The session chat's model button: a fixed ChatModelSettings config over the copilot's + * own state — the workspace's configured models, the session's model/effort selection + * and its localStorage pins, the custom-prompt editors, and the free-tier grant. + */ + import { User, Building2, Settings, ExternalLink } from 'lucide-svelte' + import ChatModelSettings from '../ChatModelSettings.svelte' + import type { ChatModelSettingsConfig } from '../chatModelSettings' import { COPILOT_SESSION_MODEL_SETTING_NAME, COPILOT_SESSION_PROVIDER_SETTING_NAME, @@ -27,12 +28,7 @@ import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte' import { getAiChatManager } from './aiChatManagerContext' import { thinkingPreferences } from './thinkingPreferences.svelte' - import { - getReasoningCapability, - resolveEffectiveReasoning, - REASONING_OFF, - type ReasoningProviderModel - } from '../reasoningRegistry' + import { getReasoningCapability, REASONING_OFF, type ReasoningProviderModel } from '../reasoningRegistry' const aiChatManager = getAiChatManager() const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai` @@ -54,41 +50,6 @@ let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100))) let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80) - let capability = $derived( - getReasoningCapability(providerModel.provider as AIProvider, providerModel.model) - ) - // Effective effort accounts for the default-on level on capable models. - let currentEffort = $derived(resolveEffectiveReasoning(providerModel)) - // Slider stops: an off position only where the model can truly disable (else the - // provider would coerce it to the lowest level), then the provider-native levels. - let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels]) - let currentStop = $derived( - providerModel.reasoning === REASONING_OFF - ? REASONING_OFF - : (currentEffort ?? stops[stops.length - 1]) - ) - // Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely - // for models with no reasoning support. - let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined) - - // The trigger label resizes when the effort changes (e.g. dragging the slider while the menu - // is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would - // shift the popover. So we freeze the trigger to its width at open time and release it on close — - // no movement while open, and natural sizing (no reserved padding) the rest of the time. - let effortSlider: ReasoningEffortSlider | undefined = $state(undefined) - let menuOpen = $state(false) - let triggerEl: HTMLElement | undefined = $state(undefined) - let lockedWidth = $state(undefined) - $effect(() => { - if (menuOpen) { - if (lockedWidth === undefined && triggerEl) { - lockedWidth = triggerEl.getBoundingClientRect().width - } - } else { - lockedWidth = undefined - } - }) - function selectModel(m: AIProviderModel) { // Carry the effort onto the new model only if it supports that level ('off' // only where the model can truly disable); otherwise drop it so the model's @@ -225,9 +186,8 @@ } } - // Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned, - // so it flips on screen edges instead of overflowing). The menu keeps itself open on - // item click (closeOnItemClick=false), so these actions close it explicitly via `close`. + // Prompt parameters, surfaced as a melt submenu. The menu keeps itself open on item + // click, so these actions close it explicitly before opening a modal. function paramItems(close: () => void): Item { return { displayName: 'Parameters', @@ -262,111 +222,50 @@ } } - - // Adjust the reasoning effort with the arrow keys while the Thinking item is focused. + const config = $derived({ + label: providerModel.model, + title: 'Model & reasoning settings', + badge: + freeTier && !freeTier.exhausted + ? { text: 'Free', warn: freeRunningLow } + : undefined, + topItems: (close) => [paramItems(close)], + sections: [ + { + label: 'Model', + options: models.map((m) => ({ + key: `${m.provider}/${m.model}`, + label: m.model, + selected: m.model === providerModel.model && m.provider === providerModel.provider, + onSelect: () => selectModel(m) + })) + } + ], + reasoning: { + provider: providerModel.provider as AIProvider, + model: providerModel.model, + value: providerModel.reasoning, + offToken: REASONING_OFF, + onSelect: selectReasoning + }, + // A reading preference rather than a model parameter: it applies to every chat in + // this browser, including thinking already in the transcript. No close(): flipping + // it should not dismiss the menu. + bottomItems: () => [ + { + displayName: 'Always expand thinking', + selected: thinkingPreferences.expandByDefault, + action: () => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault) + } + ] + }) {#snippet externalLinkIcon()} {/snippet} - - {#snippet buttonReplacement()} -
- -
- {/snippet} - {#snippet menu({ item, builders, close })} -
- - - -
-
Model
-
- {#each models as m (m.provider + m.model)} - selectModel(m)} - > - {m.model} - {#if m.model === providerModel.model && m.provider === providerModel.provider} - - {/if} - - {/each} -
- -
- {#if capability.supported} - - effortSlider?.adjust(e)} class="block group"> - - - {:else} - {}} - unsupportedReason="Not supported by this model" - /> - {/if} - - - (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)} - > - Always expand thinking - {#if thinkingPreferences.expandByDefault} - - {/if} - -
- {/snippet} -
+
{/if} + +{#if message.content || createdAt || runHref} + +
+ {#if message.content} + + {/if} + {#if createdAt} + {displayDate(createdAt)} + {/if} + {#if runHref} + + job {jobId?.slice(0, 8)} + + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 967d358166..ddfd61111f 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -632,6 +632,11 @@ export type AssistantDisplayMessage = BaseDisplayMessage & { /** Flow step that produced this message, when the conversation is a flow run * rather than a copilot turn. Rendered as a label above the content. */ stepName?: string + /** The run behind this answer, linked under it. Flow chats only: a copilot turn + * happens in the browser and has no job to open. */ + jobId?: string + /** When the message was stored, shown beside the run link. */ + createdAt?: string } /** diff --git a/frontend/src/lib/components/copilot/chatModelSettings.ts b/frontend/src/lib/components/copilot/chatModelSettings.ts new file mode 100644 index 0000000000..db14cb6616 --- /dev/null +++ b/frontend/src/lib/components/copilot/chatModelSettings.ts @@ -0,0 +1,67 @@ +import type { AIProvider } from '$lib/gen' +import type { Item } from '$lib/utils' + +/** + * The contract between a chat and its model button. + * + * One component renders this menu for every chat — the copilot's own session chat and + * the flow chat — so the component knows only about rows, choices and a reasoning + * ladder. What a row means (a workspace AI resource, a prompt to edit, a reading + * preference) is the caller's business, and each caller derives its own config: a fixed + * one for the session chat, one derived from the flow's exposed inputs for flow chat. + */ + +export type ModelChoice = { + /** Stable across rebuilds of the config; used as the `{#each}` key. */ + key: string + label: string + /** Muted trailing text, e.g. the provider a resource speaks. */ + hint?: string + selected: boolean + onSelect: () => void +} + +export type ChoiceSection = { + /** Section heading, e.g. 'Provider' or 'Model'. */ + label: string + options: ModelChoice[] + /** Fetched lists render one consistent loading line instead of the options. */ + loading?: boolean + /** Shown when the list is empty and settled. */ + emptyMessage?: string + maxHeight?: string +} + +export type ChatModelSettingsConfig = { + /** The trigger's main text: the chosen model, or an invitation to choose one. */ + label: string + title?: string + /** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */ + badge?: { text: string; warn?: boolean } + /** Nothing here is editable — the trigger still names the model, but no menu opens. */ + readOnly?: boolean + readOnlyReason?: string + /** + * Rows above and below the choice sections. Given the menu's own `close` because a + * row that opens a modal must close the menu first, while a row that toggles a + * preference must not. + */ + topItems?: (close: () => void) => Item[] + sections?: ChoiceSection[] + bottomItems?: (close: () => void) => Item[] + /** + * The thinking slider. The ladder is derived from the provider and model here rather + * than by each caller, so a new provider's effort levels reach every chat at once. + * `value` is the raw stored effort (undefined meaning the model's default), and + * `offToken` the token this caller stores for "off" — the copilot keeps its own + * sentinel and translates when it calls the provider, an agent writes the + * provider-native token straight into its step. + */ + reasoning?: { + provider: AIProvider + model: string + value: string | undefined + offToken: string | undefined + onSelect: (token: string) => void + } +} diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index 1c7cf13fd7..14399b2fc7 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -25,7 +25,8 @@ Save, X, Check, - Settings2 + Settings2, + MessageCircle } from 'lucide-svelte' import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte' import FlowInputEditor from './FlowInputEditor.svelte' @@ -47,10 +48,12 @@ import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen' import { deepEqual } from 'fast-equals' import Toggle from '$lib/components/Toggle.svelte' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { AI_AGENT_SCHEMA } from '../flowInfers' import { nextId } from '../flowModuleNextId' - import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import FlowChat from '../conversations/FlowChat.svelte' + import { isEmptyAgentChatInputValue } from '../conversations/agentChatInputs' import { SPECIAL_MODULE_IDS } from '$lib/components/copilot/chat/shared' interface Props { @@ -105,9 +108,10 @@ lastModule?.value?.input_transforms?.streaming?.value === true ) }) - let showChatModeWarning = $state(false) - let showAdditionalInputs = $state(false) + // Chat mode shows one of the two at a time: the conversation, or the inputs it sends. + let chatPanelTab = $state<'chat' | 'inputs'>('chat') let chatInputsEditTab = $state(false) + let chatEditableSchemaForm: EditableSchemaForm | undefined = $state(undefined) let chatInputsAddPropertyV2: AddPropertyV2 | undefined = $state(undefined) let addPropertyV2: AddPropertyV2 | undefined = $state(undefined) @@ -520,23 +524,9 @@ return jobId } - function hasOtherInputs(): boolean { - const properties = flowStore.val.schema?.properties - return Boolean( - properties && - Object.keys(properties).length > 0 && - !(Object.keys(properties).length === 1 && Object.keys(properties).includes('user_message')) - ) - } - function handleToggleChatMode() { if (!flowStore.val.value?.chat_input_enabled) { - // Check if there are existing inputs - if (hasOtherInputs()) { - showChatModeWarning = true - } else { - enableChatMode() - } + enableChatMode() } else { // Disable chat input - remove from flow.value if (flowStore.val.value) { @@ -545,21 +535,46 @@ } } + /** + * Add the flow input the agent's `user_attachments` reads, and return its name. Chat + * mode means files dropped in the composer, and that only works through a flow input — + * so it is set up with the message and the memory rather than left to be discovered. + */ + function addAttachmentsInput(): string { + const schema = (flowStore.val.schema ?? {}) as Record + const properties: Record = (schema.properties ??= {}) + let name = 'files' + for (let i = 2; name in properties; i++) name = `files_${i}` + properties[name] = { + type: 'array', + items: { type: 'object', resourceType: 's3object' }, + description: 'Images or PDFs for the agent to read' + } + flowStore.val.schema = schema + return name + } + function enableChatMode() { // Enable chat input - set in flow.value flowStore.val.value.chat_input_enabled = true - // Set up the schema for chat input + // The chat fills the flow's form rather than standing in for it: all it needs is a + // `user_message` string, which the server requires under that exact argument name. + // Every other input stays — the composer drives the ones an agent field reads, and + // the rest are asked for in the Configure-inputs modal. + const schema = flowStore.val.schema ?? {} + const properties = { ...(schema.properties ?? {}) } + // Only a string can carry the message; anything else here cannot be what chat sends. + if (properties['user_message']?.type !== 'string') { + properties['user_message'] = { type: 'string', description: 'Message from user' } + } + const required: string[] = Array.isArray(schema.required) ? schema.required : [] flowStore.val.schema = { $schema: 'https://json-schema.org/draft/2020-12/schema', + ...schema, type: 'object', - properties: { - user_message: { - type: 'string', - description: 'Message from user' - } - }, - required: ['user_message'] + properties, + required: required.includes('user_message') ? required : [...required, 'user_message'] } // Find all AI agent modules @@ -579,6 +594,8 @@ (accu, key) => { if (key === 'user_message') { accu[key] = { type: 'javascript', expr: 'flow_input.user_message' } + } else if (key === 'user_attachments') { + accu[key] = { type: 'javascript', expr: `flow_input.${addAttachmentsInput()}` } } else if (key === 'memory') { accu[key] = { type: 'static', value: { kind: 'auto', context_length: 10 } } } else { @@ -595,7 +612,7 @@ } ] sendUserToast( - 'Chat mode enabled. AI agent created with user message input and context memory set to 10.', + 'Chat mode enabled. AI agent created with user message and attachments inputs, and context memory set to 10.', false ) } else if (aiAgentModules.length === 1) { @@ -608,12 +625,12 @@ // Degenerate shapes the input form can produce without deliberate // configuration count as unconfigured: empty static value (undefined - // persists as null through JSON round-trips), blank JS expression - // (the JS toggle seeds a bare backtick pair), or an AI transform - // (meaningless for the chat input). + // persists as null through JSON round-trips, and the step panel seeds an + // array-typed field with []), blank JS expression (the JS toggle seeds a + // bare backtick pair), or an AI transform (meaningless for the chat input). const isUnconfigured = (transform: InputTransform | undefined) => transform === undefined || - (transform.type === 'static' && (transform.value == null || transform.value === '')) || + (transform.type === 'static' && isEmptyAgentChatInputValue(transform.value)) || (transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') || transform.type === 'ai' @@ -626,6 +643,14 @@ applied.push('user message input') } + if (isUnconfigured(value.input_transforms['user_attachments'])) { + value.input_transforms['user_attachments'] = { + type: 'javascript', + expr: `flow_input.${addAttachmentsInput()}` + } + applied.push('attachments input') + } + if (isUnconfigured(value.input_transforms['memory'])) { value.input_transforms['memory'] = { type: 'static', @@ -642,40 +667,48 @@ ) } // If there are multiple AI agents, don't auto-configure (ambiguous which one to configure) - - showChatModeWarning = false } - { - showChatModeWarning = false - chatInputEnabled = false - }} -> -

- Enabling Chat Mode will replace all existing flow inputs with a single - user_message - parameter. -

-

- Your current input configuration will be lost. Are you sure you want to continue? -

-
+ +{#snippet inputsEditButton(open: boolean, toggle: () => void)} + + + {#snippet children({ item })} + + + {/snippet} + {/if}
{/if} @@ -705,9 +741,13 @@
{#if flowStore.val.value?.chat_input_enabled}
- {#if showAdditionalInputs} -
+ {#if chatPanelTab === 'inputs'} + +
{#snippet openEditTab()} - + {@render inputsAddTrigger()} {/snippet} {/snippet}
+ {:else} + {/if} -
{:else}
@@ -826,22 +858,9 @@
{#snippet close_button()} -
@@ -895,12 +914,7 @@ }} > {#snippet trigger()} -
- -
+ {@render inputsAddTrigger()} {/snippet} {/if} diff --git a/frontend/src/lib/components/flows/conversations/AgentChatInputSubmenu.svelte b/frontend/src/lib/components/flows/conversations/AgentChatInputSubmenu.svelte deleted file mode 100644 index 02e825a340..0000000000 --- a/frontend/src/lib/components/flows/conversations/AgentChatInputSubmenu.svelte +++ /dev/null @@ -1,111 +0,0 @@ - - - - -{#if $subOpen} -
-
- -
-
-{/if} diff --git a/frontend/src/lib/components/flows/conversations/ChatModelPicker.svelte b/frontend/src/lib/components/flows/conversations/ChatModelPicker.svelte deleted file mode 100644 index 05f643395a..0000000000 --- a/frontend/src/lib/components/flows/conversations/ChatModelPicker.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - -
-
-

Provider

- {#if resources.loading} -
- Loading resources... -
- {:else} - ({ value: m, label: m }))} - value={value?.model} - onchange={(model) => onChange({ ...value, model })} - placeholder={provider ? 'Select a model' : 'Pick a provider first'} - disabled={disabled || !provider} - loading={models.loading} - clearable - /> -
-
diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index ee7115cbef..16e320e50f 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -22,6 +22,8 @@ flowModules?: FlowModule[] /** Wider centered column, for the full-page chat. */ wideLayout?: boolean + /** Whether the editor's own test chats are listed. On where testing happens. */ + showTestChats?: boolean } let { @@ -32,18 +34,21 @@ hideSidebar = false, inputSchema = undefined, flowModules = undefined, - wideLayout = false + wideLayout = false, + showTestChats = false }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') const manager = createFlowChatManager() manager.operatingWorkspace = () => flowEditorContext?.opWorkspace?.() + manager.showTestChats = showTestChats // Initialize manager when component mounts $effect(() => { if ($workspaceStore) { manager.initialize(onRunFlow, path, useStreaming) + void manager.selectLatestConversation() } return () => { @@ -79,7 +84,11 @@ }) -
+ +
{#if !hideSidebar} {/if} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index 2c04351e59..b173c676e1 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -1,6 +1,6 @@