mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
feat: chat with a saved agent from the agent editor (#11292)
* feat: chat with a saved agent from the agent editor * fix: keep agent chats apart from a same-path flow's conversations * fix: point an agent editor chat's model gap at the agent, not a step * fix: match the memory gate's icon to the chat's empty state
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getContext, setContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { Info, MessageCircleOff } from 'lucide-svelte'
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import FlowChat from '../conversations/FlowChat.svelte'
|
||||
import {
|
||||
AGENT_CHAT_SCHEMA,
|
||||
agentChatFlow,
|
||||
agentChatGap,
|
||||
agentChatPath
|
||||
} from '../conversations/agentEditorChat'
|
||||
import { runFlowPreview } from '../utils.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import type { Flow, FlowModule, InputTransform, Job, OpenFlow } from '$lib/gen'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
@@ -30,7 +39,12 @@
|
||||
type AIAgentConfig
|
||||
} from '../agentResourceUtils'
|
||||
import { agentArgsToTransforms } from '../linkedAgentDrafts'
|
||||
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
|
||||
import {
|
||||
AGENT_EDITOR_RUN_INPUTS,
|
||||
AGENT_TOOLS_ROW,
|
||||
DEFAULT_AGENT_MEMORY,
|
||||
keepsManagedMemory
|
||||
} from '../agentFormFields'
|
||||
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
|
||||
import { useAgentDraft } from '../agentDraft.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
@@ -199,6 +213,17 @@
|
||||
// apart from the echo of their own write.
|
||||
let lastArgs = $state<string | undefined>(undefined)
|
||||
|
||||
// Picked on the first load, after which it is the reader's: following the memory setting live
|
||||
// would pull them out of a chat for switching memory off in the form.
|
||||
let testMode = $state<'form' | 'chat' | undefined>(undefined)
|
||||
// Mounted the first time it is shown, then only hidden: unmounting destroys the SDK chat,
|
||||
// which stops following the turn in flight, and its answer would never show.
|
||||
let chatMounted = $state(false)
|
||||
function showTestMode(mode: 'form' | 'chat') {
|
||||
testMode = mode
|
||||
if (mode === 'chat') chatMounted = true
|
||||
}
|
||||
|
||||
// draft.args -> module. Only when the draft moved on its own (a load, an external write, a
|
||||
// discard); an edit made in the form arrives here as its own echo and is skipped.
|
||||
$effect(() => {
|
||||
@@ -217,6 +242,7 @@
|
||||
} as any
|
||||
}
|
||||
flowStore.val.value.modules = [built]
|
||||
if (testMode === undefined) showTestMode(keepsManagedMemory(args.memory) ? 'chat' : 'form')
|
||||
// Caught rather than left to float: a tool whose shape the schema loader cannot read
|
||||
// would otherwise reject into the global unhandled-rejection handler, which reports the
|
||||
// bare message and no stack — saying nothing about which agent or tool caused it. The
|
||||
@@ -294,6 +320,43 @@
|
||||
let testIsLoading = $state(false)
|
||||
let scriptProgress = $state(undefined)
|
||||
|
||||
let chatPath = $derived(agentChatPath(path))
|
||||
|
||||
/** One chat turn: the agent as edited, not as deployed, the same as a run from the form. */
|
||||
async function runChatTurn(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
inputs?: Record<string, any>
|
||||
): Promise<string | undefined> {
|
||||
if (!agentModule) return undefined
|
||||
return await runFlowPreview(
|
||||
{ ...(inputs ?? {}), user_message: userMessage },
|
||||
agentChatFlow($state.snapshot(agentModule) as FlowModule),
|
||||
chatPath,
|
||||
undefined,
|
||||
conversationId,
|
||||
undefined,
|
||||
workspace
|
||||
)
|
||||
}
|
||||
|
||||
// What the composer reads for its model button and paperclip.
|
||||
let chatModules = $derived(agentModule ? agentChatFlow(agentModule).value.modules : undefined)
|
||||
let chatGap = $derived(agentChatGap(agentValue?.input_transforms))
|
||||
|
||||
function turnOnMemory() {
|
||||
if (!agentValue) return
|
||||
agentValue.input_transforms.memory = {
|
||||
type: 'static',
|
||||
value: structuredClone(DEFAULT_AGENT_MEMORY)
|
||||
}
|
||||
}
|
||||
|
||||
function turnOnStreaming() {
|
||||
if (!agentValue) return
|
||||
agentValue.input_transforms.streaming = { type: 'static', value: true }
|
||||
}
|
||||
|
||||
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
|
||||
// tool it just created is the only place the click can land.
|
||||
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
|
||||
@@ -341,6 +404,18 @@
|
||||
export function draftHandle() {
|
||||
return draft
|
||||
}
|
||||
/** The switch between the form and the chat, which the dialog's header holds. `mode` is
|
||||
* undefined until the agent loads and picks the first one. */
|
||||
export function testPaneHandle() {
|
||||
return {
|
||||
get mode() {
|
||||
return testMode
|
||||
},
|
||||
set mode(mode: 'form' | 'chat' | undefined) {
|
||||
if (mode) showTestMode(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if draft.refusal}
|
||||
@@ -359,7 +434,7 @@
|
||||
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
|
||||
answer want opposite splits, and only the reader knows which they are on. -->
|
||||
<Splitpanes class="h-full">
|
||||
<Pane size={66} minSize={30}>
|
||||
<Pane size={55} minSize={30}>
|
||||
<div class="h-full min-h-0 overflow-auto">
|
||||
<div class="px-4 pt-4">
|
||||
<Label label="Path">
|
||||
@@ -410,37 +485,98 @@
|
||||
</PropPickerWrapper>
|
||||
</div>
|
||||
</Pane>
|
||||
<!-- Laid out as the script editor's preview column is: what a run takes above what it
|
||||
produced, both alongside what is being edited. -->
|
||||
<Pane size={34} minSize={20}>
|
||||
<Splitpanes horizontal class="h-full">
|
||||
<Pane size={40} minSize={15}>
|
||||
<div class="h-full overflow-auto">
|
||||
<ModulePreview
|
||||
mod={agentModule as FlowModule}
|
||||
schema={flowLocalAgentSchema(schema)}
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
bind:scriptProgress
|
||||
/>
|
||||
<Pane size={45} minSize={20}>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<!-- Laid out as the script editor's preview column is: what a run takes above what it
|
||||
produced, both alongside what is being edited. -->
|
||||
<div class="flex-1 min-h-0 {testMode === 'chat' ? 'hidden' : ''}">
|
||||
<Splitpanes horizontal class="h-full">
|
||||
<Pane size={40} minSize={15}>
|
||||
<div class="h-full overflow-auto">
|
||||
<ModulePreview
|
||||
mod={agentModule as FlowModule}
|
||||
schema={flowLocalAgentSchema(schema)}
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
bind:scriptProgress
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={60} minSize={20}>
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
mod={agentModule as FlowModule}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
{#if chatMounted}
|
||||
<div class={testMode === 'chat' ? 'flex flex-col flex-1 min-h-0' : 'hidden'}>
|
||||
{#if chatGap?.memory}
|
||||
<div
|
||||
class="flex-1 flex flex-col items-center justify-center gap-2 px-8 text-center"
|
||||
>
|
||||
<MessageCircleOff size={48} class="text-tertiary opacity-50 mb-2" />
|
||||
<p class="text-sm font-semibold text-emphasis">Chat needs managed memory</p>
|
||||
<p class="text-xs text-secondary max-w-xs">
|
||||
{chatGap.memoryCanTurnOn
|
||||
? 'Without it, every message would be answered without the ones before it.'
|
||||
: 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'}
|
||||
</p>
|
||||
{#if chatGap.memoryCanTurnOn && !readOnly}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
btnClasses="bg-surface mt-2"
|
||||
onClick={turnOnMemory}
|
||||
>
|
||||
Turn on managed memory
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if chatGap?.noStream}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b text-2xs text-secondary"
|
||||
>
|
||||
<Info size={12} class="shrink-0" />
|
||||
<span class="flex-1">
|
||||
{chatGap.noStream === 'image'
|
||||
? 'Image answers do not stream: each one shows once its run ends.'
|
||||
: 'Streaming is off: each answer shows once its run ends.'}
|
||||
</span>
|
||||
{#if chatGap.noStream === 'off' && !readOnly}
|
||||
<Button unifiedSize="2xs" variant="subtle" onClick={turnOnStreaming}>
|
||||
Turn on
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hidden rather than unmounted while memory is off: switching it off mid-turn must
|
||||
not end the chat following that turn. Test chats, since what runs is the agent as
|
||||
edited. -->
|
||||
<div class={chatGap?.memory ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
|
||||
<FlowChat
|
||||
onRunFlow={runChatTurn}
|
||||
path={chatPath}
|
||||
conversationKind="test"
|
||||
subject="agent"
|
||||
frame="none"
|
||||
inputSchema={AGENT_CHAT_SCHEMA}
|
||||
flowModules={chatModules}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={60} minSize={20}>
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
mod={agentModule as FlowModule}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { FlaskConical, History, Save } from 'lucide-svelte'
|
||||
import { FlaskConical, FormInput, History, MessageSquare, Save } from 'lucide-svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import { resource } from 'runed'
|
||||
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
|
||||
@@ -93,6 +95,7 @@
|
||||
})
|
||||
|
||||
let draft = $derived(host?.draftHandle())
|
||||
let testPane = $derived(host?.testPaneHandle())
|
||||
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
|
||||
// the dialog carries only the refusal the host renders.
|
||||
let refused = $derived(draft?.refusal != null)
|
||||
@@ -268,6 +271,38 @@
|
||||
{#snippet settings()}
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
{#if !inEvals && !refused}
|
||||
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
|
||||
loaded and the pane has picked its first mode. -->
|
||||
{#if testPane?.mode}
|
||||
<ToggleButtonGroup
|
||||
bind:selected={
|
||||
() => testPane?.mode,
|
||||
(mode) => {
|
||||
if (testPane) testPane.mode = mode
|
||||
}
|
||||
}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="chat"
|
||||
label="Chat"
|
||||
icon={MessageSquare}
|
||||
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="form"
|
||||
label="Form"
|
||||
icon={FormInput}
|
||||
tooltip="Run the agent once, on the inputs in a form"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{#if readOnly}
|
||||
<Badge
|
||||
color="gray"
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
* users have no test chats to look at.
|
||||
*/
|
||||
conversationKind?: 'test' | 'deployed'
|
||||
/** What a message runs, as the chat names it. An agent has no deployed chats, so its
|
||||
* sidebar offers no filter between those and the test ones. */
|
||||
subject?: 'flow' | 'agent'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -62,7 +65,8 @@
|
||||
description = undefined,
|
||||
wideLayout = false,
|
||||
frame = 'top',
|
||||
conversationKind = 'deployed'
|
||||
conversationKind = 'deployed',
|
||||
subject = 'flow'
|
||||
}: Props = $props()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -87,13 +91,13 @@
|
||||
pollDelayMs: $enterpriseLicense ? 50 : undefined,
|
||||
run: async ({ user_message, ...inputs }, { conversationId }) => {
|
||||
const jobId = await onRunFlow(String(user_message), conversationId, inputs)
|
||||
if (!jobId) throw new Error('the flow did not start')
|
||||
if (!jobId) throw new Error(`the ${subject} did not start`)
|
||||
// The server creates the conversation with the run, so the sidebar can list
|
||||
// it now, whatever becomes of the turn.
|
||||
sidebar?.conversationStarted(conversationId)
|
||||
return jobId
|
||||
},
|
||||
onError: (error) => sendUserToast('Failed to run flow: ' + error.message, true)
|
||||
onError: (error) => sendUserToast(`Failed to run ${subject}: ${error.message}`, true)
|
||||
})
|
||||
const unsubscribe = created.subscribe((s) => (chatState = s))
|
||||
chat = created
|
||||
@@ -126,7 +130,7 @@
|
||||
{chat}
|
||||
{chatState}
|
||||
defaultKind={conversationKind}
|
||||
canFilterKind={conversationKind !== 'deployed'}
|
||||
canFilterKind={conversationKind !== 'deployed' && subject === 'flow'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
|
||||
@@ -147,6 +151,7 @@
|
||||
{description}
|
||||
{wideLayout}
|
||||
{conversationKind}
|
||||
{subject}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
wideLayout?: boolean
|
||||
/** What this surface's runs create: previews in the editor, deployed runs on the flow page. */
|
||||
conversationKind?: 'test' | 'deployed'
|
||||
/** What a message runs, as the composer names it. */
|
||||
subject?: 'flow' | 'agent'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -56,7 +58,8 @@
|
||||
workspace = undefined,
|
||||
description = undefined,
|
||||
wideLayout = false,
|
||||
conversationKind = 'deployed'
|
||||
conversationKind = 'deployed',
|
||||
subject = 'flow'
|
||||
}: Props = $props()
|
||||
|
||||
// Derive helperScript for dynamic inputs from schema
|
||||
@@ -91,7 +94,7 @@
|
||||
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 modelGap = $derived(agentModelGap(modelWiring, subject))
|
||||
const showModelButton = $derived(showsModelButton(modelWiring))
|
||||
|
||||
// LocalStorage helpers
|
||||
@@ -273,7 +276,7 @@
|
||||
{:else}
|
||||
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg font-medium">Start a conversation</p>
|
||||
<p class="text-sm">Send a message to run the flow and see the results</p>
|
||||
<p class="text-sm">Send a message to run the {subject} and see the results</p>
|
||||
{#if !emptyString(description)}
|
||||
<div class="mt-6 pt-4 border-t max-w-md text-left text-xs text-tertiary">
|
||||
<GfmMarkdown md={description ?? ''} noPadding prose="sm" />
|
||||
@@ -336,7 +339,7 @@
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
|
||||
placeholder="Send a message to run the flow"
|
||||
placeholder="Send a message to run the {subject}"
|
||||
disabled={deploymentInProgress || !!modelGap || !!wrongKindReason}
|
||||
disabledMessage={deploymentInProgress
|
||||
? 'Deployment in progress'
|
||||
|
||||
@@ -242,15 +242,19 @@ export function resolveAgentModelWiring(
|
||||
* 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 {
|
||||
export function agentModelGap(
|
||||
wiring: AgentModelWiring | undefined,
|
||||
subject: 'flow' | 'agent' = 'flow'
|
||||
): string | undefined {
|
||||
// No agent, several of them, or an expression we cannot read: not ours to judge.
|
||||
if (!wiring || wiring.whole) return undefined
|
||||
// Asked of each agent rather than of what they agree on: agents that merely disagree
|
||||
// about the model all have one, and the message would be false — while an agent with
|
||||
// an empty model still cannot run, however well the others are configured.
|
||||
return wiring.someAgentCannotRun
|
||||
? 'Pick a provider and model on the AI agent step to use this chat.'
|
||||
: undefined
|
||||
if (!wiring.someAgentCannotRun) return undefined
|
||||
return subject === 'agent'
|
||||
? 'Pick a provider and model for this agent to use this chat.'
|
||||
: 'Pick a provider and model on the AI agent step to use this chat.'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { agentChatFlow, agentChatGap, agentChatPath } from './agentEditorChat'
|
||||
|
||||
describe('agentChatPath', () => {
|
||||
it('is a path no flow can take, so a same-path flow never shares its conversations', () => {
|
||||
const flowPath = /^[ufg](\/[\w-]+){2,}$/
|
||||
expect('f/support/agent').toMatch(flowPath)
|
||||
expect(agentChatPath('f/support/agent')).not.toMatch(flowPath)
|
||||
})
|
||||
})
|
||||
|
||||
function agent(inputTransforms: Record<string, any>): FlowModule {
|
||||
return {
|
||||
id: '__wm_agent_root',
|
||||
value: { type: 'aiagent', tools: [{ id: 't' }], input_transforms: inputTransforms }
|
||||
} as unknown as FlowModule
|
||||
}
|
||||
|
||||
describe('agentChatFlow', () => {
|
||||
it('runs the agent in chat mode on the message and files, never on a step memory id', () => {
|
||||
const flow = agentChatFlow(
|
||||
agent({
|
||||
system_prompt: { type: 'static', value: 'Be brief' },
|
||||
user_message: { type: 'static', value: 'a default' },
|
||||
memory_id: { type: 'static' },
|
||||
previous_messages: { type: 'static' }
|
||||
})
|
||||
)
|
||||
expect(flow.value.chat_input_enabled).toBe(true)
|
||||
const step = flow.value.modules[0].value as any
|
||||
expect(step.tools).toEqual([{ id: 't' }])
|
||||
expect(step.input_transforms).toEqual({
|
||||
system_prompt: { type: 'static', value: 'Be brief' },
|
||||
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
|
||||
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentChatGap', () => {
|
||||
it('is nothing to fix with managed memory and streaming left at its default', () => {
|
||||
expect(
|
||||
agentChatGap({ memory: { type: 'static', value: { kind: 'window', context_length: 10 } } })
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads an agent with no memory setting as off, and offers to turn it on', () => {
|
||||
expect(agentChatGap({})).toEqual({ memory: true, memoryCanTurnOn: true, noStream: undefined })
|
||||
})
|
||||
|
||||
it('flags memory off and streaming off', () => {
|
||||
expect(
|
||||
agentChatGap({
|
||||
memory: { type: 'static', value: { kind: 'off' } },
|
||||
streaming: { type: 'static', value: false }
|
||||
})
|
||||
).toEqual({ memory: true, memoryCanTurnOn: true, noStream: 'off' })
|
||||
})
|
||||
|
||||
it('does not offer to replace a legacy manual memory', () => {
|
||||
expect(
|
||||
agentChatGap({ memory: { type: 'static', value: { kind: 'manual', messages: [] } } })
|
||||
).toEqual({ memory: true, memoryCanTurnOn: false, noStream: undefined })
|
||||
})
|
||||
|
||||
it('flags an image answer as never streaming, whatever the setting', () => {
|
||||
expect(
|
||||
agentChatGap({
|
||||
memory: { type: 'static', value: { kind: 'window', context_length: 10 } },
|
||||
output_type: { type: 'static', value: 'image' },
|
||||
streaming: { type: 'static', value: true }
|
||||
})
|
||||
).toEqual({ memory: false, memoryCanTurnOn: false, noStream: 'image' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AiAgent, FlowModule, InputTransform, OpenFlow } from '$lib/gen'
|
||||
import { AGENT_FLOW_LOCAL_KEYS } from '../agentResourceUtils'
|
||||
import { agentMemoryMode, keepsManagedMemory } from '../agentFormFields'
|
||||
|
||||
/**
|
||||
* What an agent's chat runs, and files its conversations, under. Conversations are keyed by path
|
||||
* alone, and a flow may share the agent's, so the suffix is one no flow path can carry (its
|
||||
* segments are `[\w-]`): otherwise each would list the other's chats, and continuing one would run
|
||||
* a step that never saw its memory. The first two segments, which the server checks a preview
|
||||
* run's path against, stay the agent's.
|
||||
*/
|
||||
export function agentChatPath(agentPath: string): string {
|
||||
return `${agentPath}.chat`
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs of the flow a saved agent is chatted with: the message, and the files the composer's
|
||||
* paperclip uploads. `user_message` is the name the server requires of a chat-mode run.
|
||||
*/
|
||||
export const AGENT_CHAT_SCHEMA = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_message: { type: 'string', description: 'Message from user' },
|
||||
user_attachments: {
|
||||
type: 'array',
|
||||
items: { type: 'object', resourceType: 's3object' },
|
||||
description: 'Images or PDFs for the agent to read'
|
||||
}
|
||||
},
|
||||
required: ['user_message']
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat-mode flow one turn of the agent editor's chat runs: the agent as edited, alone, reading
|
||||
* the message and files from the chat. Every other flow-local input is dropped, `memory_id` above
|
||||
* all: a step memory id, even an empty one, would replace the conversation's and the agent would
|
||||
* not remember the turns before.
|
||||
*/
|
||||
export function agentChatFlow(agent: FlowModule): OpenFlow {
|
||||
const value = agent.value as AiAgent
|
||||
const brain = Object.fromEntries(
|
||||
Object.entries(value.input_transforms ?? {}).filter(
|
||||
([key]) => !(AGENT_FLOW_LOCAL_KEYS as readonly string[]).includes(key)
|
||||
)
|
||||
)
|
||||
return {
|
||||
summary: '',
|
||||
value: {
|
||||
chat_input_enabled: true,
|
||||
modules: [
|
||||
{
|
||||
id: agent.id,
|
||||
value: {
|
||||
...value,
|
||||
input_transforms: {
|
||||
...brain,
|
||||
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
|
||||
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
|
||||
} as AiAgent['input_transforms']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
schema: AGENT_CHAT_SCHEMA
|
||||
}
|
||||
}
|
||||
|
||||
/** What the agent as configured takes away from a chat. */
|
||||
export type AgentChatGap = {
|
||||
/** No managed memory: every message would be answered without the ones before it, so the
|
||||
* chat is not offered at all. */
|
||||
memory: boolean
|
||||
/** Whether turning memory on is a plain switch, rather than replacing an older manual list. */
|
||||
memoryCanTurnOn: boolean
|
||||
/** Why a turn shows nothing until its run ends: streaming switched off, or an image answer,
|
||||
* which the worker never streams whatever the setting. */
|
||||
noStream: 'off' | 'image' | undefined
|
||||
}
|
||||
|
||||
function staticValue(transform: InputTransform | undefined): unknown {
|
||||
return transform?.type === 'static' ? transform.value : undefined
|
||||
}
|
||||
|
||||
export function agentChatGap(
|
||||
transforms: Record<string, InputTransform> | undefined
|
||||
): AgentChatGap | undefined {
|
||||
const memory = staticValue(transforms?.['memory'])
|
||||
const gap: AgentChatGap = {
|
||||
memory: !keepsManagedMemory(memory),
|
||||
memoryCanTurnOn: agentMemoryMode(memory) === 'off',
|
||||
noStream:
|
||||
staticValue(transforms?.['output_type']) === 'image'
|
||||
? 'image'
|
||||
: // Unset streams: it is on by default.
|
||||
staticValue(transforms?.['streaming']) === false
|
||||
? 'off'
|
||||
: undefined
|
||||
}
|
||||
return gap.memory || gap.noStream ? gap : undefined
|
||||
}
|
||||
Reference in New Issue
Block a user