mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
5aa863c6e3
commit
d993013dc0
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user