diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a4b873795b..0ec6d725e8 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -142,6 +142,7 @@ class AIChatManager { cachedDatatables = $state([]) private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined) + private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined allowedModes: Record = $derived({ @@ -228,6 +229,40 @@ class AIChatManager { } } + requestUserQuestion = ( + toolId: string, + _question: { question: string; choices: string[] } + ): Promise => { + return new Promise((resolve) => { + this.userQuestionCallbacks.set(toolId, resolve) + }) + } + + handleUserQuestionAnswer = (toolId: string, choice: string) => { + const callback = this.userQuestionCallbacks.get(toolId) + if (!callback) { + return + } + + this.displayMessages = this.displayMessages.map((message) => { + if (message.role === 'tool' && message.tool_call_id === toolId && message.userQuestion) { + return { + ...message, + content: `User answered question: ${choice}`, + isLoading: false, + userQuestion: { + ...message.userQuestion, + selectedChoice: choice + } + } + } + return message + }) + + callback(choice) + this.userQuestionCallbacks.delete(toolId) + } + setAiChatInput(aiChatInput: AIChatInput | null) { this.aiChatInput = aiChatInput } @@ -838,7 +873,8 @@ class AIChatManager { this.displayMessages = [...this.displayMessages] } }, - requestConfirmation: this.requestConfirmation + requestConfirmation: this.requestConfirmation, + requestUserQuestion: this.requestUserQuestion } } @@ -869,6 +905,10 @@ class AIChatManager { this.confirmationCallback(false) this.confirmationCallback = undefined } + for (const resolveQuestion of this.userQuestionCallbacks.values()) { + resolveQuestion(undefined) + } + this.userQuestionCallbacks.clear() const cancelReason = reason ?? 'user_cancelled' console.log('cancelling request:', { reason: cancelReason, @@ -1207,7 +1247,10 @@ class AIChatManager { ...message, isLoading: false, content: messageText, - error: messageText + error: messageText, + userQuestion: message.userQuestion + ? { ...message.userQuestion, canceled: true } + : undefined } } return message diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte new file mode 100644 index 0000000000..29481a04f2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -0,0 +1,85 @@ + + +
+
+ +

{userQuestion.question}

+
+ +
+ {#each userQuestion.choices as choice, index (index)} + + {/each} +
+
diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 695fc41d72..2e1140663d 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge' import ToolContentDisplay from './ToolContentDisplay.svelte' import ToolMessageActions from './ToolMessageActions.svelte' + import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' interface Props { message: ToolDisplayMessage @@ -28,111 +29,125 @@ ? message.actions : [] ) + + const activeUserQuestion = $derived( + message.userQuestion && + message.isLoading && + !message.error && + !message.userQuestion.selectedChoice && + !message.userQuestion.canceled + ? message.userQuestion + : undefined + ) -
- - + {#if message.isLoading && !message.needsConfirmation} + + {:else if message.error} + + {:else if !message.isLoading && !message.error} + + {/if} + + {message.content} + +
+ - - {#if isExpanded} -
- - {#if hasParameters || message.needsConfirmation} -
- -
- {/if} + + {#if isExpanded} +
+ + {#if hasParameters || message.needsConfirmation} +
+ +
+ {/if} - - {#if message.needsConfirmation} -
- - -
+ + +
- - {:else if !message.isStreamingArguments} - - - {#if visibleActions.length > 0} - - {:else} + + {:else if !message.isStreamingArguments} + + {#if visibleActions.length > 0} + + {:else} + + {/if} {/if} - {/if} -
- {/if} - + + {/if} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 2732db4dac..eb734b60f2 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -70,12 +70,16 @@ function getGlobalTool(name: string): Tool<{}> { return tool } -async function callGlobalTool(name: string, args: Record): Promise { +async function callGlobalTool( + name: string, + args: Record, + callbacks: ToolCallbacks = toolCallbacks +): Promise { return getGlobalTool(name).fn({ args, workspace: WORKSPACE, helpers: {}, - toolCallbacks, + toolCallbacks: callbacks, toolId: `test-${name}` }) } @@ -198,4 +202,39 @@ describe('global AI tools', () => { }) expect(item.value.value).toBeUndefined() }) + + it('asks the user a multiple-choice question and returns the selected answer', async () => { + const callbacks: ToolCallbacks = { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestUserQuestion: vi.fn(async (_toolId, question) => question.choices[1]) + } + + const raw = await callGlobalTool( + 'askUserQuestion', + { + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }, + callbacks + ) + + expect(raw).toBe('python3') + expect(callbacks.requestUserQuestion).toHaveBeenCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + question: 'Which script language should be used?', + choices: ['bun', 'python3'] + }) + ) + expect(callbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-askUserQuestion', + expect.objectContaining({ + content: 'User answered question: python3', + isLoading: false, + result: 'python3', + userQuestion: expect.objectContaining({ selectedChoice: 'python3' }) + }) + ) + }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 210636e43d..61e2549a0d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -111,6 +111,18 @@ const getInstructionsSchema = z.object({ ) }) +const askUserQuestionSchema = z.object({ + question: z + .string() + .min(1) + .describe('The concise question to show to the user before continuing.'), + choices: z + .array(z.string().min(1).describe('Short answer text shown to the user and returned as-is.')) + .min(2) + .max(6) + .describe('Two to six mutually exclusive answer strings.') +}) + const listWorkspaceItemsSchema = z.object({ types: z .array(itemTypeSchema) @@ -469,6 +481,7 @@ Important rules: - Use search_resource_types before write_resource to discover the resource_type name and the JSON Schema its value must match. - Use get_instructions before writing a script, flow, resource, or app. For scripts, pass the target language; when modifying, use the language from the item you read. - Schedules, triggers, and variables do not need get_instructions — their tool schemas describe every field. +- When a required decision is ambiguous, use askUserQuestion with two to six clear answer strings instead of guessing. - A workspace item is { type, path, summary?, language?, triggerKind?, value, isDraft }. For scripts, value is the source code string. For flows, read_workspace_item returns value as the compact flow object { modules, schema, preprocessor_module, failure_module, groups }; write_flow takes the same flow fields as top-level tool arguments plus path/summary. For schedules/triggers/resources/variables, value is the full request body for that type. For apps, value is { files, runnables, data?, policy?, custom_path? } with frontend file contents and backend runnable definitions. - Apps (raw apps): use list_workspace_items with types: ['app'] to find them, read_workspace_item with type 'app' for a metadata summary (file paths + runnable list, no contents), then read_app_file to read individual files. Edit with write_app_file / patch_app_file / delete_app_file for frontend files and write_app_runnable / delete_app_runnable for backend runnables. Frontend file paths start with "/" (e.g. /index.tsx). Backend inline runnables are addressed as "backend//main.{ts|py}". /wmill.d.ts is generated and cannot be written. - To create a new raw app, use init_app. Before calling it, confirm framework (react19 / react18 / svelte5 / vue), path, and summary with the user — do not silently default to react19, even though it is the recommended choice. @@ -1205,6 +1218,60 @@ export const globalTools: Tool<{}>[] = [ return getInstructions(parsed.subject, parsed.language) } }, + { + def: createToolDef( + askUserQuestionSchema, + 'askUserQuestion', + 'Ask the user a multiple-choice question and wait for their selection before continuing.' + ), + fn: async ({ args, toolId, toolCallbacks }) => { + const parsed = askUserQuestionSchema.parse(args) + const userQuestion = { + question: parsed.question, + choices: parsed.choices + } + + toolCallbacks.setToolStatus(toolId, { + content: parsed.question, + userQuestion, + isLoading: true + }) + + if (!toolCallbacks.requestUserQuestion) { + const message = 'This chat context cannot ask interactive questions.' + toolCallbacks.setToolStatus(toolId, { + content: message, + userQuestion: { ...userQuestion, canceled: true }, + isLoading: false, + error: message + }) + return JSON.stringify({ success: false, error: message }) + } + + const selectedChoice = await toolCallbacks.requestUserQuestion(toolId, userQuestion) + if (!selectedChoice) { + const message = 'Question cancelled by user' + toolCallbacks.setToolStatus(toolId, { + content: message, + userQuestion: { ...userQuestion, canceled: true }, + isLoading: false, + error: message + }) + return JSON.stringify({ success: false, error: message }) + } + + toolCallbacks.setToolStatus(toolId, { + content: `User answered question: ${selectedChoice}`, + userQuestion: { + ...userQuestion, + selectedChoice + }, + result: selectedChoice, + isLoading: false + }) + return selectedChoice + } + }, { def: createToolDef( listWorkspaceItemsSchema, diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 1cb0b3edc0..5cfe98a204 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -68,6 +68,39 @@ describe('createToolDef', () => { expect(parameters?.properties?.config?.anyOf?.length).toBeGreaterThan(1) }) + it('disables strict mode for schemas with optional properties', async () => { + const { createToolDef } = await import('./shared') + const toolDef = createToolDef( + z.object({ + subject: z.string(), + language: z.string().optional() + }), + 'get_instructions', + 'Get instructions' + ) + + const parameters = toolDef.function.parameters as any + expect(toolDef.function.strict).toBe(false) + expect(parameters.required).toEqual(['subject']) + expect(parameters.properties.language.type).toBe('string') + }) + + it('keeps strict mode for schemas without optional properties', async () => { + const { createToolDef } = await import('./shared') + const toolDef = createToolDef( + z.object({ + question: z.string(), + choices: z.array(z.string()) + }), + 'askUserQuestion', + 'Ask a question' + ) + + const parameters = toolDef.function.parameters as any + expect(toolDef.function.strict).toBe(true) + expect(parameters.required).toEqual(['question', 'choices']) + }) + it('does not expose runnable target fields on workspace mutation tools', async () => { const { createWorkspaceMutationTools } = await import('./workspaceTools') const [scheduleTool, triggerTool] = createWorkspaceMutationTools() diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index d29f9fc0ee..9e60d9fc99 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -485,6 +485,13 @@ export type CreatedResourceAction = { export type ToolDisplayAction = CreatedResourceAction +export type UserQuestionDisplay = { + question: string + choices: string[] + selectedChoice?: string + canceled?: boolean +} + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -500,6 +507,7 @@ export type ToolDisplayMessage = { toolName?: string showFade?: boolean actions?: ToolDisplayAction[] + userQuestion?: UserQuestionDisplay } export type AssistantDisplayMessage = BaseDisplayMessage & { @@ -684,6 +692,10 @@ export interface ToolCallbacks { setToolStatus: (id: string, metadata?: Partial) => void removeToolStatus: (id: string) => void requestConfirmation?: (toolId: string) => Promise + requestUserQuestion?: ( + toolId: string, + question: UserQuestionDisplay + ) => Promise } export function createToolDef( @@ -697,11 +709,12 @@ export function createToolDef( delete parameters.$schema if (!parameters.required) parameters.required = [] normalizeToolParameterSchema(parameters) + const effectiveStrict = strict && !hasOptionalProperties(parameters) return { type: 'function', function: { - strict, + strict: effectiveStrict, name, description, parameters @@ -709,6 +722,54 @@ export function createToolDef( } } +function hasOptionalProperties(schema: Record | undefined): boolean { + if (!schema || typeof schema !== 'object') { + return false + } + + if (schema.properties && typeof schema.properties === 'object') { + const required = new Set(Array.isArray(schema.required) ? schema.required : []) + const propertyKeys = Object.keys(schema.properties) + if (propertyKeys.some((key) => !required.has(key))) { + return true + } + for (const key of propertyKeys) { + if (hasOptionalProperties(schema.properties[key])) { + return true + } + } + } + + if (schema.items) { + if (Array.isArray(schema.items)) { + if (schema.items.some((item) => hasOptionalProperties(item))) { + return true + } + } else if (hasOptionalProperties(schema.items)) { + return true + } + } + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === 'object' && + hasOptionalProperties(schema.additionalProperties) + ) { + return true + } + + for (const key of ['allOf', 'anyOf', 'oneOf']) { + if ( + Array.isArray(schema[key]) && + schema[key].some((subSchema: Record) => hasOptionalProperties(subSchema)) + ) { + return true + } + } + + return false +} + const searchHubScriptsSchema = z.object({ query: z .string() diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 0248ceda1d..d1950697f9 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -492,7 +492,7 @@ } const skipSelector = - '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu]' + '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]' if (target) { const tag = target.tagName const isEditable =