feat(ai-chat): tool cards, chat history and test filter for flow chat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hw1yzWHyqqZmQrviUEhr5b
This commit is contained in:
Guilhem Lemouel
2026-09-07 16:31:07 +02:00
co-authored by Claude Opus 5
parent 5aa863c6e3
commit d993013dc0
31 changed files with 1638 additions and 907 deletions
@@ -0,0 +1 @@
ALTER TABLE flow_conversation DROP COLUMN is_test;
@@ -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'
);
@@ -41,6 +41,9 @@ pub struct FlowConversationMessage {
#[derive(Deserialize)]
pub struct ListConversationsQuery {
pub flow_path: Option<String>,
/// 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<bool>,
}
#[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,
@@ -633,6 +633,7 @@ pub async fn handle_chat_conversation_messages(
run_query: &RunJobQuery,
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
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?;
}
+8
View File
@@ -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
+2
View File
@@ -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?;
}
@@ -26,6 +26,8 @@ pub struct FlowConversation {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
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<FlowConversation> {
// 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?;
@@ -481,7 +481,7 @@
)
return jobId ?? ''
}}
hideSidebar={true}
showTestChats
path={$pathStore}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
@@ -0,0 +1,66 @@
<script lang="ts">
/**
* A soft edge on a scroller, so content scrolling out of view fades instead of being
* cut against whatever borders it.
*
* Rendered as an overlay in the scroller's positioned ancestor rather than inside the
* scroller: `sticky` would resolve against the scroller's padding box and leave the
* first few pixels unfaded. It shows only when there is something hidden in that
* direction, so a transcript that fits shows no edge at all.
*/
import { twMerge } from 'tailwind-merge'
interface Props {
/** The scrolling element this masks. */
scroller: HTMLElement | undefined
edge?: 'top' | 'bottom'
/** Tailwind colour stop to fade from — the surface the scroller sits on. */
from?: string
/** Tailwind height of the fade band. */
height?: string
class?: string
}
let {
scroller,
edge = 'top',
from = 'from-surface',
height = 'h-4',
class: className = ''
}: Props = $props()
let hidden = $state(true)
$effect(() => {
const el = scroller
if (!el) return
const update = () => {
// A pixel of slack: fractional scroll offsets otherwise leave the bottom edge
// showing on a scroller that is already at its end.
hidden =
edge === 'top' ? el.scrollTop <= 1 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
}
update()
el.addEventListener('scroll', update, { passive: true })
// Content arriving or the pane resizing changes what is hidden without a scroll.
const observer = new ResizeObserver(update)
observer.observe(el)
if (el.firstElementChild) observer.observe(el.firstElementChild)
return () => {
el.removeEventListener('scroll', update)
observer.disconnect()
}
})
</script>
<div
class={twMerge(
'pointer-events-none absolute inset-x-0 transition-opacity duration-150',
edge === 'top' ? 'top-0 bg-gradient-to-b' : 'bottom-0 bg-gradient-to-t',
from,
'to-transparent',
height,
hidden ? 'opacity-0' : 'opacity-100',
className
)}
></div>
+77 -18
View File
@@ -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 }
}
@@ -0,0 +1,235 @@
<script lang="ts">
/**
* The model button every chat puts in the bottom-right of its composer: the trigger
* names the model and its reasoning effort, and the menu holds the choices behind
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
* chat render the same control from different data — see chatModelSettings.ts.
*/
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.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'
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
import {
getReasoningCapability,
resolveEffectiveReasoning,
REASONING_OFF
} from './reasoningRegistry'
import type { ChatModelSettingsConfig, ChoiceSection } from './chatModelSettings'
import type { Item } from '$lib/utils'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
type MeltItem = MenubarMenuElements['item']
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
let { config }: { config: ChatModelSettingsConfig } = $props()
const reasoning = $derived(config.reasoning)
const capability = $derived(
reasoning
? getReasoningCapability(reasoning.provider, reasoning.model)
: { supported: false, levels: [] as string[], canDisable: false }
)
// 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.
const stops = $derived([
...(capability.canDisable && reasoning?.offToken !== undefined ? [reasoning.offToken] : []),
...capability.levels
])
// Effective effort accounts for the default-on level on capable models.
const effective = $derived(
reasoning
? resolveEffectiveReasoning({
provider: reasoning.provider,
model: reasoning.model,
reasoning: reasoning.value
})
: undefined
)
const currentStop = $derived(
reasoning && reasoning.value === reasoning.offToken
? (reasoning.offToken ?? '')
: (effective ?? stops[stops.length - 1] ?? '')
)
// Trigger suffix: the effort token, or 'off' when disabled. Omitted entirely for
// models with no reasoning support. The off token is provider-native and can read as
// anything ('none', 'disabled'); on the button it always reads as off.
const effortLabel = $derived(capability.supported ? (effective ?? REASONING_OFF) : undefined)
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
// The trigger label resizes when the effort changes (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 freeze the trigger to its width at open time and release it
// on close — no movement while open, natural sizing the rest of the time.
let menuOpen = $state(false)
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
// one rendered must not draw one above itself whichever block that turns out to be.
const BLOCK_CLASS =
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
const ROW_CLASS =
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
</script>
{#snippet trigger()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
disabled={config.readOnly}
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title={config.readOnly ? config.readOnlyReason : config.title}
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{config.label}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if config.badge}
<span
class={twMerge(
'shrink-0 rounded-full px-1.5 text-2xs',
config.badge.warn
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'
)}>{config.badge.text}</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet section(sec: ChoiceSection, item: MeltItem)}
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
{#if sec.loading}
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
<Loader2 size={14} class="animate-spin" /> Loading...
</div>
{:else if sec.options.length === 0}
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
{:else}
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
{#each sec.options as option (option.key)}
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
<span class="truncate grow min-w-0">{option.label}</span>
{#if option.hint}
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
{/if}
{#if option.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
{/if}
{/snippet}
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
{#each items.filter((row) => !row.hide) as row (row.displayName)}
{#if row.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
{#if row.submenuItems}
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
{:else}
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
{#if row.icon}
<row.icon size={14} class="shrink-0" />
{/if}
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
{#if row.selected}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/if}
{/each}
{/snippet}
{#if config.readOnly}
{@render trigger()}
{:else}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
{@render trigger()}
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
{#if config.topItems}
<div class={BLOCK_CLASS}>
{@render rows(config.topItems(close), item, builders)}
</div>
{/if}
{#each config.sections ?? [] as sec (sec.label)}
<div class={BLOCK_CLASS}>
{@render section(sec, item)}
</div>
{/each}
{#if reasoning}
<div class={BLOCK_CLASS}>
{#if capability.supported && stops.length > 1}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the row
above. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper
{item}
onKeydown={(e) => effortSlider?.adjust(e)}
class="block group"
>
<ReasoningEffortSlider
bind:this={effortSlider}
{stops}
current={currentStop}
onSelect={reasoning.onSelect}
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
/>
</MenuItemWrapper>
{:else}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
</div>
{/if}
{#if config.bottomItems}
<div class={BLOCK_CLASS}>
{@render rows(config.bottomItems(close), item, builders)}
</div>
{/if}
</div>
{/snippet}
</DropdownV2>
{/if}
@@ -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}
</div>
</div>
<!-- Sits below the scroll-to-latest button, which carries z-10. -->
<ScrollFade scroller={scrollElement} />
{#if showScrollToLatest}
<div
transition:fade={{ duration: 120 }}
@@ -862,10 +865,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{/if}
<!-- Same horizontal padding as the transcript above: the composer's edges line up with
the messages rather than sitting closer to the panel edge. -->
<div
class={wideLayout
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
? 'relative w-full max-w-3xl mx-auto px-7 pb-2'
: 'relative w-full max-w-2xl mx-auto px-3 pb-2'}
>
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
@@ -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 }
: {}),
@@ -126,7 +126,7 @@
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
{#if message.role === 'assistant'}
<div class="px-[1px]"><AssistantMessage {message} /></div>
<div class="px-[1px] group/answer"><AssistantMessage {message} /></div>
{:else if message.role === 'tool'}
<div class="px-[1px]"
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
@@ -1,11 +1,12 @@
<script lang="ts">
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<number | undefined>(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<ChatModelSettingsConfig>({
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)
}
]
})
</script>
{#snippet externalLinkIcon()}
<ExternalLink size={14} class="shrink-0 text-secondary" />
{/snippet}
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
<div
bind:this={triggerEl}
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
endIcon={{ icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title="Model & reasoning settings"
>
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{providerModel.model}</span>
{#if effortLabel}
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
{/if}
{#if freeTier && !freeTier.exhausted}
<span
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
: 'bg-surface-secondary text-tertiary'}">Free</span
>
{/if}
</span>
</Button>
</div>
{/snippet}
{#snippet menu({ item, builders, close })}
<div
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="max-h-48 overflow-y-auto">
{#each models as m (m.provider + m.model)}
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => selectModel(m)}
>
<span class="truncate grow min-w-0">{m.model}</span>
{#if m.model === providerModel.model && m.provider === providerModel.provider}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
{/each}
</div>
<div class="my-1 border-t border-border-light"></div>
{#if capability.supported}
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
up/down navigation), and so hovering it takes the highlight off the Parameters
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
<MenuItemWrapper {item} onKeydown={(e) => effortSlider?.adjust(e)} class="block group">
<ReasoningEffortSlider
bind:this={effortSlider}
{stops}
current={currentStop}
onSelect={selectReasoning}
/>
</MenuItemWrapper>
{:else}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
<!-- A reading preference rather than a model parameter: it applies to every
chat in this browser, including thinking already in the transcript. -->
<MenuItem
{item}
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
>
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
{#if thinkingPreferences.expandByDefault}
<Check size={14} class="shrink-0 text-primary" />
{/if}
</MenuItem>
</div>
{/snippet}
</DropdownV2>
<ChatModelSettings {config} />
<AIPromptsModal
bind:open={modalOpen}
@@ -14,6 +14,10 @@
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { ExternalLink } from 'lucide-svelte'
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
interface Props {
message: DisplayMessage
@@ -21,6 +25,14 @@
let { message }: Props = $props()
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
// the browser — so the footer is absent rather than empty elsewhere.
const jobId = $derived(message.role === 'assistant' ? message.jobId : undefined)
const createdAt = $derived(message.role === 'assistant' ? message.createdAt : undefined)
const runHref = $derived(
jobId ? `${base}/run/${jobId}?workspace=${$workspaceStore}` : undefined
)
const reasoning = $derived(
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
)
@@ -137,3 +149,30 @@
<Markdown md={message.content} {plugins} />
</div>
{/if}
{#if message.content || createdAt || runHref}
<!-- Present but invisible until the answer is hovered: kept in flow so revealing it
does not nudge the message below. -->
<div
class="mt-1.5 flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
>
{#if message.content}
<CopyButton value={message.content} title="Copy answer" class="-ml-1" />
{/if}
{#if createdAt}
<span>{displayDate(createdAt)}</span>
{/if}
{#if runHref}
<a
href={runHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/if}
</div>
{/if}
@@ -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
}
/**
@@ -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
}
}
@@ -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<string, any>
const properties: Record<string, any> = (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
}
</script>
<!-- Add svelte:window to listen for keyboard events -->
<svelte:window onkeydown={handleKeydown} />
<ConfirmationModal
open={showChatModeWarning}
title="Enable Chat Mode?"
confirmationText="Continue"
onConfirmed={enableChatMode}
onCanceled={() => {
showChatModeWarning = false
chatInputEnabled = false
}}
>
<p class="text-sm text-secondary">
Enabling Chat Mode will replace all existing flow inputs with a single
<span class="font-mono text-xs bg-surface-secondary px-1 rounded">user_message</span>
parameter.
</p>
<p class="text-sm text-secondary mt-2">
Your current input configuration will be lost. Are you sure you want to continue?
</p>
</ConfirmationModal>
<!-- The edit toggle and the add-input target, shared by both panels below: chat mode
carries a smaller set of side tabs, but the controls themselves must not differ. -->
{#snippet inputsEditButton(open: boolean, toggle: () => void)}
<Button
onClick={toggle}
{...open
? {
title: 'Close input editor',
startIcon: { icon: ChevronRight },
btnClasses: 'rounded-none rounded-tl-md'
}
: {
title: 'Open input editor',
startIcon: { icon: Pen }
}}
variant="accent"
iconOnly
wrapperClasses="h-full"
/>
{/snippet}
{#snippet inputsAddTrigger()}
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
id="add-flow-input-btn"
>
<Plus size={14} />
</div>
{/snippet}
<FlowCard {noEditor} title="Flow Input">
{#snippet action()}
{#if !disabled}
<div class="flex items-center gap-2">
<Toggle
size="sm"
size="xs"
bind:checked={chatInputEnabled}
on:change={() => {
handleToggleChatMode()
@@ -683,20 +716,23 @@
options={{
right: 'Chat Mode',
rightTooltip:
'When enabled, the flow execution page will show a chat interface where each message sent runs the flow with the message as "user_message" input parameter. The flow schema will be automatically set to accept only a user_message string input.'
'When enabled, the flow execution page shows a chat interface where each message runs the flow with the message as its "user_message" input. That input is added if the flow does not already have it; every other input is kept.'
}}
/>
{#if flowStore.val.value?.chat_input_enabled}
<Button
size="xs"
variant="border"
color={showAdditionalInputs ? 'blue' : 'light'}
startIcon={{ icon: Settings2 }}
title="Manage inputs"
on:click={() => (showAdditionalInputs = !showAdditionalInputs)}
>
Manage inputs
</Button>
<ToggleButtonGroup bind:selected={chatPanelTab} noWFull>
{#snippet children({ item })}
<ToggleButton size="xs" value="chat" label="Chat" icon={MessageCircle} {item} />
<ToggleButton
size="xs"
value="inputs"
label="Inputs"
icon={Settings2}
tooltip="Edit the flow inputs the chat sends alongside each message"
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
{/if}
@@ -705,9 +741,13 @@
<div class="flex flex-col h-full">
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-col h-full">
{#if showAdditionalInputs}
<div class="border-b p-2">
{#if chatPanelTab === 'inputs'}
<!-- EditableSchemaForm scrolls internally against `h-full`, so the wrapper has
to be bounded (flex-1 min-h-0) or the form grows to content height and
spills out of the panel. -->
<div class="py-2 px-4 flex-1 min-h-0">
<EditableSchemaForm
bind:this={chatEditableSchemaForm}
bind:schema={flowStore.val.schema}
hiddenArgs={['user_message']}
isFlowInput
@@ -722,46 +762,38 @@
}}
>
{#snippet openEditTab()}
<Button
size="xs"
variant={chatInputsEditTab ? 'contained' : 'border'}
color={chatInputsEditTab ? 'blue' : 'light'}
startIcon={{ icon: chatInputsEditTab ? ChevronRight : Pen }}
title={chatInputsEditTab ? 'Close editor' : 'Edit inputs'}
onClick={() => {
chatInputsEditTab = !chatInputsEditTab
}}
/>
{@render inputsEditButton(
chatInputsEditTab,
() => (chatInputsEditTab = !chatInputsEditTab)
)}
{/snippet}
{#snippet addProperty()}
<AddPropertyV2
bind:this={chatInputsAddPropertyV2}
bind:schema={flowStore.val.schema}
onAddNew={() => {}}
onAddNew={(argName) => {
chatInputsEditTab = true
chatEditableSchemaForm?.openField(argName)
refreshFlowStateStore(flowStore)
}}
>
{#snippet trigger()}
<Button
size="xs"
color="light"
startIcon={{ icon: Plus }}
title="Add additional input"
>
Add input
</Button>
{@render inputsAddTrigger()}
{/snippet}
</AddPropertyV2>
{/snippet}
</EditableSchemaForm>
</div>
{:else}
<FlowChat
onRunFlow={runFlowWithMessage}
showTestChats
path={$pathStore}
useStreaming={shouldUseStreaming}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
{/if}
<FlowChat
onRunFlow={runFlowWithMessage}
path={$pathStore}
hideSidebar={true}
useStreaming={shouldUseStreaming}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
</div>
{:else}
<div class="py-2 px-4 flex-1 min-h-0">
@@ -826,22 +858,9 @@
<div class={twMerge('flex flex-row divide-x', ButtonType.ColorVariants.blue.divider)}>
<SideBarTab {dropdownItems} fullMenu={!!$flowInputEditorState?.selectedTab}>
{#snippet close_button()}
<Button
onClick={() => handleEditSchema()}
{...!!$flowInputEditorState?.selectedTab
? {
title: 'Close input editor',
startIcon: { icon: ChevronRight },
btnClasses: 'rounded-none rounded-tl-md'
}
: {
title: 'Open input editor',
startIcon: { icon: Pen }
}}
variant="accent"
iconOnly
wrapperClasses="h-full"
/>
{@render inputsEditButton(!!$flowInputEditorState?.selectedTab, () =>
handleEditSchema()
)}
{/snippet}
</SideBarTab>
</div>
@@ -895,12 +914,7 @@
}}
>
{#snippet trigger()}
<div
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
id="add-flow-input-btn"
>
<Plus size={14} />
</div>
{@render inputsAddTrigger()}
{/snippet}
</AddPropertyV2>
{/if}
@@ -1,111 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte'
import { melt } from '@melt-ui/svelte'
import { twMerge } from 'tailwind-merge'
import { ChevronRight } from 'lucide-svelte'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { deepEqual } from 'fast-equals'
import { type DynamicInput } from '$lib/utils'
import { AGENT_CHAT_INPUT_META, type AgentChatInput } from './agentChatInputs'
interface Props {
input: AgentChatInput
value: any
onChange: (value: any) => void
builders: ReturnType<typeof createDropdownMenu>['builders']
meltItem: MenubarMenuElements['item']
workspace?: string
helperScript?: DynamicInput.HelperScript
}
let { input, value, onChange, builders, workspace, helperScript }: Props = $props()
const {
elements: { subTrigger, subMenu },
states: { subOpen }
} = untrack(() => builders).createSubmenu()
const meta = $derived(AGENT_CHAT_INPUT_META[input.key])
const summary = $derived(meta.summarize(value))
// A one-property schema, so the submenu holds the exact editor the Configure-inputs
// modal would render for this input.
const fieldSchema = $derived({
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: { [input.name]: input.property },
required: input.required ? [input.name] : [],
order: [input.name]
})
// SchemaForm writes into `args` in place, so it cannot drive a function binding.
// Both directions are kept in step: out on an edit, and back in when the owner
// resets the value from outside.
let synced = $state.snapshot(value)
let args = $state<Record<string, any>>({ [input.name]: synced })
$effect(() => {
const edited = $state.snapshot(args[input.name])
if (!deepEqual(edited, synced)) {
synced = edited
onChange(edited)
}
})
$effect(() => {
const incoming = $state.snapshot(value)
if (
!deepEqual(
incoming,
untrack(() => synced)
)
) {
synced = incoming
args = { [input.name]: incoming }
}
})
// Melt's roving focus blurs the focused element on pointermove, which would abort a
// native drag or steal focus mid-typing. Direct listeners so they run before melt's.
function isolatePointer(node: HTMLElement) {
const stop = (e: Event) => e.stopPropagation()
node.addEventListener('pointerdown', stop)
node.addEventListener('pointermove', stop)
node.addEventListener('keydown', stop)
return {
destroy() {
node.removeEventListener('pointerdown', stop)
node.removeEventListener('pointermove', stop)
node.removeEventListener('keydown', stop)
}
}
}
</script>
<button
use:melt={$subTrigger}
class={twMerge(
'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full',
'data-[highlighted]:bg-surface-hover',
'flex flex-row gap-2 items-center rounded-sm'
)}
>
<meta.icon size={14} class="shrink-0" />
<p class="truncate grow min-w-0 whitespace-nowrap text-left">{meta.label}</p>
{#if summary}
<span class="shrink-0 text-tertiary truncate max-w-[80px]">{summary}</span>
{/if}
<ChevronRight size={14} class="ml-auto shrink-0 text-tertiary" />
</button>
{#if $subOpen}
<div
use:melt={$subMenu}
class="z-[6000] bg-surface-tertiary dark:border w-72 origin-top-right rounded-lg shadow-lg focus:outline-none p-3"
>
<div use:isolatePointer>
<SchemaForm schema={fieldSchema} bind:args {helperScript} {workspace} />
</div>
</div>
{/if}
@@ -1,132 +0,0 @@
<script lang="ts">
/**
* Picking a model from a chat: a resource, then one of its models.
*
* Deliberately not AIProviderPicker. That one is an authoring form rendered through
* SchemaForm into the flow editor and the evals scorer, so it asks for the provider
* separately and carries authoring-only affordances. Here the provider is whatever
* the chosen resource is — its `resource_type` is the provider kind — so asking
* again would be asking the reader to restate something already known.
*
* The reasoning effort is absent on purpose: it lives in the settings menu beside
* this, as a slider, exactly where the copilot's own chat puts it.
*/
import { ResourceService, type AIProvider } from '$lib/gen'
import Select from '$lib/components/select/Select.svelte'
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
import { resource } from 'runed'
import { Loader2 } from 'lucide-svelte'
type ProviderValue = {
kind?: AIProvider
model?: string
resource?: string
reasoning_effort?: string
}
interface Props {
value: ProviderValue | undefined
onChange: (value: ProviderValue) => void
workspace: string | undefined
disabled?: boolean
}
let { value, onChange, workspace, disabled = false }: Props = $props()
const AI_RESOURCE_TYPES = Object.keys(AI_PROVIDERS)
// `$res:` is the stored form; the pickers work in bare paths.
const resourcePath = $derived(value?.resource?.replace(/^\$res:/, '') || undefined)
const resources = resource(
() => workspace,
async (ws) => {
if (!ws) return []
const rows = await ResourceService.listResource({
workspace: ws,
resourceType: AI_RESOURCE_TYPES.join(',')
})
return rows.map((r) => ({
value: r.path,
label: r.path,
// The row's own type is the provider; an unrecognised one is a custom endpoint.
provider: (AI_RESOURCE_TYPES.includes(r.resource_type ?? '')
? r.resource_type
: 'customai') as AIProvider
}))
}
)
const provider = $derived(
resources.current?.find((r) => r.value === resourcePath)?.provider ?? value?.kind
)
// Models the resource actually serves, asked of the provider. Its own catalogue is
// the fallback, so a listing that fails or is unsupported still offers real ids
// rather than an empty box.
const models = resource(
() => ({ workspace, resourcePath, provider }),
async ({ workspace, resourcePath, provider }, _prev, { onCleanup }) => {
if (!provider) return []
const fallback = AI_PROVIDERS[provider]?.defaultModels ?? []
if (!workspace || !resourcePath) return fallback
const controller = new AbortController()
onCleanup(() => controller.abort())
try {
const listed = await fetchAvailableModels(
resourcePath,
workspace,
provider,
controller.signal
)
return listed.length > 0 ? listed : fallback
} catch {
return fallback
}
}
)
function selectResource(path: string | undefined) {
const picked = resources.current?.find((r) => r.value === path)
onChange({
...value,
kind: picked?.provider,
resource: path ? `$res:${path}` : undefined,
// The models of one provider mean nothing to another.
model: undefined
})
}
</script>
<div class="flex flex-col gap-2 min-w-0">
<div class="flex flex-col gap-1">
<p class="text-2xs uppercase tracking-wide text-secondary">Provider</p>
{#if resources.loading}
<div class="flex items-center gap-2 text-xs text-tertiary py-1">
<Loader2 size={14} class="animate-spin" /> Loading resources...
</div>
{:else}
<Select
items={resources.current ?? []}
value={resourcePath}
onchange={selectResource}
placeholder="Select an AI resource"
{disabled}
clearable
/>
{/if}
</div>
<div class="flex flex-col gap-1">
<p class="text-2xs uppercase tracking-wide text-secondary">Model</p>
<Select
items={(models.current ?? []).map((m) => ({ 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
/>
</div>
</div>
@@ -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>('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 @@
})
</script>
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
<!-- border-t: the line the chat starts at, dividing it from whatever header sits above.
pb-3: the transcript and composer stop short of the panel edge, the way the session
chat sits in its own panel. The column's max width and side padding come from
AIChatDisplay itself. -->
<div class="flex overflow-hidden flex-1 pb-3 border-t">
{#if !hideSidebar}
<FlowConversationsSidebar {manager} />
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { Loader2, MessageCircle } from 'lucide-svelte'
import { Loader2, MessageCircle, SlidersHorizontal } from 'lucide-svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
@@ -11,12 +11,14 @@
import { CancelError, WorkspaceService, type FlowModule } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { resource } from 'runed'
import FlowChatSettings from './FlowChatSettings.svelte'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
import {
agentModelGap,
agentModelWiringInputs,
isEmptyAgentChatInputValue,
PER_TURN_AGENT_CHAT_INPUT_KEY,
resolveAgentChatInputs,
resolveStaticAgentModel
resolveAgentModelWiring
} from './agentChatInputs'
interface Props {
@@ -87,14 +89,20 @@
if (!loaded || loaded.ws !== chatWorkspace) return false
return loaded.settings.large_file_storage?.s3_resource_path !== undefined
})
const settingInputs = $derived(
agentChatInputs.filter((input) => input.key !== PER_TURN_AGENT_CHAT_INPUT_KEY)
)
const staticModel = $derived(resolveStaticAgentModel(flowModules))
// The model gets its own button, shaped like the copilot's model settings, driven by
// whichever provider fields the flow exposes. Attachments are the paperclip's. Nothing
// else is promoted, so every other flow input is asked for in the Configure-inputs modal.
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
// chat says what to go and do instead of offering controls that write nowhere.
const modelGap = $derived(agentModelGap(modelWiring))
const modalSchema = $derived.by(() => {
if (!additionalInputsSchema) return undefined
const promoted = new Set(agentChatInputs.map((input) => input.name))
const promoted = new Set([
...agentChatInputs.map((input) => input.name),
...agentModelWiringInputs(modelWiring)
])
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
)
@@ -206,16 +214,31 @@
{/snippet}
{#snippet footerSettings()}
<FlowChatSettings
inputs={settingInputs}
values={inputValues}
onChange={setInputValue}
{staticModel}
onOpenInputs={modalSchema ? openInputsModal : undefined}
inputsMissingRequired={modalMissingRequired}
workspace={chatWorkspace}
helperScript={dynamicInputHelperScript}
/>
{#if modalSchema}
<div class="relative">
<Button
unifiedSize="2xs"
variant="subtle"
startIcon={{ icon: SlidersHorizontal }}
btnClasses="text-secondary font-normal"
title="Configure the flow inputs sent with each message"
onClick={openInputsModal}
>
Inputs
</Button>
{#if modalMissingRequired}
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
</div>
{/if}
{#if modelWiring}
<FlowChatModelSettings
wiring={modelWiring}
values={inputValues}
setValue={setInputValue}
workspace={chatWorkspace}
/>
{/if}
{/snippet}
<!-- The transcript scroller fills its flex row, which needs a height to resolve
@@ -238,12 +261,10 @@
hideModeSelector
{wideLayout}
{emptyHint}
footerSettings={settingInputs.length > 0 || modalSchema || staticModel
? footerSettings
: undefined}
footerSettings={modalSchema || modelWiring ? footerSettings : undefined}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress}
disabledMessage="Deployment in progress"
disabled={deploymentInProgress || !!modelGap}
disabledMessage={deploymentInProgress ? 'Deployment in progress' : (modelGap ?? '')}
loadPastChat={() => {}}
deletePastChat={() => {}}
saveAndClear={() => {}}
@@ -6,12 +6,20 @@ 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'
import { parseStreamEvents, toolSummary } from '$lib/components/chat/utils'
import { randomUUID } from '$lib/utils/uuid'
export interface ChatMessage extends FlowConversationMessage {
loading?: boolean
streaming?: boolean
/**
* The call behind a tool row, as the stream reports it. Local to a running turn: the
* server stores only the summary sentence, and once the run settles the same details
* are read back from the tool's own job instead (see toolCallContext).
*/
tool_name?: string
tool_arguments?: string
tool_result?: string
}
export interface ConversationWithDraft extends FlowConversation {
@@ -36,6 +44,12 @@ export class FlowChatManager {
conversations = $state<ConversationWithDraft[]>([])
deletingConversationId = $state<string | undefined>(undefined)
isSidebarExpanded = $state(false)
/**
* Whether the list includes chats run from the editor's test panel. On in the editor,
* where testing is the point; off on a deployed flow, so someone's trial runs are not
* mixed into the real conversations.
*/
showTestChats = $state(false)
selectedConversationId = $state<string | undefined>(undefined)
conversationListComponent = $state<InfiniteList | undefined>(undefined)
@@ -160,6 +174,29 @@ export class FlowChatManager {
}
}
/**
* Open this flow's most recent conversation, unless the caller already chose one.
*
* Every turn is stored the moment it runs a preview from the editor exactly like a
* deployed run so a chat that has been used before should come back to it instead of
* to an empty pane. The editor needs this most: it hides the conversations sidebar, so
* without it there is no way back to what was said.
*/
async selectLatestConversation() {
if (this.selectedConversationId || !this.#workspace() || !this.#path) return
const [latest] = await this.loadConversations(1, 1)
// Re-checked after the await: a message sent meanwhile has already opened its own.
if (!latest || this.selectedConversationId) return
await this.selectConversation(latest.id)
}
/** Flip the test-chat filter and reload the list under it. */
async setShowTestChats(show: boolean) {
if (this.showTestChats === show) return
this.showTestChats = show
await this.refreshConversations()
}
async refreshConversations() {
await this.conversationListComponent?.loadData('forceRefresh')
}
@@ -221,6 +258,7 @@ export class FlowChatManager {
const response = await FlowConversationsService.listFlowConversations({
workspace: this.#workspace()!,
flowPath: this.#path,
includeTest: this.showTestChats,
page: page,
perPage: perPage
})
@@ -473,6 +511,49 @@ export class FlowChatManager {
this.focusInput()
}
/** Temp tool rows by the call id the stream gives them, so four events edit one row. */
#toolMessageIds = new Map<string, string>()
/** The assistant text stops growing once something else takes over the transcript. */
#settleStreamingMessage() {
this.messages = this.messages.map((msg) =>
msg.streaming ? { ...msg, streaming: false } : msg
)
}
/**
* Create or update the row for one tool call. A call arrives as up to four events
* (call, arguments, execution, result), each carrying a little more, and they must land
* on the same row rather than stacking up as separate cards.
*/
#upsertToolMessage(conversationId: string, callId: string, patch: Partial<ChatMessage>) {
const existingId = this.#toolMessageIds.get(callId)
if (existingId) {
this.messages = this.messages.map((msg) =>
msg.id === existingId ? { ...msg, ...patch } : msg
)
return
}
const id = 'temp-' + randomUUID()
this.#toolMessageIds.set(callId, id)
this.messages = [
...this.messages,
{
id,
content: '',
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'tool',
conversation_id: conversationId,
job_id: '',
loading: false,
streaming: false,
success: true,
...patch
}
]
}
private async handleStreamingMessage(
messageContent: string,
currentConversationId: string,
@@ -484,6 +565,9 @@ export class FlowChatManager {
this.currentEventSource.close()
}
// Rows from the previous turn are settled and must not be edited by this one.
this.#toolMessageIds.clear()
// Track stream state for this message
let accumulatedContent = ''
let assistantMessageId = ''
@@ -561,68 +645,60 @@ export class FlowChatManager {
if (data.new_result_stream) {
// Stop polling since we are receiving last step streaming
this.stopPolling()
const {
type,
content: newContent,
success
} = parseStreamDeltas(data.new_result_stream)
accumulatedContent += newContent
// Create tool message if type is tool_result
if (type === 'tool_result') {
// set last message streaming to false
this.messages = this.messages.map((msg) =>
msg.id === this.messages[this.messages.length - 1].id
? { ...msg, streaming: false }
: msg
)
this.messages = [
...this.messages,
{
id: 'temp-' + randomUUID(),
content: newContent,
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'tool',
conversation_id: currentConversationId,
job_id: '',
loading: false,
streaming: false,
success
}
]
// Reset assistant message ID since we are creating a tool message
assistantMessageId = ''
accumulatedContent = ''
// One chunk can carry several events, so each is applied in turn: a
// chunk holding a call and its result must produce both.
for (const event of parseStreamEvents(data.new_result_stream)) {
if (event.kind === 'tool_call' || event.kind === 'tool_execution') {
// The assistant text so far is finished; the tool row follows it.
this.#settleStreamingMessage()
assistantMessageId = ''
accumulatedContent = ''
this.#upsertToolMessage(currentConversationId, event.callId, {
tool_name: event.name,
content: `Running ${event.name}`,
loading: true
})
} else if (event.kind === 'tool_arguments') {
this.#upsertToolMessage(currentConversationId, event.callId, {
tool_name: event.name,
tool_arguments: event.arguments
})
} else if (event.kind === 'tool_result') {
this.#upsertToolMessage(currentConversationId, event.callId, {
tool_name: event.name,
tool_result: event.result,
content: toolSummary(event.name, event.success),
success: event.success,
loading: false
})
} else if (event.kind === 'token') {
accumulatedContent += event.content
}
}
// Create message on first content
else if (
type === 'message' &&
assistantMessageId.length === 0 &&
accumulatedContent.length > 0
) {
assistantMessageId = 'temp-' + randomUUID()
this.messages = [
...this.messages,
{
id: assistantMessageId,
content: accumulatedContent,
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'assistant',
conversation_id: currentConversationId,
job_id: '',
loading: false,
streaming: true
}
]
} else {
// Update existing message
this.messages = this.messages.map((msg) =>
msg.id === assistantMessageId ? { ...msg, content: accumulatedContent } : msg
)
// The assistant's own text is one growing message until a tool
// interrupts it, which is what resets the id above.
if (accumulatedContent.length > 0) {
if (assistantMessageId.length === 0) {
assistantMessageId = 'temp-' + randomUUID()
this.messages = [
...this.messages,
{
id: assistantMessageId,
content: accumulatedContent,
created_at: new Date().toISOString(),
created_seq: 0,
message_type: 'assistant',
conversation_id: currentConversationId,
job_id: '',
loading: false,
streaming: true
}
]
} else {
this.messages = this.messages.map((msg) =>
msg.id === assistantMessageId ? { ...msg, content: accumulatedContent } : msg
)
}
}
}
@@ -0,0 +1,234 @@
<script lang="ts">
/**
* The flow chat's model button: the same ChatModelSettings the session chat renders,
* over whatever the flow exposes.
*
* The agent takes one `provider` object, but an author can expose it field by field —
* fixing the resource in the step and letting the chat pick only the model, say. Each
* control here appears exactly when the flow wired the field behind it, so a chat never
* offers a knob whose value it could not write back.
*/
import ChatModelSettings from '$lib/components/copilot/ChatModelSettings.svelte'
import type { ChatModelSettingsConfig } from '$lib/components/copilot/chatModelSettings'
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
import { explicitOffToken } from '$lib/components/copilot/reasoningRegistry'
import { ResourceService, type AIProvider } from '$lib/gen'
import type { Item } from '$lib/utils'
import { Plug, Plus } from 'lucide-svelte'
import { resource } from 'runed'
import type { AgentModelWiring, ProviderField } from './agentChatInputs'
interface Props {
wiring: AgentModelWiring
/** Every flow input value the composer holds for this conversation. */
values: Record<string, any>
setValue: (name: string, value: any) => void
workspace?: string
}
let { wiring, values, setValue, workspace }: Props = $props()
function fieldValue(field: ProviderField): any {
if (wiring.whole) return values[wiring.whole]?.[field]
const name = wiring.fields[field]
return name ? values[name] : wiring.fixed[field]
}
function editable(field: ProviderField): boolean {
return wiring.whole !== undefined || wiring.fields[field] !== undefined
}
/** Written together, because choosing a resource also invalidates the model. */
function setFields(patch: Partial<Record<ProviderField, any>>) {
if (wiring.whole) {
setValue(wiring.whole, { ...(values[wiring.whole] ?? {}), ...patch })
return
}
for (const [field, value] of Object.entries(patch)) {
const name = wiring.fields[field as ProviderField]
if (name) setValue(name, value)
}
}
const resourceEditable = $derived(editable('resource'))
const modelEditable = $derived(editable('model'))
const effortEditable = $derived(editable('reasoning_effort'))
// Nothing to write: the flow fixes the lot, so the button names it and opens nothing.
const readOnly = $derived(!resourceEditable && !modelEditable && !effortEditable)
const AI_RESOURCE_TYPES = Object.keys(AI_PROVIDERS)
// `$res:` is the stored form; the picker works in bare paths.
const resourcePath = $derived(
typeof fieldValue('resource') === 'string'
? fieldValue('resource').replace(/^\$res:/, '') || undefined
: undefined
)
const model = $derived(fieldValue('model'))
const effort = $derived(fieldValue('reasoning_effort'))
let appConnect: AppConnect | undefined = $state(undefined)
// Bumped after the connect drawer creates one, to re-list.
let resourcesVersion = $state(0)
// Set when a resource is created here: it can only be selected once the re-listing
// that follows tells us which provider it speaks.
let pendingResourcePath = $state<string | undefined>(undefined)
const resources = resource(
() => (resourceEditable ? { workspace, version: resourcesVersion } : undefined),
async (args) => {
const ws = args?.workspace
if (!ws) return []
const rows = await ResourceService.listResource({
workspace: ws,
resourceType: AI_RESOURCE_TYPES.join(',')
})
return rows.map((r) => ({
path: r.path,
// The row's own type is the provider; an unrecognised one is a custom endpoint.
provider: (AI_RESOURCE_TYPES.includes(r.resource_type ?? '')
? r.resource_type
: 'customai') as AIProvider
}))
}
)
const provider = $derived(
resources.current?.find((r) => r.path === resourcePath)?.provider ??
(fieldValue('kind') as AIProvider | undefined)
)
// Models the resource actually serves, asked of the provider. Its own catalogue is the
// fallback, so a listing that fails or is unsupported still offers real ids rather than
// an empty menu.
const models = resource(
() => ({ workspace, resourcePath, provider, modelEditable }),
async ({ workspace, resourcePath, provider, modelEditable }, _prev, { onCleanup }) => {
if (!modelEditable || !provider) return []
const fallback = AI_PROVIDERS[provider]?.defaultModels ?? []
if (!workspace || !resourcePath) return fallback
const controller = new AbortController()
onCleanup(() => controller.abort())
try {
const listed = await fetchAvailableModels(
resourcePath,
workspace,
provider,
controller.signal
)
return listed.length > 0 ? listed : fallback
} catch {
return fallback
}
}
)
$effect(() => {
if (!pendingResourcePath) return
const created = resources.current?.find((r) => r.path === pendingResourcePath)
if (created) {
pendingResourcePath = undefined
selectResource(created.path, created.provider)
}
})
function selectResource(path: string, picked: AIProvider) {
setFields({
kind: picked,
resource: `$res:${path}`,
// The models of one provider mean nothing to another, and the new list only
// arrives async, so there is nothing to carry the current one against.
model: undefined
})
}
function providerItem(close: () => void): Item {
const rows: Item[] = resources.loading
? [{ displayName: 'Loading resources...', disabled: true }]
: (resources.current ?? []).length === 0
? [{ displayName: 'No AI resource in this workspace', disabled: true }]
: (resources.current ?? []).map((r) => ({
displayName: r.path,
selected: r.path === resourcePath,
action: () => selectResource(r.path, r.provider)
}))
return {
displayName: 'Provider',
icon: Plug,
extra: providerSummary,
submenuItems: [
...rows,
{
// The same reach the form's ResourcePicker gives: create one without
// leaving for workspace settings first.
displayName: 'Add a resource',
icon: Plus,
separatorTop: true,
action: () => {
close()
appConnect?.open()
}
}
]
}
}
const config = $derived<ChatModelSettingsConfig>({
label: typeof model === 'string' && model ? model : 'Select a model',
title: 'Model & reasoning settings',
readOnly,
readOnlyReason: 'Set in the flow',
topItems: resourceEditable ? (close) => [providerItem(close)] : undefined,
sections: modelEditable
? [
{
label: 'Model',
options: (models.current ?? []).map((m) => ({
key: m,
label: m,
selected: m === model,
onSelect: () => setFields({ model: m })
})),
loading: models.loading,
emptyMessage: provider ? 'No model available' : 'Pick a provider first'
}
]
: undefined,
// Offered as a slider only where the flow exposed it. When nothing is editable the
// menu never opens, so passing it there only names the effort on the button.
reasoning:
(effortEditable || readOnly) && provider && typeof model === 'string' && model
? {
provider,
model,
value: typeof effort === 'string' ? effort : undefined,
// An agent writes the provider-native token straight into its step, so
// there is no sentinel to translate later.
offToken: explicitOffToken(provider, model),
onSelect: (token) => setFields({ reasoning_effort: token })
}
: undefined
})
</script>
{#snippet providerSummary()}
{#if resourcePath}
<span class="shrink-0 text-tertiary truncate max-w-[80px]">{resourcePath}</span>
{/if}
{/snippet}
{#if resourceEditable}
<AppConnect
bind:this={appConnect}
{workspace}
on:refresh={(e) => {
resourcesVersion++
if (e.detail) {
pendingResourcePath = e.detail
}
}}
/>
{/if}
<ChatModelSettings {config} />
@@ -1,206 +0,0 @@
<script lang="ts">
import { ChevronDown, Settings2, SlidersHorizontal } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { type DynamicInput } from '$lib/utils'
import AgentChatInputSubmenu from './AgentChatInputSubmenu.svelte'
import ChatModelPicker from './ChatModelPicker.svelte'
import ReasoningEffortSlider from '$lib/components/copilot/ReasoningEffortSlider.svelte'
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
import { getReasoningCapability, explicitOffToken } from '$lib/components/copilot/reasoningRegistry'
import type { AIProvider } from '$lib/gen'
import { type AgentChatInput, type AgentModel } from './agentChatInputs'
interface Props {
/** Wired agent fields, one submenu each. */
inputs: AgentChatInput[]
values: Record<string, any>
onChange: (name: string, value: any) => void
/** The model the flow fixes, when it fixes one. Named on the trigger, not editable. */
staticModel?: AgentModel
/** Opens the Configure-inputs modal, when the flow has inputs no agent field reads. */
onOpenInputs?: () => void
inputsMissingRequired?: boolean
workspace?: string
helperScript?: DynamicInput.HelperScript
}
let {
inputs,
values,
onChange,
staticModel,
onOpenInputs,
inputsMissingRequired = false,
workspace,
helperScript
}: Props = $props()
let menuOpen = $state(false)
// The chosen model where the flow exposes one, else the model it fixes. With neither —
// several agents, or a model computed per run — there is no single one to name, so the
// trigger says what it is instead: settings.
const modelInput = $derived(inputs.find((input) => input.key === 'provider'))
// Everything but the model gets a submenu; the model and its thinking sit in this
// panel together, the way the copilot's own settings menu lays them out.
const submenuInputs = $derived(inputs.filter((input) => input.key !== 'provider'))
const model = $derived.by((): AgentModel | undefined => {
const chosen = modelInput ? values[modelInput.name] : undefined
return typeof chosen?.model === 'string' ? chosen : staticModel
})
// Thinking sits in this menu rather than inside the model editor, matching where the
// copilot's own chat puts it. Editable only where a flow input feeds the provider —
// a model the flow fixes has nothing here for the composer to write.
const capability = $derived(
model?.kind && model?.model
? getReasoningCapability(model.kind as AIProvider, model.model)
: { supported: false, levels: [] as string[], canDisable: false }
)
const offToken = $derived(
model?.kind && model?.model
? explicitOffToken(model.kind as AIProvider, model.model)
: undefined
)
const effortStops = $derived([
...(capability.canDisable && offToken !== undefined ? [offToken] : []),
...capability.levels
])
const currentEffort = $derived(model?.reasoning_effort ?? effortStops[0] ?? '')
function selectEffort(stop: string) {
if (!modelInput) return
onChange(modelInput.name, { ...values[modelInput.name], reasoning_effort: stop })
}
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
// The trigger resizes when a value changes while the menu is open, which would shift a
// bottom-end popover anchored to its right edge. Freeze the width for as long as it is open.
let triggerEl: HTMLElement | undefined = $state(undefined)
let lockedWidth = $state<number | undefined>(undefined)
$effect(() => {
if (menuOpen) {
if (lockedWidth === undefined && triggerEl) {
lockedWidth = triggerEl.getBoundingClientRect().width
}
} else {
lockedWidth = undefined
}
})
</script>
<DropdownV2
customMenu
placement="bottom-end"
fixedHeight={false}
closeOnItemClick={false}
bind:open={menuOpen}
>
{#snippet buttonReplacement()}
<div
bind:this={triggerEl}
class="relative"
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
>
<!-- With a single model the trigger reads exactly as the copilot's does:
the model, then its reasoning effort. Otherwise there is no one model to
name and it falls back to what it opens. -->
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
startIcon={model ? undefined : { icon: Settings2 }}
endIcon={{ icon: ChevronDown }}
btnClasses="w-full max-w-[200px] text-secondary font-normal"
title={model ? 'Model & agent settings' : 'Agent settings'}
>
{#if model}
<span class="flex items-center gap-1 min-w-0">
<span class="truncate">{model.model}</span>
{#if model.reasoning_effort}
<span class="shrink-0 text-tertiary">· {model.reasoning_effort}</span>
{/if}
</span>
{:else}
<span class="truncate">Settings</span>
{/if}
</Button>
{#if inputsMissingRequired}
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
</div>
{/snippet}
{#snippet menu({ item, builders })}
<div
class="bg-surface-tertiary dark:border w-72 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
>
{#each submenuInputs as input (input.name)}
<AgentChatInputSubmenu
{input}
value={values[input.name]}
onChange={(value) => onChange(input.name, value)}
{builders}
meltItem={item}
{workspace}
{helperScript}
/>
{/each}
{#if modelInput}
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="px-3 pb-2">
<ChatModelPicker
value={values[modelInput.name]}
onChange={(v) => onChange(modelInput.name, v)}
{workspace}
/>
</div>
{/if}
{#if modelInput && model?.model}
<div class="my-1 border-t border-border-light"></div>
{#if capability.supported && effortStops.length > 1}
<MenuItemWrapper {item} onKeydown={(e) => effortSlider?.adjust(e)} class="block group">
<ReasoningEffortSlider
bind:this={effortSlider}
stops={effortStops}
current={currentEffort}
onSelect={selectEffort}
format={(stop) => (stop === offToken ? 'off' : stop)}
/>
</MenuItemWrapper>
{:else}
<ReasoningEffortSlider
stops={[]}
current=""
onSelect={() => {}}
unsupportedReason="Not supported by this model"
/>
{/if}
{/if}
<!-- A model the flow fixes is shown but not offered: no flow input feeds it, so
changing it here would mean editing the flow. -->
{#if staticModel && !modelInput}
<div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
<div class="px-3 pb-1 text-primary truncate">{staticModel.model}</div>
<div class="px-3 pb-1.5 text-2xs text-tertiary">Set in the flow</div>
{/if}
{#if onOpenInputs}
<div class="my-1 border-t border-border-light"></div>
<button
class="px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full flex flex-row gap-2 items-center rounded-sm"
onclick={() => {
menuOpen = false
onOpenInputs?.()
}}
>
<SlidersHorizontal size={14} class="shrink-0" />
<p class="truncate grow min-w-0 text-left">Other inputs</p>
{#if inputsMissingRequired}
<span class="w-2 h-2 bg-yellow-500 rounded-full shrink-0"></span>
{/if}
</button>
{/if}
</div>
{/snippet}
</DropdownV2>
@@ -1,6 +1,16 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
import {
MessageCircle,
Plus,
Trash2,
PanelLeftClose,
PanelLeftOpen,
FlaskConical
} from 'lucide-svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Filter } from 'lucide-svelte'
import { type FlowConversation } from '$lib/gen'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
@@ -25,7 +35,7 @@
: 'w-[44px]'}"
>
<!-- Header -->
<div class="flex-shrink-0 border-b">
<div class="flex-shrink-0">
<div class="flex flex-col gap-2 p-1">
<Button
unifiedSize="md"
@@ -41,17 +51,57 @@
>
<div transition:fade={{ duration: 100 }}> Conversations </div>
</Button>
<Button
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={() => manager.createConversation({ clearMessages: true })}
title="Start new conversation"
iconOnly={!manager.isSidebarExpanded}
btnClasses={'justify-start transition-all duration-150 whitespace-nowrap'}
<!-- Side by side while there is width for both labels; stacked once collapsed,
where the rail fits one icon across. -->
<div
class={manager.isSidebarExpanded
? 'flex flex-row gap-1 items-center'
: 'flex flex-col gap-2'}
>
<div transition:fade={{ duration: 100 }}> New chat </div>
</Button>
<Button
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={() => manager.createConversation({ clearMessages: true })}
title="Start new conversation"
iconOnly={!manager.isSidebarExpanded}
wrapperClasses={manager.isSidebarExpanded ? 'grow min-w-0' : ''}
btnClasses={'w-full justify-start transition-all duration-150 whitespace-nowrap'}
>
<div transition:fade={{ duration: 100 }}> New chat </div>
</Button>
<Popover placement="bottom-start" closeButton={false}>
{#snippet trigger()}
<Button
nonCaptureEvent
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Filter, classes: 'ml-[2px]' }}
title="Filter conversations"
iconOnly={!manager.isSidebarExpanded}
btnClasses={'w-full justify-start transition-all duration-150 whitespace-nowrap'}
>
<div transition:fade={{ duration: 100 }}>
Filter{manager.showTestChats ? ' · 1' : ''}
</div>
</Button>
{/snippet}
{#snippet content()}
<div class="p-3">
<Toggle
size="xs"
checked={manager.showTestChats}
on:change={(e) => manager.setShowTestChats(e.detail)}
options={{ right: 'Show test chats' }}
/>
<p class="text-2xs text-tertiary mt-1.5 max-w-[190px]">
Chats run from the flow editor's test panel, kept apart from the flow's real
conversations.
</p>
</div>
{/snippet}
</Popover>
</div>
</div>
</div>
@@ -102,6 +152,11 @@
selected={manager.selectedConversationId === conversation.id}
btnClasses="transition-all duration-150 group"
>
{#if conversation.is_test}
<!-- Both kinds share this list whenever the filter is on, so a test chat
has to be readable as one at a glance. -->
<FlaskConical size={12} class="shrink-0 mr-1 text-tertiary" />
{/if}
<span class="flex-1 text-left truncate">
{getConversationTitle(conversation)}
</span>
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { parseProviderTransform, resolveAgentModelWiring } from './agentChatInputs'
import type { FlowModule } from '$lib/gen'
function agent(expr: string): FlowModule {
return {
id: 'a',
value: { type: 'aiagent', tools: [], input_transforms: { provider: { type: 'javascript', expr } } }
} as unknown as FlowModule
}
describe('parseProviderTransform', () => {
it('reads a whole-object reference', () => {
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.model' })).toEqual({
whole: 'model',
fields: {},
fixed: {}
})
})
it('splits a partial expression into wired and fixed fields', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model, reasoning_effort: flow_input.thinking }"
})
expect(wiring).toEqual({
fields: { model: 'model', reasoning_effort: 'thinking' },
fixed: { kind: 'anthropic', resource: '$res:u/admin/claude' }
})
})
// The regression this detector exists for: counting flow_input references would read
// this as one input carrying the whole provider object, and the composer would write
// {kind, resource, model} into an input the expression uses as the model name.
it('does not mistake a single-reference partial expression for a whole-object one', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }"
})
expect(wiring?.whole).toBeUndefined()
expect(wiring?.fields).toEqual({ model: 'model' })
})
// The flow editor's JS field commonly holds a parenthesised object, which is how an
// author writes one without it reading as a block.
it('accepts a parenthesised object expression', () => {
const wiring = parseProviderTransform({
type: 'javascript',
expr: `({
"kind": "anthropic",
"resource": "$res:u/admin/anthropic_windmill_codegen",
"model": "claude-sonnet-5",
"reasoning_effort": flow_input.thinking
})`
})
expect(wiring).toEqual({
fields: { reasoning_effort: 'thinking' },
fixed: {
kind: 'anthropic',
resource: '$res:u/admin/anthropic_windmill_codegen',
model: 'claude-sonnet-5'
}
})
})
it('treats a static provider as entirely fixed', () => {
expect(
parseProviderTransform({
type: 'static',
value: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
})
).toEqual({
fields: {},
fixed: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
})
})
it.each([
["{ ...base, model: flow_input.model }", 'a spread could supply any field'],
["{ model: pickModel(flow_input.x) }", 'a call is not classifiable'],
['flow_input.model + 1', 'not a bare reference'],
['{ model: ', 'unparseable']
])('gives up on %s', (expr) => {
expect(parseProviderTransform({ type: 'javascript', expr } as any)).toBeUndefined()
})
})
describe('resolveAgentModelWiring', () => {
const fixedResource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
it('drives a field every agent reads from the same input', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`),
agent(`({ ${fixedResource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`)
])
expect(wiring?.fields).toEqual({ reasoning_effort: 'thinking' })
// The agents run different models, so there is no single one to name.
expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/admin/claude' })
})
it('drops a field the agents disagree about', () => {
const wiring = resolveAgentModelWiring([
agent(`({ ${fixedResource}, reasoning_effort: flow_input.thinking })`),
agent(`({ ${fixedResource}, reasoning_effort: flow_input.other })`)
])
expect(wiring?.fields.reasoning_effort).toBeUndefined()
})
it('refuses a flow mixing whole-object and field-by-field wiring', () => {
expect(
resolveAgentModelWiring([
agent('flow_input.provider'),
agent(`({ ${fixedResource}, model: flow_input.model })`)
])
).toBeUndefined()
})
})
@@ -1,7 +1,6 @@
import type { FlowModule, InputTransform } from '$lib/gen'
import { getAllModules } from '../flowExplorer'
import { Bot, Hash, Paperclip, ScrollText, Thermometer } from 'lucide-svelte'
import type { ComponentType } from 'svelte'
import { parseExpressionAt } from 'acorn'
/**
* AI agent inputs the chat composer can drive, in footer display order.
@@ -12,16 +11,15 @@ import type { ComponentType } from 'svelte'
* on a step linked to an `ai_agent` resource, where every field but `user_message` /
* `user_attachments` comes from the resource and is not overridable at all.
*
* `max_iterations` is deliberately absent: it caps the agent's tool-use loop rather than
* a single generation, so it belongs with the flow's settings, not the model's.
* Only what the person chatting legitimately owns turn to turn is here: the files they
* attach, and the model they are talking to. `system_prompt`, `temperature` and
* `max_completion_tokens` shape how the agent behaves for everyone who runs the flow
* surfacing them per conversation invites tuning the flow from the chat instead of
* fixing it in the editor. They stay flow settings, reachable through Configure inputs
* when the author deliberately exposes them. `max_iterations` is absent for the same
* reason, and because it caps the tool-use loop rather than a single generation.
*/
export const AGENT_CHAT_INPUT_KEYS = [
'user_attachments',
'provider',
'system_prompt',
'temperature',
'max_completion_tokens'
] as const
export const AGENT_CHAT_INPUT_KEYS = ['user_attachments'] as const
export type AgentChatInputKey = (typeof AGENT_CHAT_INPUT_KEYS)[number]
@@ -37,40 +35,6 @@ export type AgentChatInput = {
required: boolean
}
export const AGENT_CHAT_INPUT_META: Record<
AgentChatInputKey,
{ icon: ComponentType; label: string; summarize: (value: any) => string | undefined }
> = {
user_attachments: {
icon: Paperclip,
label: 'Attach',
summarize: (value) => {
const count = Array.isArray(value) ? value.length : value ? 1 : 0
return count > 0 ? String(count) : undefined
}
},
provider: {
icon: Bot,
label: 'Model',
summarize: (value) => (typeof value?.model === 'string' ? value.model : undefined)
},
system_prompt: {
icon: ScrollText,
label: 'System prompt',
summarize: (value) => (typeof value === 'string' && value.trim() !== '' ? 'set' : undefined)
},
temperature: {
icon: Thermometer,
label: 'Temperature',
summarize: (value) => (typeof value === 'number' ? String(value) : undefined)
},
max_completion_tokens: {
icon: Hash,
label: 'Max tokens',
summarize: (value) => (typeof value === 'number' ? String(value) : undefined)
}
}
const FLOW_INPUT_REF = /flow_input\??\.([A-Za-z_$][\w$]*)/g
/**
@@ -92,25 +56,176 @@ export function flowInputRef(transform: InputTransform | undefined): string | un
/** A provider value as the agent stores it. */
export type AgentModel = { kind?: string; model?: string; reasoning_effort?: string }
/** The provider fields the composer can read or drive. */
const PROVIDER_FIELDS = ['kind', 'resource', 'model', 'reasoning_effort'] as const
export type ProviderField = (typeof PROVIDER_FIELDS)[number]
/**
* The model the flow already fixes, when it fixes exactly one: a single AI agent step
* whose `provider` is a static value. Named on the settings trigger the way the session
* chat names its own model, but not editable no flow input feeds it, so there is
* nothing the composer could write.
* How an AI agent step's `provider` is supplied, field by field.
*
* The agent takes one `provider` object, so an author who wants the chat to choose only
* the model writes the rest as literals around it:
*
* { kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }
*
* `fields` names the flow input behind each field the author exposed, `fixed` holds the
* literals, and `whole` covers the plain `flow_input.x` case where one input carries the
* entire object. Reading them apart is what lets the composer offer exactly the knobs the
* flow exposed and stops a partial expression from being mistaken for a whole-object
* one, which would write a provider object into an input the flow reads as a model name.
*/
export function resolveStaticAgentModel(
export type AgentModelWiring = {
whole?: string
fields: Partial<Record<ProviderField, string>>
fixed: Partial<Record<ProviderField, any>>
}
/** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */
function flowInputName(node: any): string | undefined {
const member = node?.type === 'ChainExpression' ? node.expression : node
if (member?.type !== 'MemberExpression') return undefined
if (member.object?.type !== 'Identifier' || member.object.name !== 'flow_input') return undefined
if (!member.computed && member.property?.type === 'Identifier') return member.property.name
if (member.computed && member.property?.type === 'Literal') {
return typeof member.property.value === 'string' ? member.property.value : undefined
}
return undefined
}
/** A property's name, for the plain `key:` and `'key':` forms only. */
function propertyKey(property: any): string | undefined {
if (property?.type !== 'Property' || property.computed) return undefined
if (property.key?.type === 'Identifier') return property.key.name
if (property.key?.type === 'Literal' && typeof property.key.value === 'string') {
return property.key.value
}
return undefined
}
/**
* Read a `provider` input transform. Anything this cannot account for in full returns
* undefined rather than a guess: the composer then leaves the field alone instead of
* writing into an expression it does not understand.
*/
export function parseProviderTransform(
transform: InputTransform | undefined
): AgentModelWiring | undefined {
if (transform?.type === 'static') {
const value = transform.value
if (!value || typeof value !== 'object') return undefined
const fixed: AgentModelWiring['fixed'] = {}
for (const field of PROVIDER_FIELDS) {
if (value[field] !== undefined) fixed[field] = value[field]
}
return { fields: {}, fixed }
}
if (transform?.type !== 'javascript') return undefined
// Parenthesised so a leading `{` reads as an object literal rather than a block. The
// author's own text may already be wrapped that way, so any balanced surround is fine —
// what the span check rejects is an expression with something else beside it.
const source = `(${transform.expr})`
let node: any
try {
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
} catch {
return undefined
}
const before = source.slice(0, node.start)
const after = source.slice(node.end)
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
const whole = flowInputName(node)
if (whole) return { whole, fields: {}, fixed: {} }
if (node.type !== 'ObjectExpression') return undefined
const fields: AgentModelWiring['fields'] = {}
const fixed: AgentModelWiring['fixed'] = {}
for (const property of node.properties) {
const key = propertyKey(property)
// A spread or a computed key could supply any field, so nothing here is knowable.
if (!key) return undefined
if (!(PROVIDER_FIELDS as readonly string[]).includes(key)) continue
const name = flowInputName(property.value)
if (name) {
fields[key as ProviderField] = name
} else if (property.value?.type === 'Literal') {
fixed[key as ProviderField] = property.value.value
} else {
return undefined
}
}
return { fields, fixed }
}
/**
* The provider wiring the chat can act on, across every AI agent in the flow.
*
* With several agents a field is drivable when they agree on it: one flow input feeding
* it, or one literal fixing it. Where they disagree there is no single value to show or
* write, so that field is dropped and the others still work. A flow mixing whole-object
* and field-by-field wiring is ambiguous throughout and yields nothing.
*/
export function resolveAgentModelWiring(
modules: FlowModule[] | undefined
): AgentModel | undefined {
const agents = getAllModules(modules ?? []).filter((m) => m.value.type === 'aiagent')
if (agents.length !== 1) return undefined
const provider = (agents[0].value as any).input_transforms?.['provider']
if (provider?.type !== 'static') return undefined
const value = provider.value
return typeof value?.model === 'string'
? { kind: value.kind, model: value.model, reasoning_effort: value.reasoning_effort }
): AgentModelWiring | undefined {
const wirings = getAllModules(modules ?? [])
.filter((m) => m.value.type === 'aiagent')
.map((agent) => parseProviderTransform((agent.value as any).input_transforms?.['provider']))
.filter((wiring): wiring is AgentModelWiring => wiring !== undefined)
if (wirings.length === 0) return undefined
if (wirings.length === 1) return wirings[0]
const wholes = new Set(wirings.map((w) => w.whole))
if (wholes.size === 1 && !wholes.has(undefined)) {
return { whole: [...wholes][0], fields: {}, fixed: {} }
}
if (wirings.some((w) => w.whole !== undefined)) return undefined
const fields: AgentModelWiring['fields'] = {}
const fixed: AgentModelWiring['fixed'] = {}
for (const field of PROVIDER_FIELDS) {
const inputs = new Set(wirings.map((w) => w.fields[field]).filter((n) => n !== undefined))
if (inputs.size === 1) {
fields[field] = [...inputs][0]
continue
}
// One agent reading it from an input while another fixes it: no single answer.
if (inputs.size > 1) continue
const literals = new Set(
wirings.map((w) => w.fixed[field]).filter((v) => v !== undefined).map((v) => JSON.stringify(v))
)
if (literals.size === 1) fixed[field] = JSON.parse([...literals][0])
}
return { fields, fixed }
}
/**
* Why the chat cannot run, when the agent's own provider is incomplete.
*
* A freshly added agent carries `{ kind: 'openai', model: '', resource: '' }`, so it names
* a provider kind while having nothing to call the run fails and the chat can do nothing
* about it, because no flow input feeds either field. Saying so beats a dead model button.
* A field the flow exposes is never a gap: the reader picks it in the composer.
*/
export function agentModelGap(wiring: AgentModelWiring | undefined): string | undefined {
// No agent, several of them, or an expression we cannot read: not ours to judge.
if (!wiring || wiring.whole) return undefined
const missing = (field: ProviderField) =>
wiring.fields[field] === undefined &&
(wiring.fixed[field] === undefined || wiring.fixed[field] === '')
return missing('resource') || missing('model')
? 'Pick a provider and model on the AI agent step to use this chat.'
: undefined
}
/** Every flow input the wiring reads, so the modal does not ask for them a second time. */
export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] {
if (!wiring) return []
return [...(wiring.whole ? [wiring.whole] : []), ...Object.values(wiring.fields)]
}
export function isEmptyAgentChatInputValue(value: any): boolean {
if (value === undefined || value === null || value === '') return true
return Array.isArray(value) && value.length === 0
@@ -5,6 +5,8 @@ import type {
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatMessage, FlowChatManager } from './FlowChatManager.svelte'
import { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
import { ToolCallStore, type ToolCallDetails } from './toolCallContext.svelte'
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
import { dataUrlToBlob, type AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
@@ -31,11 +33,26 @@ export type FlowChatViewHostOptions = {
canAttach?: () => boolean
}
/**
* A tool's arguments and result reach us as strings: the provider's JSON for the call, and
* whatever the tool returned, which is often but not always JSON. Parsed where it parses so
* the card can fold it, kept verbatim where it does not.
*/
function parseToolPayload(raw: string | null | undefined): any {
if (raw === undefined || raw === null || raw === '') return undefined
try {
return JSON.parse(raw)
} catch {
return raw
}
}
function toDisplayMessage(
message: ChatMessage,
userIndex: number,
showStepNames: boolean,
inputs: MessageInputsStore,
toolCalls: ToolCallStore,
failed: boolean
): DisplayMessage {
switch (message.message_type) {
@@ -53,24 +70,46 @@ function toDisplayMessage(
contextElements: contextElements.length > 0 ? contextElements : undefined
}
}
case 'tool':
// Both producers of a tool row — the agent executor and the frontend's own
// stream parser — write the whole message as a one-line description of the
// call ("Used web_search tool"). There is no result to reveal, so the row
// is the label and nothing else. `toolName` stays unset: it drives the
// copilot's plan-card detection, which a flow step summary must not trip.
case 'tool': {
const failed = message.success === false
// While the turn streams, the events carry the call; afterwards the same details
// come from the tool's own job. A row shows whichever it has.
const streamed: ToolCallDetails = {
toolName: message.tool_name,
parameters: parseToolPayload(message.tool_arguments),
result: parseToolPayload(message.tool_result)
}
const fromJob = toolCalls.get(message.job_id)
const toolName = streamed.toolName ?? fromJob.toolName
const parameters = streamed.parameters ?? fromJob.parameters
const result = failed ? undefined : (streamed.result ?? fromJob.result)
return {
role: 'tool',
tool_call_id: message.id,
content: message.content,
// Withheld for the copilot's two plan-mode names: `toolName` is what makes
// ToolExecutionDisplay render a plan card, and an agent tool that happened to
// share one would silently become one.
toolName: isPlanCardTool(toolName) ? undefined : toolName,
parameters,
result,
// The card's fold is opt-in (ToolExecutionDisplay reads showDetails), so it is
// offered only when there is a call or a result behind it to reveal.
showDetails: parameters !== undefined || result !== undefined,
error: failed ? message.content : undefined,
isLoading: message.loading
}
}
default:
return {
role: 'assistant',
content: message.content,
streaming: message.streaming,
stepName: showStepNames ? (message.step_name ?? undefined) : undefined
stepName: showStepNames ? (message.step_name ?? undefined) : undefined,
// The run behind the answer, so a reader can open what produced it. Absent on
// the temp message a stream builds, which has no job id until it settles.
jobId: message.job_id || undefined,
createdAt: message.created_at
}
}
}
@@ -112,6 +151,7 @@ export class FlowChatViewHost implements ChatViewHost {
)
#messageInputs = new MessageInputsStore(() => this.#options.workspace?.())
#toolCalls = new ToolCallStore(() => this.#options.workspace?.())
displayMessages = $derived.by(() => {
let userIndex = 0
@@ -123,6 +163,7 @@ export class FlowChatViewHost implements ChatViewHost {
message.message_type === 'user' ? userIndex++ : -1,
showStepNames,
this.#messageInputs,
this.#toolCalls,
message.message_type === 'user' && turnFailed(messages, i)
)
)
@@ -0,0 +1,79 @@
/**
* What a tool call ran with and returned, recovered from its job.
*
* A conversation row stores only a summary sentence ("Used X tool"), but a Windmill tool
* runs as its own job, and that job already holds everything the card needs: `args` are
* the arguments the model supplied, `result` is what came back, and `script_path` names
* the tool. Reading them there keeps one copy of the data instead of two.
*
* Two kinds of tool are out of reach and keep the sentence:
* - an MCP tool runs inside the agent's own job, so its row carries no job id;
* - a provider-native tool (web search) runs inside the completion, and its row points at
* the *agent's* job — whose args are the agent's configuration, not the search's. Using
* them would show confidently wrong details, so an aiagent job is ignored.
*/
import { JobService } from '$lib/gen'
export type ToolCallDetails = {
toolName?: string
parameters?: any
result?: any
}
const EMPTY: ToolCallDetails = {}
/** The tool's own name, which is the last segment of the job's path. */
function toolNameFromPath(path: string | undefined): string | undefined {
const name = path?.split('/').filter(Boolean).pop()
return name && name !== '' ? name : undefined
}
export function jobToToolCallDetails(job: any): ToolCallDetails {
// The agent's own job means this row is a provider-native tool; its args describe the
// agent, not the call.
if (!job || job.job_kind === 'aiagent') return EMPTY
const parameters =
job.args && typeof job.args === 'object' && Object.keys(job.args).length > 0
? job.args
: undefined
return {
toolName: toolNameFromPath(job.script_path),
parameters,
result: job.result
}
}
/** Per-conversation cache of tool jobs by id. One fetch per tool row while mounted. */
export class ToolCallStore {
#workspace: () => string | undefined
#byJob = $state<Record<string, ToolCallDetails>>({})
#inFlight = new Set<string>()
constructor(workspace: () => string | undefined) {
this.#workspace = workspace
}
/** The call behind a tool row, fetching on first ask. Empty until the job lands. */
get(jobId: string | null | undefined): ToolCallDetails {
if (!jobId) return EMPTY
const cached = this.#byJob[jobId]
if (cached) return cached
void this.#load(jobId)
return EMPTY
}
async #load(jobId: string) {
const workspace = this.#workspace()
if (!workspace || this.#inFlight.has(jobId)) return
this.#inFlight.add(jobId)
try {
const job = await JobService.getJob({ workspace, id: jobId, noLogs: true })
this.#byJob = { ...this.#byJob, [jobId]: jobToToolCallDetails(job) }
} catch {
// A purged job, or one this user cannot read: the row keeps its summary.
this.#byJob = { ...this.#byJob, [jobId]: EMPTY }
} finally {
this.#inFlight.delete(jobId)
}
}
}