mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
feat: add yolo mode for ai chat tools (#9258)
* feat: add yolo mode for ai chat tools * nit * fix: align chat footer controls * feat: add ai chat autonomy modes * feat: add autonomy mode dropdown * fix: highlight yolo autonomy icon * fix: auto accept flow edits * fix: hide unsupported autonomy modes * fix: handle auto-accept flow editor races
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
import AvailableContextList from './AvailableContextList.svelte'
|
||||
import { type Snippet } from 'svelte'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ChevronDown,
|
||||
ChevronsRight,
|
||||
CheckIcon,
|
||||
HistoryIcon,
|
||||
Hourglass,
|
||||
@@ -23,16 +26,43 @@
|
||||
import ProviderModelSelector from './ProviderModelSelector.svelte'
|
||||
import ChatMode from './ChatMode.svelte'
|
||||
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
import { getModifierKey } from '$lib/utils'
|
||||
import type { SelectedContext } from './app/core'
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const aiChatManager = getAiChatManager()
|
||||
type AutonomyModeOption = { label: string; mode: AIAutonomyMode }
|
||||
const autonomyModeOptions: AutonomyModeOption[] = [
|
||||
{ label: 'auto accept off', mode: AIAutonomyMode.DEFAULT },
|
||||
{ label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT },
|
||||
{ label: 'yolo on', mode: AIAutonomyMode.YOLO }
|
||||
]
|
||||
const autonomyModeLabel = (
|
||||
mode: AIAutonomyMode,
|
||||
options: AutonomyModeOption[] = autonomyModeOptions
|
||||
) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label
|
||||
const isAutonomyModeAvailable = (
|
||||
mode: AIAutonomyMode,
|
||||
autoAcceptEditsAvailable: boolean,
|
||||
autoAcceptToolConfirmationsAvailable: boolean
|
||||
) => {
|
||||
switch (mode) {
|
||||
case AIAutonomyMode.DEFAULT:
|
||||
return true
|
||||
case AIAutonomyMode.ACCEPT_EDIT:
|
||||
return autoAcceptEditsAvailable
|
||||
case AIAutonomyMode.YOLO:
|
||||
return autoAcceptToolConfirmationsAvailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let {
|
||||
messages,
|
||||
@@ -179,6 +209,37 @@
|
||||
aiChatManager.mode === AIMode.GLOBAL ||
|
||||
aiChatManager.mode === AIMode.APP
|
||||
)
|
||||
const availableAutonomyModeOptions = $derived.by(() =>
|
||||
autonomyModeOptions.filter((option) =>
|
||||
isAutonomyModeAvailable(
|
||||
option.mode,
|
||||
aiChatManager.autoAcceptEditsAvailable,
|
||||
aiChatManager.autoAcceptToolConfirmationsAvailable
|
||||
)
|
||||
)
|
||||
)
|
||||
const effectiveAutonomyMode = $derived(
|
||||
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
: AIAutonomyMode.DEFAULT
|
||||
)
|
||||
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
|
||||
const autonomyModeTooltip = $derived.by(() => {
|
||||
switch (effectiveAutonomyMode) {
|
||||
case AIAutonomyMode.ACCEPT_EDIT:
|
||||
return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.'
|
||||
case AIAutonomyMode.YOLO:
|
||||
if (!aiChatManager.autoAcceptEditsAvailable) {
|
||||
return 'Automatically accepts tool confirmations.'
|
||||
}
|
||||
return 'Automatically accepts script and flow edits plus tool confirmations.'
|
||||
default:
|
||||
if (!aiChatManager.autoAcceptEditsAvailable) {
|
||||
return 'Requires confirmation for tool calls.'
|
||||
}
|
||||
return 'Requires confirmation for edits and tool calls.'
|
||||
}
|
||||
})
|
||||
|
||||
// "Waiting for user" detection — when the latest tool message is staged
|
||||
// for confirmation or has an unanswered askUserQuestion, the AI loop is
|
||||
@@ -209,6 +270,29 @@
|
||||
}
|
||||
return aiChatManager.appAiChatHelpers.getSelectedContext()
|
||||
})
|
||||
|
||||
const yoloBypassedTools = $derived.by(() => {
|
||||
return aiChatManager.tools
|
||||
.filter((tool) => tool.requiresConfirmation === true)
|
||||
.map((tool) => ({
|
||||
name: tool.def.function.name,
|
||||
label: tool.confirmationMessage ?? tool.def.function.name
|
||||
}))
|
||||
})
|
||||
const visibleYoloBypassedTools = $derived(yoloBypassedTools.slice(0, MAX_YOLO_TOOLTIP_TOOLS))
|
||||
const hiddenYoloBypassedToolCount = $derived(
|
||||
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
|
||||
)
|
||||
const showFlowPendingActionControls = $derived(
|
||||
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
|
||||
!aiChatManager.autoAcceptEditsActive
|
||||
)
|
||||
const showFooterLeftControls = $derived(
|
||||
!disabled &&
|
||||
(showContextPicker ||
|
||||
showAutonomyModeSelector ||
|
||||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
@@ -322,7 +406,7 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'sticky z-10 mt-2 ml-2 self-start pointer-events-none',
|
||||
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-14' : 'bottom-2'
|
||||
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
|
||||
)}
|
||||
>
|
||||
{#if waitingForUserAction}
|
||||
@@ -345,7 +429,7 @@
|
||||
transition:fade={{ duration: 120 }}
|
||||
class={twMerge(
|
||||
'absolute left-1/2 -translate-x-1/2 z-10 rounded-md bg-surface shadow-md',
|
||||
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-12' : 'bottom-2'
|
||||
showFlowPendingActionControls ? 'bottom-12' : 'bottom-2'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
@@ -370,7 +454,7 @@
|
||||
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
|
||||
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
|
||||
>
|
||||
{#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
|
||||
{#if showFlowPendingActionControls}
|
||||
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
|
||||
<Button
|
||||
startIcon={{ icon: CheckIcon }}
|
||||
@@ -409,49 +493,136 @@
|
||||
{disabled}
|
||||
isFirstMessage={messages.length === 0}
|
||||
/>
|
||||
<div class="flex flex-row justify-between items-center gap-x-1.5">
|
||||
<div class="flex flex-row items-center gap-x-1.5">
|
||||
{#if showContextPicker && !disabled}
|
||||
<Popover>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
|
||||
title="Add context"
|
||||
>
|
||||
@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
<AppAvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<AvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if aiChatManager.mode === 'script' && hasDiff}
|
||||
<ChatQuickActions {askAi} {diffMode} />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center gap-x-1.5"
|
||||
class:justify-between={showFooterLeftControls}
|
||||
class:justify-end={!showFooterLeftControls}
|
||||
>
|
||||
{#if showFooterLeftControls}
|
||||
<div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap">
|
||||
{#if showContextPicker && !disabled}
|
||||
<Popover>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
|
||||
title="Add context"
|
||||
>
|
||||
@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
<AppAvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<AvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
void aiChatInput?.addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if showAutonomyModeSelector}
|
||||
<div class="min-w-0">
|
||||
<Popover class="max-w-full">
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
|
||||
title={autonomyModeTooltip}
|
||||
>
|
||||
<ChevronsRight
|
||||
size={13}
|
||||
class={twMerge(
|
||||
'shrink-0',
|
||||
effectiveAutonomyMode === AIAutonomyMode.YOLO
|
||||
? 'text-red-500'
|
||||
: 'text-accent'
|
||||
)}
|
||||
/>
|
||||
<span class="truncate"
|
||||
>{autonomyModeLabel(
|
||||
effectiveAutonomyMode,
|
||||
availableAutonomyModeOptions
|
||||
)}</span
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="flex flex-col gap-1 p-1 min-w-32">
|
||||
{#each availableAutonomyModeOptions as option (option.mode)}
|
||||
<button
|
||||
class={twMerge(
|
||||
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
|
||||
effectiveAutonomyMode === option.mode && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={() => {
|
||||
aiChatManager.setAutonomyMode(option.mode)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
|
||||
<Tooltip small placement="top">
|
||||
<AlertTriangle class="w-3 h-3 text-red-500" />
|
||||
{#snippet text()}
|
||||
<div class="max-w-64 text-xs">
|
||||
<p class="font-semibold">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
? 'Yolo auto-accepts edits and tool usage.'
|
||||
: 'Yolo auto-accepts tool usage.'}
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
? 'This can result in edits being applied or tools being called without user confirmation.'
|
||||
: 'This can result in tools being called without user confirmation.'}
|
||||
</p>
|
||||
{#if yoloBypassedTools.length > 0}
|
||||
<p class="mt-2 font-semibold">Bypassed in current mode:</p>
|
||||
<ul class="mt-1 list-disc pl-4 space-y-0.5">
|
||||
{#each visibleYoloBypassedTools as tool (tool.name)}
|
||||
<li class="break-words">{tool.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenYoloBypassedToolCount > 0}
|
||||
<p class="mt-1">+ {hiddenYoloBypassedToolCount} more</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="mt-2">No tools in the current mode require confirmation.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
|
||||
<ChatQuickActions {askAi} {diffMode} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if disabled}
|
||||
<div class="text-primary text-xs my-2 px-2">
|
||||
<Markdown md={disabledMessage} />
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
try {
|
||||
const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection)
|
||||
if (reply) {
|
||||
aiChatManager.scriptEditorApplyCode?.(reply)
|
||||
await aiChatManager.applyScriptEditorCode(reply)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Inline AI request failed:', error)
|
||||
|
||||
@@ -43,6 +43,7 @@ import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState
|
||||
import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { untrack } from 'svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
|
||||
@@ -66,6 +67,8 @@ import { isGlobalAiEnabled } from './global/gate'
|
||||
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
|
||||
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
|
||||
const MAX_TOKENS_HARD_LIMIT = 5000
|
||||
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
|
||||
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
|
||||
|
||||
export enum AIMode {
|
||||
SCRIPT = 'script',
|
||||
@@ -77,12 +80,38 @@ export enum AIMode {
|
||||
ASK = 'ask'
|
||||
}
|
||||
|
||||
export enum AIAutonomyMode {
|
||||
DEFAULT = 'default',
|
||||
ACCEPT_EDIT = 'acceptedit',
|
||||
YOLO = 'yolo'
|
||||
}
|
||||
|
||||
const ALL_AI_MODES = Object.values(AIMode)
|
||||
const ALL_AI_AUTONOMY_MODES = Object.values(AIAutonomyMode)
|
||||
const AUTO_ACCEPT_EDIT_MODES = new Set<AIMode>([AIMode.SCRIPT, AIMode.FLOW])
|
||||
const AUTO_ACCEPT_TOOL_CONFIRMATION_MODES = new Set<AIMode>([
|
||||
AIMode.SCRIPT,
|
||||
AIMode.FLOW,
|
||||
AIMode.APP,
|
||||
AIMode.GLOBAL
|
||||
])
|
||||
|
||||
export function isAIMode(mode: unknown): mode is AIMode {
|
||||
return ALL_AI_MODES.includes(mode as AIMode)
|
||||
}
|
||||
|
||||
export function isAIAutonomyMode(mode: unknown): mode is AIAutonomyMode {
|
||||
return ALL_AI_AUTONOMY_MODES.includes(mode as AIAutonomyMode)
|
||||
}
|
||||
|
||||
export function supportsAutoAcceptEdits(mode: AIMode): boolean {
|
||||
return AUTO_ACCEPT_EDIT_MODES.has(mode)
|
||||
}
|
||||
|
||||
export function supportsAutoAcceptToolConfirmations(mode: AIMode): boolean {
|
||||
return AUTO_ACCEPT_TOOL_CONFIRMATION_MODES.has(mode)
|
||||
}
|
||||
|
||||
export function isAIModeVisible(mode: AIMode): boolean {
|
||||
return mode !== AIMode.GLOBAL || isGlobalAiEnabled()
|
||||
}
|
||||
@@ -95,6 +124,26 @@ function isWorkspacePath(path: string | undefined): path is string {
|
||||
return path?.startsWith('f/') === true || path?.startsWith('u/') === true
|
||||
}
|
||||
|
||||
function getPersistedAutonomyMode(): AIAutonomyMode {
|
||||
if (!BROWSER || typeof localStorage === 'undefined') {
|
||||
return AIAutonomyMode.DEFAULT
|
||||
}
|
||||
const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY)
|
||||
if (isAIAutonomyMode(persistedMode)) {
|
||||
return persistedMode
|
||||
}
|
||||
return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true'
|
||||
? AIAutonomyMode.YOLO
|
||||
: AIAutonomyMode.DEFAULT
|
||||
}
|
||||
|
||||
function persistAutonomyMode(mode: AIAutonomyMode) {
|
||||
if (!BROWSER || typeof localStorage === 'undefined') {
|
||||
return
|
||||
}
|
||||
localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode)
|
||||
}
|
||||
|
||||
export class AIChatManager {
|
||||
contextManager = new ContextManager()
|
||||
historyManager = new HistoryManager()
|
||||
@@ -112,6 +161,17 @@ export class AIChatManager {
|
||||
currentReply = $state<string>('')
|
||||
displayMessages = $state<DisplayMessage[]>([])
|
||||
messages = $state<ChatCompletionMessageParam[]>([])
|
||||
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
|
||||
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
|
||||
autoAcceptEditsActive = $derived(
|
||||
this.autoAcceptEditsAvailable &&
|
||||
(this.autonomyMode === AIAutonomyMode.ACCEPT_EDIT ||
|
||||
this.autonomyMode === AIAutonomyMode.YOLO)
|
||||
)
|
||||
autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode))
|
||||
autoAcceptToolConfirmationsActive = $derived(
|
||||
this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable
|
||||
)
|
||||
#automaticScroll = $state<boolean>(true)
|
||||
systemMessage = $state<ChatCompletionSystemMessageParam>({
|
||||
role: 'system',
|
||||
@@ -122,9 +182,9 @@ export class AIChatManager {
|
||||
|
||||
scriptEditorOptions = $state<ScriptOptions | undefined>(undefined)
|
||||
flowOptions = $state<FlowOptions | undefined>(undefined)
|
||||
scriptEditorApplyCode = $state<((code: string, opts?: ReviewChangesOpts) => void) | undefined>(
|
||||
undefined
|
||||
)
|
||||
scriptEditorApplyCode = $state<
|
||||
((code: string, opts?: ReviewChangesOpts) => void | Promise<void>) | undefined
|
||||
>(undefined)
|
||||
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
|
||||
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
|
||||
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
|
||||
@@ -141,7 +201,7 @@ export class AIChatManager {
|
||||
/** Cached datatables for app context (fetched asynchronously) */
|
||||
cachedDatatables = $state<AppDatatableElement[]>([])
|
||||
|
||||
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
|
||||
private confirmationCallbacks = new Map<string, (value: boolean) => void>()
|
||||
private userQuestionCallbacks = new Map<string, (choice: string | undefined) => void>()
|
||||
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
@@ -215,20 +275,65 @@ export class AIChatManager {
|
||||
|
||||
// Request confirmation from user for a tool call
|
||||
requestConfirmation = (toolId: string): Promise<boolean> => {
|
||||
if (this.autoAcceptToolConfirmationsActive) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Store the callback for this specific tool
|
||||
this.confirmationCallback = resolve
|
||||
this.confirmationCallbacks.set(toolId, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle confirmation response for a specific tool
|
||||
handleToolConfirmation = (toolId: string, confirmed: boolean) => {
|
||||
if (this.confirmationCallback) {
|
||||
this.confirmationCallback(confirmed)
|
||||
this.confirmationCallback = undefined
|
||||
const confirmationCallback = this.confirmationCallbacks.get(toolId)
|
||||
if (confirmationCallback) {
|
||||
confirmationCallback(confirmed)
|
||||
this.confirmationCallbacks.delete(toolId)
|
||||
}
|
||||
}
|
||||
|
||||
private acceptPendingToolConfirmations = () => {
|
||||
for (const confirmationCallback of this.confirmationCallbacks.values()) {
|
||||
confirmationCallback(true)
|
||||
}
|
||||
this.confirmationCallbacks.clear()
|
||||
}
|
||||
|
||||
private acceptPendingFlowEdits = (flowHelpers = this.flowAiChatHelpers) => {
|
||||
if (flowHelpers?.hasPendingChanges()) {
|
||||
flowHelpers.acceptAllModuleActions()
|
||||
}
|
||||
}
|
||||
|
||||
setAutonomyMode = (mode: AIAutonomyMode) => {
|
||||
this.autonomyMode = mode
|
||||
persistAutonomyMode(mode)
|
||||
|
||||
if (this.autoAcceptToolConfirmationsActive) {
|
||||
this.acceptPendingToolConfirmations()
|
||||
}
|
||||
if (this.autoAcceptEditsActive) {
|
||||
this.acceptPendingFlowEdits()
|
||||
}
|
||||
}
|
||||
|
||||
setAutoAcceptToolConfirmations = (enabled: boolean) => {
|
||||
this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT)
|
||||
}
|
||||
|
||||
applyScriptEditorCode = async (code: string, opts?: ReviewChangesOpts) => {
|
||||
if (this.autoAcceptEditsActive && opts?.mode === 'revert') {
|
||||
return
|
||||
}
|
||||
|
||||
const effectiveOpts =
|
||||
this.autoAcceptEditsActive && (opts?.mode ?? 'apply') === 'apply'
|
||||
? ({ ...opts, mode: 'apply', applyAll: true } satisfies ReviewChangesOpts)
|
||||
: opts
|
||||
await this.scriptEditorApplyCode?.(code, effectiveOpts)
|
||||
}
|
||||
|
||||
requestUserQuestion = (
|
||||
toolId: string,
|
||||
_question: { question: string; choices: string[] }
|
||||
@@ -346,7 +451,7 @@ export class AIChatManager {
|
||||
},
|
||||
getWorkspaceMutationTarget: this.getScriptWorkspaceMutationTarget,
|
||||
applyCode: (code: string, opts?: ReviewChangesOpts) => {
|
||||
this.scriptEditorApplyCode?.(code, opts)
|
||||
return this.applyScriptEditorCode(code, opts)
|
||||
},
|
||||
getLintErrors: () => {
|
||||
if (this.scriptEditorGetLintErrors) {
|
||||
@@ -874,6 +979,7 @@ export class AIChatManager {
|
||||
}
|
||||
},
|
||||
requestConfirmation: this.requestConfirmation,
|
||||
shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive,
|
||||
requestUserQuestion: this.requestUserQuestion
|
||||
}
|
||||
}
|
||||
@@ -886,6 +992,9 @@ export class AIChatManager {
|
||||
...params
|
||||
})
|
||||
this.messages = [...this.messages, ...(addedMessages ?? [])]
|
||||
if (this.autoAcceptEditsActive) {
|
||||
this.acceptPendingFlowEdits()
|
||||
}
|
||||
await this.historyManager.saveChat(this.displayMessages, this.messages)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -901,10 +1010,10 @@ export class AIChatManager {
|
||||
}
|
||||
|
||||
cancel = (reason?: string) => {
|
||||
if (this.confirmationCallback) {
|
||||
this.confirmationCallback(false)
|
||||
this.confirmationCallback = undefined
|
||||
for (const confirmationCallback of this.confirmationCallbacks.values()) {
|
||||
confirmationCallback(false)
|
||||
}
|
||||
this.confirmationCallbacks.clear()
|
||||
for (const resolveQuestion of this.userQuestionCallbacks.values()) {
|
||||
resolveQuestion(undefined)
|
||||
}
|
||||
@@ -1060,10 +1169,10 @@ export class AIChatManager {
|
||||
|
||||
listenForCurrentEditorChanges = (currentEditor: CurrentEditor) => {
|
||||
if (currentEditor && currentEditor.type === 'script') {
|
||||
this.scriptEditorApplyCode = (code) => {
|
||||
this.scriptEditorApplyCode = async (code, opts) => {
|
||||
if (currentEditor && currentEditor.type === 'script') {
|
||||
currentEditor.hideDiffMode()
|
||||
currentEditor.editor.reviewAndApplyCode(code)
|
||||
await currentEditor.editor.reviewAndApplyCode(code, opts)
|
||||
}
|
||||
}
|
||||
this.scriptEditorShowDiffMode = () => {
|
||||
@@ -1164,6 +1273,11 @@ export class AIChatManager {
|
||||
|
||||
setFlowHelpers = (flowHelpers: FlowAIChatHelpers) => {
|
||||
this.flowAiChatHelpers = flowHelpers
|
||||
untrack(() => {
|
||||
if (this.autoAcceptEditsActive) {
|
||||
this.acceptPendingFlowEdits(flowHelpers)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
this.flowAiChatHelpers = undefined
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FlowAIChatHelpers } from './flow/core'
|
||||
import type { CurrentEditor } from '$lib/components/flows/types'
|
||||
import type { ReviewChangesOpts } from './monaco-adapter'
|
||||
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
Selection: class Selection {}
|
||||
}))
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
WorkspaceService: {},
|
||||
ScriptService: {},
|
||||
FlowService: {},
|
||||
JobService: {}
|
||||
}))
|
||||
|
||||
vi.mock('$lib/stores', () => ({
|
||||
workspaceStore: { subscribe: () => () => undefined }
|
||||
}))
|
||||
|
||||
vi.mock('$lib/toast', () => ({
|
||||
sendUserToast: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('$lib/aiStore', () => ({
|
||||
getCurrentModel: () => undefined,
|
||||
tryGetCurrentModel: () => undefined,
|
||||
getCombinedCustomPrompt: () => ''
|
||||
}))
|
||||
|
||||
vi.mock('../lib', () => ({
|
||||
getModelContextWindow: () => 128000,
|
||||
workspaceAIClients: { subscribe: () => () => undefined }
|
||||
}))
|
||||
|
||||
vi.mock('./api/apiTools', () => ({
|
||||
loadApiTools: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./chatLoop', () => ({
|
||||
runChatLoop: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./global/gate', () => ({
|
||||
isGlobalAiEnabled: () => true
|
||||
}))
|
||||
|
||||
function createFlowHelpers({
|
||||
hasPendingChanges,
|
||||
acceptAllModuleActions
|
||||
}: {
|
||||
hasPendingChanges: () => boolean
|
||||
acceptAllModuleActions: () => void
|
||||
}): FlowAIChatHelpers {
|
||||
return {
|
||||
getFlowAndSelectedId: vi.fn(),
|
||||
getRootModules: vi.fn(),
|
||||
inlineScriptSession: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
|
||||
setSnapshot: vi.fn(),
|
||||
revertToSnapshot: vi.fn(),
|
||||
setCode: vi.fn(),
|
||||
setFlowJson: vi.fn(),
|
||||
getFlowInputsSchema: vi.fn(),
|
||||
updateExprsToSet: vi.fn(),
|
||||
acceptAllModuleActions,
|
||||
rejectAllModuleActions: vi.fn(),
|
||||
hasPendingChanges,
|
||||
selectStep: vi.fn(),
|
||||
testFlow: vi.fn(),
|
||||
getLintErrors: vi.fn()
|
||||
} as unknown as FlowAIChatHelpers
|
||||
}
|
||||
|
||||
describe('AIChatManager autonomy mode', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('accepts pending flow edits when auto-accept is enabled from script mode', async () => {
|
||||
const manager = new AIChatManager()
|
||||
const acceptAllModuleActions = vi.fn()
|
||||
|
||||
manager.mode = AIMode.SCRIPT
|
||||
manager.setFlowHelpers(
|
||||
createFlowHelpers({
|
||||
hasPendingChanges: () => true,
|
||||
acceptAllModuleActions
|
||||
})
|
||||
)
|
||||
|
||||
manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT)
|
||||
|
||||
expect(acceptAllModuleActions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts pending flow edits when helpers register while auto-accept is already enabled', async () => {
|
||||
const manager = new AIChatManager()
|
||||
const acceptAllModuleActions = vi.fn()
|
||||
|
||||
manager.mode = AIMode.SCRIPT
|
||||
manager.setAutonomyMode(AIAutonomyMode.ACCEPT_EDIT)
|
||||
manager.setFlowHelpers(
|
||||
createFlowHelpers({
|
||||
hasPendingChanges: () => true,
|
||||
acceptAllModuleActions
|
||||
})
|
||||
)
|
||||
|
||||
expect(acceptAllModuleActions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for flow step editor review before resolving applyScriptEditorCode', async () => {
|
||||
const manager = new AIChatManager()
|
||||
let finishReview: (() => void) | undefined
|
||||
const reviewPromise = new Promise<void>((resolve) => {
|
||||
finishReview = resolve
|
||||
})
|
||||
const hideDiffMode = vi.fn()
|
||||
const reviewAndApplyCode = vi.fn(() => reviewPromise)
|
||||
const opts = { mode: 'apply' } satisfies ReviewChangesOpts
|
||||
|
||||
manager.listenForCurrentEditorChanges({
|
||||
type: 'script',
|
||||
stepId: 'step-a',
|
||||
editor: {
|
||||
reviewAndApplyCode,
|
||||
getLintErrors: vi.fn()
|
||||
},
|
||||
showDiffMode: vi.fn(),
|
||||
hideDiffMode,
|
||||
diffMode: false,
|
||||
lastDeployedCode: undefined
|
||||
} as unknown as CurrentEditor)
|
||||
|
||||
let applied = false
|
||||
const applyPromise = manager
|
||||
.applyScriptEditorCode('export async function main() {}', opts)
|
||||
.then(() => {
|
||||
applied = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(hideDiffMode).toHaveBeenCalledTimes(1)
|
||||
expect(reviewAndApplyCode).toHaveBeenCalledWith('export async function main() {}', opts)
|
||||
expect(applied).toBe(false)
|
||||
|
||||
finishReview?.()
|
||||
await applyPromise
|
||||
|
||||
expect(applied).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
|
||||
import type { InputTransform } from '$lib/gen'
|
||||
import type { FlowAIChatHelpers } from './core'
|
||||
@@ -30,6 +30,22 @@
|
||||
|
||||
// Get diffManager from the graph
|
||||
const diffManager = $derived(flowModuleSchemaMap?.getDiffManager())
|
||||
async function acceptPendingFlowEditsIfEnabled(waitForComputedDiff = false) {
|
||||
if (!aiChatManager.autoAcceptEditsActive || !diffManager) {
|
||||
return
|
||||
}
|
||||
|
||||
diffManager.setCurrentFlow(flowStore.val.value)
|
||||
diffManager.setCurrentInputSchema(flowStore.val.schema)
|
||||
if (waitForComputedDiff) {
|
||||
await tick()
|
||||
}
|
||||
if (diffManager.hasPendingChanges) {
|
||||
diffManager.acceptAll(flowStore)
|
||||
await tick()
|
||||
}
|
||||
}
|
||||
|
||||
const flowHelpers: FlowAIChatHelpers = {
|
||||
// flow context
|
||||
getFlowAndSelectedId: () => {
|
||||
@@ -114,6 +130,7 @@
|
||||
if ($currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === id) {
|
||||
$currentEditor.editor.setCode(code)
|
||||
}
|
||||
await acceptPendingFlowEditsIfEnabled()
|
||||
},
|
||||
getFlowInputsSchema: async () => {
|
||||
return flowStore.val.schema ?? {}
|
||||
@@ -204,6 +221,7 @@
|
||||
|
||||
// Refresh the state store to update UI
|
||||
refreshStateStore(flowStore)
|
||||
await acceptPendingFlowEditsIfEnabled(true)
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
@@ -215,6 +233,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
!aiChatManager.autoAcceptEditsActive &&
|
||||
$currentEditor?.type === 'script' &&
|
||||
selectedId &&
|
||||
diffManager?.moduleActions[selectedId]?.pending &&
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
function handleApplyCode() {
|
||||
if (code && aiChatManager.scriptEditorApplyCode) {
|
||||
aiChatManager.scriptEditorApplyCode(code, { mode: 'apply' })
|
||||
void aiChatManager.applyScriptEditorCode(code, { mode: 'apply' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -247,6 +247,49 @@ describe('processToolCall', () => {
|
||||
expect(result.content).toBe('ok')
|
||||
})
|
||||
|
||||
it('auto-accepts required confirmations when yolo mode is active', async () => {
|
||||
const { createToolDef, processToolCall } = await import('./shared')
|
||||
const fn = vi.fn().mockResolvedValue('ok')
|
||||
const requestConfirmation = vi.fn()
|
||||
const setToolStatus = vi.fn()
|
||||
|
||||
const result = await processToolCall({
|
||||
tools: [
|
||||
{
|
||||
def: createToolDef(z.object({}), 'create_schedule', 'Create schedule'),
|
||||
requiresConfirmation: true,
|
||||
confirmationMessage: 'Create schedule',
|
||||
fn
|
||||
}
|
||||
],
|
||||
toolCall: {
|
||||
id: 'call_yolo',
|
||||
type: 'function',
|
||||
function: { name: 'create_schedule', arguments: '{}' }
|
||||
},
|
||||
helpers: {},
|
||||
workspace: 'test-workspace',
|
||||
toolCallbacks: {
|
||||
setToolStatus,
|
||||
removeToolStatus: vi.fn(),
|
||||
requestConfirmation,
|
||||
shouldAutoAcceptToolConfirmations: () => true
|
||||
}
|
||||
})
|
||||
|
||||
expect(requestConfirmation).not.toHaveBeenCalled()
|
||||
expect(fn).toHaveBeenCalled()
|
||||
expect(setToolStatus).toHaveBeenCalledWith(
|
||||
'call_yolo',
|
||||
expect.objectContaining({
|
||||
content: 'Create schedule',
|
||||
isLoading: true,
|
||||
needsConfirmation: false
|
||||
})
|
||||
)
|
||||
expect(result.content).toBe('ok')
|
||||
})
|
||||
|
||||
it('blocks workspace mutation tools for undeployed scripts and flows', async () => {
|
||||
const { processToolCall } = await import('./shared')
|
||||
const { createWorkspaceMutationTools } = await import('./workspaceTools')
|
||||
|
||||
@@ -582,10 +582,13 @@ export async function processToolCall<T>({
|
||||
}
|
||||
|
||||
// Check if tool requires confirmation
|
||||
const needsConfirmation = tool?.requiresConfirmation
|
||||
const requiresConfirmation = tool?.requiresConfirmation === true
|
||||
const autoAcceptConfirmation =
|
||||
requiresConfirmation && toolCallbacks.shouldAutoAcceptToolConfirmations?.() === true
|
||||
const needsConfirmation = requiresConfirmation && !autoAcceptConfirmation
|
||||
|
||||
toolCallbacks.setToolStatus(toolCall.id, {
|
||||
...(tool?.requiresConfirmation
|
||||
...(requiresConfirmation
|
||||
? { content: tool.confirmationMessage ?? 'Waiting for confirmation...' }
|
||||
: {}),
|
||||
parameters: args,
|
||||
@@ -695,6 +698,7 @@ export interface ToolCallbacks {
|
||||
setToolStatus: (id: string, metadata?: Partial<ToolDisplayMessage>) => void
|
||||
removeToolStatus: (id: string) => void
|
||||
requestConfirmation?: (toolId: string) => Promise<boolean>
|
||||
shouldAutoAcceptToolConfirmations?: () => boolean
|
||||
requestUserQuestion?: (
|
||||
toolId: string,
|
||||
question: UserQuestionDisplay
|
||||
|
||||
Reference in New Issue
Block a user