integrate navigator mode

This commit is contained in:
centdix
2025-06-02 17:03:54 +02:00
parent 4238173ad7
commit 6e4fdd6e18
9 changed files with 501 additions and 121 deletions
@@ -5,6 +5,7 @@
import { chatRequest, prepareSystemMessage } from './core'
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
import { globalChatInitialInput, userStore, copilotInfo } from '$lib/stores'
import AiChat from '../copilot/chat/AIChat.svelte'
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const hasCopilot = $derived($copilotInfo.enabled)
@@ -107,94 +108,5 @@
</script>
<div class="relative flex flex-col h-full bg-surface z-20">
<!-- Reset Button -->
<div class="fixed top-3 right-3 flex flex-row justify-end gap-2">
<Button
buttonType="button"
on:click={resetChat}
disabled={!hasCopilot}
startIcon={{ icon: Plus }}
size="xs2"
aria-label="Reset chat"
>
New chat
</Button>
</div>
<!-- Chat Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-4 z-10 mt-12">
{#each messages as msg}
<div class={twMerge('flex flex-col', msg.role === 'user' ? 'items-end' : 'items-start')}>
<div
class={twMerge(
'max-w-[98%] p-3 rounded-lg text-sm',
msg.role === 'user'
? 'bg-blue-500 text-white rounded-br-sm'
: 'bg-gray-100 dark:bg-gray-700 text-primary rounded-bl-sm'
)}
>
<p class="whitespace-pre-wrap break-words"
>{@html renderMarkdownLinks(msg.content as string)}</p
>
</div>
</div>
{/each}
{#if isSubmitting}
<div class="flex items-start">
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded-lg rounded-bl-sm">
<div class="flex items-center gap-2 text-secondary">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Thinking...</span>
</div>
</div>
</div>
{/if}
<!-- Suggestion buttons when no user messages yet -->
{#if !hasUserMessages && !isSubmitting}
<div class="w-full pt-4">
<div class="flex flex-wrap gap-2">
{#each suggestions as suggestion}
<Button
on:click={() => submitSuggestion(suggestion)}
size="xs2"
color="blue"
buttonType="button"
disabled={!hasCopilot}
btnClasses="whitespace-normal text-left"
>
{suggestion}
</Button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Input Area -->
<div class="p-4 border-t border-gray-200 dark:border-gray-600">
<div class="flex gap-2">
<textarea
bind:value={inputValue}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}}
placeholder="Type your message..."
class="flex-1 resize-none border border-gray-300 dark:border-gray-600 rounded-lg p-3 text-sm bg-surface text-primary focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px] max-h-32 z-10"
rows="1"
disabled={!hasCopilot || isSubmitting}
></textarea>
<Button
size="md"
disabled={!hasCopilot || !inputValue.trim() || isSubmitting}
iconOnly
startIcon={{ icon: Send }}
on:click={handleSubmit}
/>
</div>
</div>
<AiChat navigatorMode />
</div>
@@ -30,32 +30,60 @@
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam
} from 'openai/resources/index.mjs'
<<<<<<< HEAD
import { chatMode, copilotSessionModel, dbSchemas, workspaceStore } from '$lib/stores'
=======
import {
navigatorTools,
prepareNavigatorSystemMessage,
prepareNavigatorUserMessage
} from './navigator/core'
>>>>>>> 332040744 (integrate navigator mode)
interface Props {
scriptOptions?: ScriptOptions
flowHelpers?: FlowAIChatHelpers & {
getFlow: () => OpenFlow
}
showDiffMode: () => void
applyCode: (code: string) => void
navigatorMode?: boolean
showDiffMode?: () => void
applyCode?: (code: string) => void
headerLeft?: Snippet
headerRight?: Snippet
}
let { scriptOptions, flowHelpers, applyCode, showDiffMode, headerLeft, headerRight }: Props =
$props()
let {
scriptOptions,
flowHelpers,
applyCode,
showDiffMode,
headerLeft,
headerRight,
navigatorMode = false
}: Props = $props()
let instructions = $state('')
let loading = writable(false)
let currentReply: Writable<string> = writable('')
let allowedModes = $derived({
script: scriptOptions !== undefined,
flow: flowHelpers !== undefined
flow: flowHelpers !== undefined,
navigator: navigatorMode
})
<<<<<<< HEAD
async function updateMode(currentMode: 'script' | 'flow') {
if (!allowedModes[currentMode]) {
chatMode.set(currentMode === 'script' ? 'flow' : 'script')
=======
let mode: 'script' | 'flow' | 'navigator' = $state(
flowHelpers ? 'flow' : scriptOptions ? 'script' : 'navigator'
)
async function updateMode(currentMode: 'script' | 'flow' | 'navigator') {
if (!allowedModes[currentMode] && Object.keys(allowedModes).length === 1) {
const firstKey = Object.keys(allowedModes)[0]
mode = firstKey as 'script' | 'flow' | 'navigator'
>>>>>>> 332040744 (integrate navigator mode)
}
}
$effect(() => {
@@ -78,7 +106,7 @@
removeDiff?: boolean
addBackCode?: boolean
instructions?: string
mode?: 'script' | 'flow'
mode?: 'script' | 'flow' | 'navigator'
lang?: ScriptLang | 'bunnative'
isPreprocessor?: boolean
} = {}
@@ -113,7 +141,15 @@
instructions = ''
const systemMessage =
<<<<<<< HEAD
$chatMode === 'script' ? prepareScriptSystemMessage() : prepareFlowSystemMessage()
=======
mode === 'script'
? prepareScriptSystemMessage()
: mode === 'flow'
? prepareFlowSystemMessage()
: prepareNavigatorSystemMessage()
>>>>>>> 332040744 (integrate navigator mode)
if ($chatMode === 'flow' && !flowHelpers) {
throw new Error('No flow helpers passed')
@@ -129,11 +165,13 @@
const userMessage =
$chatMode === 'flow'
? prepareFlowUserMessage(oldInstructions, flowHelpers!.getFlow())
: await prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
isPreprocessor
})
: mode === 'navigator'
? prepareNavigatorUserMessage(oldInstructions)
: await prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
isPreprocessor
})
messages.push({ role: 'user', content: userMessage })
messages.push(userMessage)
await historyManager.saveChat(displayMessages, messages)
$currentReply = ''
@@ -194,7 +232,7 @@
tools: flowTools,
helpers: flowHelpers
})
} else {
} else if (mode === 'script') {
const tools: Tool<ScriptChatHelpers>[] = []
if (['python3', 'php', 'bun', 'deno', 'nativets', 'bunnative'].includes(lang)) {
tools.push(resourceTypeTool)
@@ -209,6 +247,12 @@
getLang: () => lang
}
})
} else if (mode === 'navigator') {
await chatRequest({
...params,
tools: navigatorTools,
helpers: {}
})
}
if ($currentReply) {
@@ -272,7 +316,7 @@
addBackCode: options.withCode === false
})
if (options.withDiff) {
showDiffMode()
showDiffMode?.()
}
}
@@ -37,6 +37,7 @@
allowedModes: {
script: boolean
flow: boolean
navigator: boolean
}
messages: DisplayMessage[]
instructions: string
@@ -10,12 +10,16 @@
allowedModes: {
script: boolean
flow: boolean
navigator: boolean
}
} = $props()
</script>
<div class="min-w-0">
<Popover disablePopup={!allowedModes.script || !allowedModes.flow} class="max-w-full">
<Popover
disablePopup={!allowedModes.script || !allowedModes.flow || !allowedModes.navigator}
class="max-w-full"
>
<svelte:fragment slot="trigger">
<div
class="text-tertiary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
@@ -32,14 +36,14 @@
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each ['script', 'flow'] as possibleMode}
{#each ['script', 'flow', 'navigator'] as possibleMode}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
$chatMode === possibleMode && 'bg-surface-hover'
)}
onclick={() => {
$chatMode = possibleMode as 'script' | 'flow'
$chatMode = possibleMode as 'script' | 'flow' | 'navigator'
close()
}}
>
@@ -1,5 +1,9 @@
import { ScriptService, type FlowModule, type RawScript, type Script } from '$lib/gen'
import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionTool,
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
import YAML from 'yaml'
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'
@@ -537,10 +541,7 @@ function createToolDef(
}
}
export function prepareFlowSystemMessage(): {
role: 'system'
content: string
} {
export function prepareFlowSystemMessage(): ChatCompletionSystemMessageParam {
const content = `You are a helpful assitant that creates and edit workflows on the Windmill platform. You're provided with a a bunch of tools to help you edit the flow.
Describe all steps you take to edit the flow before calling the tools.
Follow the user instructions carefully and take note of the following:
@@ -591,8 +592,13 @@ If the user wants a specific resource as step input, you should set the step val
}
}
export function prepareFlowUserMessage(instructions: string, flow: ExtendedOpenFlow) {
return `## FLOW:
export function prepareFlowUserMessage(
instructions: string,
flow: ExtendedOpenFlow
): ChatCompletionUserMessageParam {
return {
role: 'user',
content: `## FLOW:
flow_input schema:
${JSON.stringify(flow.schema ?? emptySchema())}
@@ -607,4 +613,5 @@ ${YAML.stringify(flow.value.failure_module)}
## INSTRUCTIONS:
${instructions}`
}
}
@@ -0,0 +1,408 @@
import { get, type Writable } from 'svelte/store'
import { page } from '$app/state'
import { getCompletion } from '$lib/components/copilot/lib'
import type {
ChatCompletionChunk,
ChatCompletionMessageParam,
ChatCompletionMessageToolCall,
ChatCompletionSystemMessageParam,
ChatCompletionTool,
ChatCompletionUserMessageParam
} from 'openai/resources/index.mjs'
import { triggerablesByAI } from '$lib/stores'
import type { Tool } from '../shared'
// System prompt for the LLM
export const CHAT_SYSTEM_PROMPT = `
You are Windmill's intelligent assistant, designed to help users navigate the application and answer questions about its functionality. It is your only purpose to help the user in the context of the windmill application.
Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications.
You have access to these tools:
1. View current buttons and inputs on the page (get_triggerable_components)
2. Execute buttons and inputs (trigger_component)
3. Get documentation for user requests (get_documentation)
INSTRUCTIONS:
- When users ask about application features or concepts, first use get_documentation internally to retrieve accurate information about how to fulfill the user's request.
- Then immediately use the available tools to guide the user through the application. Do not wait for the user's confirmation before taking action.
- Use get_triggerable_components to understand available options, and then trigger the components using trigger_component. Then wait a moment before rescanning the current page, and then continue with the next step. Do this 5 times max.
- If you are not able to fulfill the user's request after 5 attempts, redirect the user to the documentation.
GENERAL PRINCIPLES:
- Be concise but thorough
- Focus on taking action and completing the user's goals
- Maintain a friendly, professional tone
- If you encounter an error or can't complete a request, explain why and suggest alternatives
- When asked about a specific script, flow or app, first check components directly related to the mentioned entity, before checking the other components.
- When you do not find what you are looking for on the current page, go to the home page by looking for the "Home" component, then scan the components again.
Always use the provided tools purposefully and appropriately to achieve the user's goals.
Your actions only allow you to navigate the application through the provided tools.
When you complete the user's request, do not say "I created..." or "I updated..." or "I deleted...", but rather say something like "Here is where you can find what you were looking for...". Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.
Exemple of good behavior:
- User: "How can I set my AI providers?"
- You: <call get_documentation and fetch relevant documentation>
- You: <call get_triggerable_components to find relevant components>
- You: <trigger the components>
- You: "Here is where you can find what you were looking for. <precisions about the request based on the documentation>"
`
const GET_DOCUMENTATION_TOOL: ChatCompletionTool = {
type: 'function',
function: {
name: 'get_documentation',
description: 'Get the documentation for the user request',
parameters: {
type: 'object',
properties: {
request: {
type: 'string',
description: 'The user request'
}
},
required: ['request']
}
}
}
// Tool definitions
const GET_TRIGGERABLE_COMPONENTS_TOOL: ChatCompletionTool = {
type: 'function',
function: {
name: 'get_triggerable_components',
description: 'Get the current triggerable components on the page',
parameters: {
type: 'object',
properties: {},
required: []
}
}
}
const EXECUTE_COMMAND_TOOL: ChatCompletionTool = {
type: 'function',
function: {
name: 'trigger_component',
description: 'Trigger a triggerable component',
parameters: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'ID of the AI-triggerable component'
},
value: {
type: 'string',
description: 'Value to pass to the AI-triggerable component trigger function'
}
},
required: ['id']
}
}
}
const GET_CURRENT_PAGE_NAME_TOOL: ChatCompletionTool = {
type: 'function',
function: {
name: 'get_current_page_name',
description: 'Get the name of the current page the user is on.',
parameters: {
type: 'object',
properties: {},
required: []
}
}
}
function getTriggerableComponents(): string {
try {
// Get components registered in the triggerablesByAI store
const registeredComponents = get(triggerablesByAI)
let result = 'TRIGGERABLE_COMPONENTS:\n'
// If there are no components registered, return a message
if (Object.keys(registeredComponents).length === 0) {
return 'No AI-triggerable components are currently available on this page.\n'
}
// List each registered component with its ID and description
Object.entries(registeredComponents).forEach(([id, component], index) => {
result += `[${index}] ID: "${id}" - ${component.description} - Triggerable: ${component.onTrigger ? 'Yes' : 'No'}\n`
})
return result
} catch (error) {
console.error('Error getting triggerable components:', error)
return 'Error getting triggerable components: ' + error.message
}
}
// Function to get the current page name
function getCurrentPageName(): string {
try {
const currentPage = page.url.pathname
switch (currentPage) {
case '/':
return 'Home Page'
case '/flows/add':
return 'Flow creation page'
case '/scripts/add':
return 'Script creation page'
case '/apps/add':
return 'App creation page'
default:
return 'Non-specific page'
}
} catch (error) {
console.error('Error getting current page name:', error)
return 'Error getting current page name: ' + error.message
}
}
// Function to execute commands on the page
function triggerComponent(args: { id: string; value: string }): string {
const { id, value } = args
try {
// Handle triggering AI components
if (!id) {
return 'Trigger command requires an id parameter'
}
const components = get(triggerablesByAI)
const component = components[id]
if (!component) {
return `No triggerable component found with id: ${id}`
}
if (component.onTrigger) {
component.onTrigger(value)
return `Successfully triggered component: ${id} (${component.description})`
} else {
return `Component ${id} has no trigger handler defined`
}
} catch (error) {
console.error('Error executing command:', error)
return `Error executing command: ${error.message}`
}
}
async function getDocumentation(args: { request: string }): Promise<string> {
const retrieval = await fetch('/api/inkeep', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'inkeep-rag',
messages: [{ role: 'user', content: args.request }],
response_format: {
type: 'json_object'
}
})
})
const data = await retrieval.json()
if (!data.choices?.[0]?.message?.content) {
return 'No documentation found for this request'
}
// Parse the raw response
const raw = data.choices[0].message.content
const parsed = JSON.parse(raw)
// Clean up the response to include only essential information
if (parsed.content && Array.isArray(parsed.content)) {
const cleanedContent = parsed.content.map((item: any) => ({
title: item.title,
url: item.url,
content: item.source?.content.map((c: any) => c.text).join('\n') || []
}))
// Limit the response to 30000 characters max
const stringified = JSON.stringify({ content: cleanedContent }).slice(0, 30000)
return stringified
}
return data.choices[0].message.content
}
// Process tool calls from the LLM
async function processToolCall(
toolCall: ChatCompletionMessageToolCall,
messages: ChatCompletionMessageParam[]
) {
try {
const args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}
let result = ''
try {
if (toolCall.function.name === 'get_triggerable_components') {
result = getTriggerableComponents()
} else if (toolCall.function.name === 'trigger_component') {
result = triggerComponent(args)
} else if (toolCall.function.name === 'get_documentation') {
const docResult = await getDocumentation(args)
result = docResult || 'No documentation found for this request'
} else if (toolCall.function.name === 'get_current_page_name') {
result = getCurrentPageName()
} else {
result = `Unknown tool: ${toolCall.function.name}`
}
} catch (err) {
console.error(err)
result = `Error while calling ${toolCall.function.name}: ${err.message}`
}
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result
})
} catch (err) {
console.error(err)
}
}
export const navigatorTools: Tool<{}>[] = [
{
def: GET_TRIGGERABLE_COMPONENTS_TOOL,
fn: async ({ toolId, toolCallbacks }) => {
toolCallbacks.onToolCall(toolId, 'Getting clickable components...')
const pageName = getTriggerableComponents()
toolCallbacks.onFinishToolCall(toolId, 'Retrieved clickable components')
return pageName
}
},
{
def: EXECUTE_COMMAND_TOOL,
fn: async ({ args, toolId, toolCallbacks }) => {
toolCallbacks.onToolCall(toolId, 'Clicking on the component...')
const result = triggerComponent(args)
toolCallbacks.onFinishToolCall(toolId, 'Clicked on the component')
return result
}
},
{
def: GET_DOCUMENTATION_TOOL,
fn: async ({ args, toolId, toolCallbacks }) => {
toolCallbacks.onToolCall(toolId, 'Getting documentation...')
const docResult = await getDocumentation(args)
toolCallbacks.onFinishToolCall(toolId, 'Retrieved documentation')
return docResult
}
},
{
def: GET_CURRENT_PAGE_NAME_TOOL,
fn: async ({ toolId, toolCallbacks }) => {
const pageName = getCurrentPageName()
toolCallbacks.onFinishToolCall(toolId, 'Retrieved current page name')
return pageName
}
}
]
// Main function to handle chat requests
export async function chatRequest(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
onNewToken: (token: string) => void
) {
const toolDefs: ChatCompletionTool[] = [
GET_TRIGGERABLE_COMPONENTS_TOOL,
EXECUTE_COMMAND_TOOL,
GET_DOCUMENTATION_TOOL,
GET_CURRENT_PAGE_NAME_TOOL
]
try {
let completion: any = null
while (true) {
completion = await getCompletion(messages, abortController, toolDefs)
if (completion) {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
for await (const chunk of completion) {
if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) {
continue
}
const c = chunk as ChatCompletionChunk
const delta = c.choices[0].delta.content
if (delta) {
onNewToken(delta)
}
const toolCalls = c.choices[0].delta.tool_calls || []
for (const toolCall of toolCalls) {
const { index } = toolCall
const finalToolCall = finalToolCalls[index]
if (!finalToolCall) {
finalToolCalls[index] = toolCall
} else {
if (toolCall.function?.arguments) {
if (!finalToolCall.function) {
finalToolCall.function = toolCall.function
} else {
finalToolCall.function.arguments =
(finalToolCall.function.arguments ?? '') + toolCall.function.arguments
}
}
}
}
}
const toolCalls = Object.values(finalToolCalls).filter(
(toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined
) as ChatCompletionMessageToolCall[]
if (toolCalls.length > 0) {
messages.push({
role: 'assistant',
tool_calls: toolCalls
})
for (const toolCall of toolCalls) {
await processToolCall(toolCall, messages)
}
} else {
break
}
}
}
return completion
} catch (err) {
if (!abortController.signal.aborted) {
console.error(err)
throw err
}
}
}
// Prepare initial system message
export function prepareNavigatorSystemMessage(): ChatCompletionSystemMessageParam {
return {
role: 'system',
content: CHAT_SYSTEM_PROMPT
}
}
export function prepareNavigatorUserMessage(instructions: string): ChatCompletionUserMessageParam {
return {
role: 'user',
content: instructions
}
}
// Interface for chat context
export interface AIChatContext {
loading: Writable<boolean>
currentReply: Writable<string>
}
@@ -3,7 +3,11 @@ import type { ResourceType, ScriptLang } from '$lib/gen/types.gen'
import { capitalize, isObject, toCamel } from '$lib/utils'
import { get } from 'svelte/store'
import { compile, phpCompile, pythonCompile } from '../../utils'
import type { ChatCompletionTool } from 'openai/resources/index.mjs'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionTool,
ChatCompletionUserMessageParam
} from 'openai/resources/index.mjs'
import { type DBSchema, dbSchemas } from '$lib/stores'
import { scriptLangToEditorLang } from '$lib/scripts'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/utils'
@@ -277,10 +281,7 @@ WINDMILL LANGUAGE CONTEXT:
export const CHAT_USER_DB_CONTEXT = `- {title}: SCHEMA: \n{schema}\n`
export function prepareScriptSystemMessage(): {
role: 'system'
content: string
} {
export function prepareScriptSystemMessage(): ChatCompletionSystemMessageParam {
return {
role: 'system',
content: CHAT_SYSTEM_PROMPT
@@ -306,7 +307,7 @@ export async function prepareScriptUserMessage(
options: {
isPreprocessor?: boolean
} = {}
) {
): Promise<ChatCompletionUserMessageParam> {
let codeContext = 'CODE:\n'
let errorContext = 'ERROR:\n'
let dbContext = 'DATABASES:\n'
@@ -362,7 +363,10 @@ export async function prepareScriptUserMessage(
if (hasDiff) {
userMessage += diffContext
}
return userMessage
return {
role: 'user',
content: userMessage
}
}
const RESOURCE_TYPE_FUNCTION_DEF: ChatCompletionTool = {
@@ -14,7 +14,7 @@ export interface AIChatContext {
loading: Writable<boolean>
currentReply: Writable<string>
canApplyCode: () => boolean
applyCode: (code: string) => void
applyCode?: (code: string) => void
}
export type DisplayMessage =
+1 -1
View File
@@ -105,7 +105,7 @@ export const copilotInfo = writable<{
defaultModel: undefined,
aiModels: []
})
export const chatMode = writable<'script' | 'flow'>('script')
export const chatMode = writable<'script' | 'flow' | 'navigator'>('script')
export function setCopilotInfo(aiConfig: AIConfig) {
if (Object.keys(aiConfig.providers ?? {}).length > 0) {