Merge remote-tracking branch 'origin/main' into glm/use-melt-scroll-area

This commit is contained in:
Guilhem
2025-04-01 08:22:59 +01:00
8 changed files with 688 additions and 253 deletions
@@ -1,5 +1,12 @@
<script lang="ts">
import { copilotSessionModel, dbSchemas, type DBSchema, type DBSchemas } from '$lib/stores'
import {
copilotSessionModel,
dbSchemas,
type DBSchema,
type DBSchemas,
SQLSchemaLanguages,
workspaceStore
} from '$lib/stores'
import { writable, type Writable } from 'svelte/store'
import AIChatDisplay from './AIChatDisplay.svelte'
import {
@@ -8,11 +15,15 @@
prepareUserMessage,
type AIChatContext,
type ContextElement,
type DisplayMessage,
type SelectedContext
type DisplayMessage
} from './core'
import { createEventDispatcher, onDestroy, setContext } from 'svelte'
import type { AIProviderModel, ScriptLang } from '$lib/gen'
import {
type AIProviderModel,
type ListResourceResponse,
type ScriptLang,
ResourceService
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { openDB, type DBSchema as IDBSchema, type IDBPDatabase } from 'idb'
import { isInitialCode } from '$lib/script_helpers'
@@ -26,43 +37,11 @@
export let path: string | undefined
$: contextCodePath = path
? path.split('/').pop() + '.' + langToExt(scriptLangToEditorLang(lang))
? (path.split('/').pop() ?? 'script') + '.' + langToExt(scriptLangToEditorLang(lang))
: undefined
let initializedWithInitCode: boolean | null = null
$: lang && (initializedWithInitCode = null)
function onCodeChange() {
if (!contextCodePath) {
return
}
try {
if (initializedWithInitCode === null && code) {
if (isInitialCode(code)) {
initializedWithInitCode = true
} else {
initializedWithInitCode = false
selectedContext = [
{
type: 'code',
title: contextCodePath
}
]
}
} else if (initializedWithInitCode) {
// if the code was initial and was changed, add code context, then prevent it from being added again
selectedContext = [
{
type: 'code',
title: contextCodePath
}
]
initializedWithInitCode = false
}
} catch (err) {
console.error('Could not update context', err)
}
}
$: contextCodePath && code && onCodeChange()
let db: { schema: DBSchema; resource: string } | undefined = undefined
@@ -90,23 +69,36 @@
}
$: updateSchema(lang, args, $dbSchemas)
let selectedContext: SelectedContext[] = []
let selectedContext: ContextElement[] = []
let availableContext: ContextElement[] = []
function updateAvailableContext(
let dbResources: ListResourceResponse = []
async function updateDBResources(workspace: string | undefined) {
if (workspace) {
dbResources = await ResourceService.listResource({
workspace: workspace,
resourceType: SQLSchemaLanguages.join(',')
})
}
}
async function updateAvailableContext(
contextCodePath: string | undefined,
code: string,
lang: ScriptLang | 'bunnative',
error: string | undefined,
db: { schema: DBSchema; resource: string } | undefined,
providerModel: AIProviderModel | undefined
providerModel: AIProviderModel | undefined,
dbSchemas: DBSchemas,
dbResources: ListResourceResponse
) {
if (!contextCodePath) {
return
}
try {
availableContext = [
let newAvailableContext: ContextElement[] = [
{
type: 'code',
title: contextCodePath,
@@ -114,10 +106,21 @@
lang
}
]
if (!providerModel?.model.endsWith('/thinking')) {
for (const d of dbResources) {
const loadedSchema = dbSchemas[d.path]
newAvailableContext.push({
type: 'db',
title: d.path,
// If the db is already fetched, add the schema to the context
...(loadedSchema ? { schema: loadedSchema } : {})
})
}
}
if (error) {
availableContext = [
...availableContext,
newAvailableContext = [
...newAvailableContext,
{
type: 'error',
title: 'error',
@@ -126,22 +129,78 @@
]
}
if (db && !providerModel?.model.endsWith('/thinking')) {
availableContext = [
...availableContext,
if (db) {
// If the db is already fetched, add it to the selected context
if (
!selectedContext.find((c) => c.type === 'db' && c.title === db.resource) &&
!providerModel?.model.endsWith('/thinking')
) {
selectedContext = [
...selectedContext,
{
type: 'db',
title: db.resource,
schema: db.schema
}
]
}
}
availableContext = newAvailableContext
if (
code &&
((initializedWithInitCode === null && !isInitialCode(code)) || initializedWithInitCode)
) {
selectedContext = [
{
type: 'db',
title: db.resource,
schema: db.schema
type: 'code',
title: contextCodePath,
content: code,
lang
}
]
}
if (code && initializedWithInitCode === null) {
initializedWithInitCode = isInitialCode(code)
}
selectedContext = selectedContext
.map((c) => availableContext.find((ac) => ac.type === c.type && ac.title === c.title))
.filter((c) => c !== undefined) as ContextElement[]
} catch (err) {
console.error('Could not update available context', err)
}
}
$: updateAvailableContext(contextCodePath, code, lang, error, db, $copilotSessionModel)
function updateDisplayMessages(dbSchemas: DBSchemas) {
return displayMessages.map((m) => ({
...m,
contextElements: m.contextElements?.map((c) =>
c.type === 'db'
? {
type: 'db',
title: c.title,
schema: dbSchemas[c.title]
}
: c
) as ContextElement[]
}))
}
$: updateDBResources($workspaceStore)
$: updateAvailableContext(
contextCodePath,
code,
lang,
error,
db,
$copilotSessionModel,
$dbSchemas,
dbResources
)
let instructions = ''
let loading = writable(false)
@@ -178,28 +237,11 @@
let messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
prepareSystemMessage()
]
let displayMessages: DisplayMessage[] = []
let abortController: AbortController | undefined = undefined
function updateSelectedContextElements() {
try {
const contextElements: ContextElement[] = []
for (const selected of selectedContext) {
const el = availableContext.find(
(c) => c.type === selected.type && c.title === selected.title
)
if (el) {
contextElements.push(el)
}
}
return contextElements
} catch (err) {
console.error('Could not update selected context elements', err)
return []
}
}
let selectedContextElements: ContextElement[] = []
$: displayMessages = updateDisplayMessages($dbSchemas)
async function sendRequest() {
if (!instructions.trim()) {
@@ -210,19 +252,17 @@
aiChatDisplay?.enableAutomaticScroll()
abortController = new AbortController()
selectedContextElements = updateSelectedContextElements()
displayMessages = [
...displayMessages,
{
role: 'user',
content: instructions,
contextElements: selectedContextElements
contextElements: selectedContext
}
]
const oldInstructions = instructions
instructions = ''
const userMessage = await prepareUserMessage(oldInstructions, lang, selectedContextElements)
const userMessage = await prepareUserMessage(oldInstructions, lang, selectedContext)
messages.push({ role: 'user', content: userMessage })
await saveChat()
@@ -232,14 +272,8 @@
messages,
abortController,
lang,
(
selectedContextElements.find((c) => c.type === 'db') as
| Extract<ContextElement, { type: 'db' }>
| undefined
)?.schema,
(token) => {
currentReply.update((prev) => prev + token)
}
selectedContext.filter((c) => c.type === 'db').length > 0,
(token) => currentReply.update((prev) => prev + token)
)
messages.push({ role: 'assistant', content: $currentReply })
@@ -248,7 +282,7 @@
{
role: 'assistant',
content: $currentReply,
contextElements: selectedContextElements
contextElements: selectedContext.filter((c) => c.type === 'code')
}
]
currentReply.set('')
@@ -318,16 +352,14 @@
}
instructions = 'Fix the error'
selectedContext = [
{
type: 'code',
title: contextCodePath
},
{
type: 'error',
title: 'error'
}
]
const codeContext = availableContext.find(
(c) => c.type === 'code' && c.title === contextCodePath
)
const errorContext = availableContext.find((c) => c.type === 'error')
if (codeContext && errorContext) {
selectedContext = [codeContext, errorContext]
}
sendRequest()
}
@@ -390,7 +422,7 @@
{
role: 'assistant',
content: $currentReply,
contextElements: selectedContextElements
contextElements: selectedContext.filter((c) => c.type === 'code')
}
]
: displayMessages}
@@ -1,18 +1,11 @@
<script lang="ts">
import autosize from '$lib/autosize'
import { twMerge } from 'tailwind-merge'
import AssistantMessage from './AssistantMessage.svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { ChevronDown, HistoryIcon, Loader2, Plus, X } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import {
ContextIconMap,
type AIChatContext,
type DisplayMessage,
type ContextElement,
type SelectedContext
} from './core'
import { type AIChatContext, type DisplayMessage, type ContextElement } from './core'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME,
@@ -21,11 +14,13 @@
} from '$lib/stores'
import ContextElementBadge from './ContextElementBadge.svelte'
import { storeLocalSetting } from '$lib/utils'
import ContextTextarea from './ContextTextarea.svelte'
import AvailableContextList from './AvailableContextList.svelte'
export let pastChats: { id: string; title: string }[]
export let messages: DisplayMessage[]
export let instructions: string
export let selectedContext: SelectedContext[]
export let selectedContext: ContextElement[]
export let availableContext: ContextElement[]
const dispatch = createEventDispatcher<{
@@ -59,6 +54,19 @@
model: 'No model',
provider: 'No provider'
}
function addContextToSelection(contextElement: ContextElement) {
if (
!selectedContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
) &&
availableContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
)
) {
selectedContext = [...selectedContext, contextElement]
}
}
</script>
<div class="flex flex-col h-full">
@@ -188,97 +196,74 @@
>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-1 text-tertiary text-xs p-1 min-w-24">
{#if availableContext.filter((c) => !selectedContext.find((sc) => sc.type === c.type)).length === 0}
<div class="text-center text-tertiary text-xs">No available context</div>
{:else}
{#each availableContext as element}
{#if !selectedContext.find((c) => c.type === element.type)}
<button
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal"
on:click={() => {
selectedContext = [
...selectedContext,
{
type: element.type,
title: element.title
}
]
close()
}}
>
<svelte:component this={ContextIconMap[element.type]} size={16} />
{element.title}
</button>
{/if}
{/each}
{/if}
</div>
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
</svelte:fragment>
</Popover>
{#each selectedContext as element}
{@const contextElement = availableContext.find((c) => c.type === element.type)}
{@const contextElement = availableContext.find(
(c) => c.type === element.type && c.title === element.title
)}
{#if contextElement}
<ContextElementBadge
{contextElement}
deletable
on:delete={() => {
selectedContext = selectedContext.filter((c) => c.type !== element.type)
selectedContext = selectedContext.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/if}
{/each}
</div>
<div class="px-2 scroll-pb-2">
<textarea
on:keypress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
dispatch('sendRequest')
}
}}
bind:value={instructions}
use:autosize
rows={3}
placeholder={messages.length > 0 ? 'Ask followup' : 'Ask anything'}
class="resize-none"
/>
<ContextTextarea
{instructions}
{availableContext}
{selectedContext}
isFirstMessage={messages.length === 0}
on:addContext={(e) => addContextToSelection(e.detail.contextElement)}
on:sendRequest={() => dispatch('sendRequest')}
on:updateInstructions={(e) => (instructions = e.detail.value)}
/>
<div class="flex flex-row justify-end items-center gap-2 px-0.5">
<div class="min-w-0">
<Popover disablePopup={$copilotInfo.aiModels.length <= 1} class="max-w-full">
<svelte:fragment slot="trigger">
<div class="text-tertiary text-xs flex flex-row items-center gap-0.5 font-normal">
<span class="truncate">{providerModel.model}</span>
{#if $copilotInfo.aiModels.length > 1}
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each $copilotInfo.aiModels.filter((m) => m.model !== providerModel.model) as providerModel}
<button
class="text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal"
on:click={() => {
$copilotSessionModel = providerModel
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
storeLocalSetting(
COPILOT_SESSION_PROVIDER_SETTING_NAME,
providerModel.provider
)
close()
}}
>
{providerModel.model}
</button>
{/each}
</div>
</svelte:fragment>
</Popover>
</div>
<div class="flex flex-row justify-end items-center gap-2 px-0.5">
<div class="min-w-0">
<Popover disablePopup={$copilotInfo.aiModels.length <= 1} class="max-w-full">
<svelte:fragment slot="trigger">
<div class="text-tertiary text-xs flex flex-row items-center gap-0.5 font-normal">
<span class="truncate">{providerModel.model}</span>
{#if $copilotInfo.aiModels.length > 1}
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each $copilotInfo.aiModels.filter((m) => m.model !== providerModel.model) as providerModel}
<button
class="text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal"
on:click={() => {
$copilotSessionModel = providerModel
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, providerModel.provider)
close()
}}
>
{providerModel.model}
</button>
{/each}
</div>
</svelte:fragment>
</Popover>
</div>
</div>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import { ContextIconMap } from './core'
import type { ContextElement } from './core'
export let availableContext: ContextElement[]
export let selectedContext: ContextElement[]
export let onSelect: (element: ContextElement) => void
export let showAllAvailable = false
export let stringSearch = ''
export let selectedIndex = 0
$: actualAvailableContext = showAllAvailable
? availableContext.filter(
(c) => !stringSearch || c.title.toLowerCase().includes(stringSearch.toLowerCase())
)
: availableContext.filter(
(c) =>
!selectedContext.find((sc) => sc.type === c.type && sc.title === c.title) &&
(!stringSearch || c.title.toLowerCase().includes(stringSearch.toLowerCase()))
)
</script>
<div class="flex flex-col gap-1 text-tertiary text-xs p-1 min-w-24 max-h-48 overflow-y-scroll">
{#if actualAvailableContext.length === 0}
<div class="text-center text-tertiary text-xs">No available context</div>
{:else}
{#each actualAvailableContext as element, i}
<button
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal {i ===
selectedIndex
? 'bg-surface-hover'
: ''}"
on:click={() => onSelect(element)}
>
<svelte:component this={ContextIconMap[element.type]} size={16} />
{element.title}
</button>
{/each}
{/if}
</div>
@@ -53,7 +53,7 @@
</div>
{:else if contextElement.type === 'db'}
<div class="p-2 max-w-96 max-h-[300px] text-xs overflow-auto">
{#if contextElement.schema.lang === 'graphql'}
{#if contextElement.schema && contextElement.schema.lang === 'graphql'}
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
@@ -62,8 +62,10 @@
class="h-full"
/>
{/await}
{:else}
{:else if contextElement.schema}
<ObjectViewer json={formatSchema(contextElement.schema)} pureViewer collapseLevel={1} />
{:else}
<div class="text-tertiary">Not loaded yet</div>
{/if}
</div>
{:else if contextElement.type === 'code'}
@@ -0,0 +1,337 @@
<script lang="ts">
import autosize from '$lib/autosize'
import { createEventDispatcher } from 'svelte'
import type { ContextElement } from './core'
import AvailableContextList from './AvailableContextList.svelte'
export let instructions: string
export let availableContext: ContextElement[]
export let selectedContext: ContextElement[]
export let isFirstMessage: boolean
const dispatch = createEventDispatcher<{
updateInstructions: { value: string }
sendRequest: null
addContext: { contextElement: ContextElement }
}>()
let showContextTooltip = false
let contextTooltipWord = ''
let tooltipPosition = { x: 0, y: 0 }
let textarea: HTMLTextAreaElement
let selectedSuggestionIndex = 0
// Properties to copy for caret position calculation
const properties = [
'direction',
'boxSizing',
'width',
'height',
'overflowX',
'overflowY',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'borderStyle',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'fontStyle',
'fontVariant',
'fontWeight',
'fontStretch',
'fontSize',
'fontSizeAdjust',
'lineHeight',
'fontFamily',
'textAlign',
'textTransform',
'textIndent',
'textDecoration',
'letterSpacing',
'wordSpacing',
'tabSize',
'MozTabSize'
]
function getCaretCoordinates(element: HTMLTextAreaElement, position: number) {
// Create mirror div
const div = document.createElement('div')
div.id = 'input-textarea-caret-position-mirror-div'
document.body.appendChild(div)
// Set styles
const style = div.style
const computed = window.getComputedStyle(element)
const isInput = element.nodeName === 'INPUT'
// Default textarea styles
style.whiteSpace = 'pre-wrap'
if (!isInput) style.wordWrap = 'break-word'
// Position off-screen
style.position = 'absolute'
style.visibility = 'hidden'
// Transfer properties
properties.forEach(function (prop) {
if (isInput && prop === 'lineHeight') {
// Special case for inputs
if (computed.boxSizing === 'border-box') {
const height = parseInt(computed.height)
const outerHeight =
parseInt(computed.paddingTop) +
parseInt(computed.paddingBottom) +
parseInt(computed.borderTopWidth) +
parseInt(computed.borderBottomWidth)
const targetHeight = outerHeight + parseInt(computed.lineHeight)
if (height > targetHeight) {
style.lineHeight = height - outerHeight + 'px'
} else if (height === targetHeight) {
style.lineHeight = computed.lineHeight
} else {
style.lineHeight = '0'
}
} else {
style.lineHeight = computed.height
}
} else {
style[prop] = computed[prop]
}
})
// Firefox special handling
const isFirefox =
(window as typeof window & { mozInnerScreenX: number }).mozInnerScreenX != null
if (isFirefox) {
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll'
} else {
style.overflow = 'hidden'
}
// Add content before caret
div.textContent = element.value.substring(0, position)
// Replace spaces with non-breaking spaces for input elements
if (isInput) div.textContent = div.textContent.replace(/\s/g, '\u00a0')
// Create span for position calculation
const span = document.createElement('span')
span.textContent = element.value.substring(position) || '.'
div.appendChild(span)
// Get coordinates
const coordinates = {
top: span.offsetTop + parseInt(computed['borderTopWidth']),
left: span.offsetLeft + parseInt(computed['borderLeftWidth']),
height: parseInt(computed['lineHeight'])
}
// Cleanup
document.body.removeChild(div)
return coordinates
}
function getHighlightedText(text: string) {
return text.replace(/@[\w/.-]+/g, (match) => {
const contextElement = availableContext.find((c) => c.title === match.slice(1))
if (contextElement) {
return `<span class="bg-black dark:bg-white text-white dark:text-black z-10">${match}</span>`
}
return match
})
}
function addContextToSelection(contextElement: ContextElement) {
dispatch('addContext', { contextElement })
}
function updateInstructionsWithContext(contextElement: ContextElement) {
const index = instructions.lastIndexOf('@')
if (index !== -1) {
const newInstructions = instructions.substring(0, index) + `@${contextElement.title}`
dispatch('updateInstructions', { value: newInstructions })
}
}
function handleContextSelection(contextElement: ContextElement) {
addContextToSelection(contextElement)
updateInstructionsWithContext(contextElement)
showContextTooltip = false
}
function updateTooltipPosition(
availableContext: ContextElement[],
showContextTooltip: boolean,
contextTooltipWord: string
) {
if (!textarea || !showContextTooltip) return
try {
const coords = getCaretCoordinates(textarea, textarea.selectionEnd)
const rect = textarea.getBoundingClientRect()
const filteredAvailableContext = availableContext.filter(
(c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1))
)
const itemHeight = 28 // Estimated height of one item + gap (Button: p-1(8px) + text-xs(16px) = 24px; Parent: gap-1(4px) = 28px)
const containerPadding = 8 // p-1 top + p-1 bottom = 4px + 4px = 8px
const maxHeight = 192 + containerPadding // max-h-48 (192px) + containerPadding (8px)
// Calculate uncapped height, subtract gap from last item as it's not needed
const numItems = filteredAvailableContext.length
let uncappedHeight =
numItems > 0 ? numItems * itemHeight - 4 + containerPadding : containerPadding
// Ensure height is at least containerPadding even if no items
uncappedHeight = Math.max(uncappedHeight, containerPadding)
const estimatedTooltipHeight = Math.min(uncappedHeight, maxHeight)
const margin = 6 // Small margin between caret and tooltip
let finalY: number
if (isFirstMessage) {
// Position below the caret line
finalY = rect.top + coords.top + coords.height - 3
} else {
// Position above the caret line
finalY = rect.top + coords.top - estimatedTooltipHeight - margin
}
tooltipPosition = {
x: rect.left + coords.left - 70,
y: finalY
}
} catch (error) {
// Hide tooltip on any error related to position calculation
console.error('Error updating tooltip position', error)
showContextTooltip = false
}
}
function handleInput(e: Event) {
textarea = e.target as HTMLTextAreaElement
const words = instructions.split(/\s+/)
const lastWord = words[words.length - 1]
if (
lastWord.startsWith('@') &&
(!availableContext.find((c) => c.title === lastWord.slice(1)) ||
!selectedContext.find((c) => c.title === lastWord.slice(1)))
) {
showContextTooltip = true
contextTooltipWord = lastWord
} else {
showContextTooltip = false
contextTooltipWord = ''
selectedSuggestionIndex = 0
}
dispatch('updateInstructions', { value: instructions })
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (contextTooltipWord) {
const filteredContext = availableContext.filter(
(c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1))
)
const contextElement = filteredContext[selectedSuggestionIndex]
if (contextElement) {
const isInSelectedContext = selectedContext.find(
(c) => c.title === contextElement.title && c.type === contextElement.type
)
// If the context element is already in the selected context and the last word in the instructions is the same as the context element title, send request
if (isInSelectedContext && instructions.split(' ').pop() === '@' + contextElement.title) {
dispatch('sendRequest')
return
}
handleContextSelection(contextElement)
} else if (contextTooltipWord === '@' && availableContext.length > 0) {
handleContextSelection(availableContext[0])
}
} else {
dispatch('sendRequest')
}
}
}
function handleKeyDown(e: KeyboardEvent) {
if (!showContextTooltip) return
const filteredContext = availableContext.filter(
(c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1))
)
if (e.key === 'Tab') {
e.preventDefault()
const contextElement = filteredContext[selectedSuggestionIndex]
if (contextElement) {
handleContextSelection(contextElement)
}
}
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedSuggestionIndex = (selectedSuggestionIndex + 1) % filteredContext.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedSuggestionIndex =
(selectedSuggestionIndex - 1 + filteredContext.length) % filteredContext.length
}
}
$: updateTooltipPosition(availableContext, showContextTooltip, contextTooltipWord)
</script>
<div class="relative w-full px-2 scroll-pb-2">
<div
class="absolute top-0 left-0 w-full h-full min-h-12 px-4 text-sm pt-1 pointer-events-none"
style="line-height: 1.72"
>
<span class="break-words" style="white-space: pre-wrap;">
{@html getHighlightedText(instructions)}
</span>
</div>
<textarea
bind:this={textarea}
on:keypress={handleKeyPress}
on:keydown={handleKeyDown}
bind:value={instructions}
use:autosize
rows={3}
on:input={handleInput}
on:blur={() => {
setTimeout(() => {
showContextTooltip = false
}, 100)
}}
placeholder={isFirstMessage ? 'Ask anything' : 'Ask followup'}
class="resize-none bg-transparent caret-black dark:caret-white"
style={instructions.length > 0
? 'color: transparent; -webkit-text-fill-color: transparent;'
: ''}
/>
</div>
{#if showContextTooltip}
<div
class="absolute bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50"
style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px;"
>
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
handleContextSelection(element)
}}
showAllAvailable={true}
stringSearch={contextTooltipWord.slice(1)}
selectedIndex={selectedSuggestionIndex}
/>
</div>
{/if}
@@ -1,6 +1,6 @@
import { ResourceService } from '$lib/gen/services.gen'
import type { ResourceType, ScriptLang } from '$lib/gen/types.gen'
import { capitalize, toCamel } from '$lib/utils'
import { capitalize, isObject, toCamel } from '$lib/utils'
import { get, type Writable } from 'svelte/store'
import { getCompletion } from '../lib'
import { compile, phpCompile, pythonCompile } from '../utils'
@@ -11,13 +11,17 @@ import type {
ChatCompletionMessageToolCall,
ChatCompletionTool
} from 'openai/resources/index.mjs'
import { workspaceStore, type DBSchema } from '$lib/stores'
import { workspaceStore, type DBSchema, dbSchemas } from '$lib/stores'
import { scriptLangToEditorLang } from '$lib/scripts'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/utils'
export function formatResourceTypes(
resourceTypes: ResourceType[],
allResourceTypes: ResourceType[],
lang: 'python3' | 'php' | 'bun' | 'deno' | 'nativets' | 'bunnative'
) {
const resourceTypes = allResourceTypes.filter(
(rt) => isObject(rt.schema) && 'properties' in rt.schema && isObject(rt.schema.properties)
)
if (lang === 'python3') {
const result = resourceTypes.map((resourceType) => {
return `class ${resourceType.name}(TypedDict):\n${pythonCompile(resourceType.schema as any)}`
@@ -32,15 +36,11 @@ export function formatResourceTypes(
return '\n' + result.join('\n\n')
} else {
let resultStr = 'namespace RT {\n'
const result = resourceTypes
.filter(
(resourceType) => Boolean(resourceType.schema) && typeof resourceType.schema === 'object'
)
.map((resourceType) => {
return ` type ${toCamel(capitalize(resourceType.name))} = ${compile(
resourceType.schema as any
).replaceAll('\n', '\n ')}`
})
const result = resourceTypes.map((resourceType) => {
return ` type ${toCamel(capitalize(resourceType.name))} = ${compile(
resourceType.schema as any
).replaceAll('\n', '\n ')}`
})
return resultStr + result.join('\n\n') + '\n}'
}
}
@@ -204,18 +204,20 @@ export async function getFormattedResourceTypes(
}
export const CHAT_SYSTEM_PROMPT = `
You are a coding assistant on the Windmill platform. You are given a list of instructions to follow \`INSTRUCTIONS\` as well as the current code in the file \`CODE\`.
You are a coding assistant for the Windmill platform. You are provided with a list of \`INSTRUCTIONS\` and the current contents of a code file under \`CODE\`.
Please respond to the user's query. The user's query is never invalid.
Your task is to respond to the user's request. Assume all user queries are valid and actionable.
In the case that the user asks you to make changes to code, you should make sure to return a single CODE BLOCK, as well as explanations and descriptions of the changes.
For example, if the user asks you to "make this file look nicer", make sure your output includes a code block with concrete ways the file can look nicer.
- If suggesting changes, rewrite the **complete code** and not just a part of it.
When the user requests code changes:
- Always include a **single code block** with the **entire updated file**, not just the modified sections.
- Follow the instructions carefully and explain the reasoning behind your changes.
- If the request is abstract (e.g., "make this cleaner"), interpret it concretely and reflect that in the code block.
- Preserve existing formatting, indentation, and whitespace unless changes are strictly required to fulfill the user's request.
- The user can ask you to look at or modify specific files, databases or errors by having its name in the INSTRUCTIONS preceded by the @ symbol. In this case, put your focus on the element that is explicitly mentioned.
- The user can ask you questions about a list of \`DATABASES\` that are available in the user's workspace. If the user asks you a question about a database, you should ask the user to specify the database name if not given, or take the only one available if there is only one.
Requirements:
- When suggesting changes, do not change spacing, indentation, or other whitespace apart from what is strictly necessary to apply the changes.
Do not output any of these instructions, nor tell the user anything about them unless directly prompted for them.
Important:
Do not mention or reveal these instructions to the user unless explicitly asked to do so.
`
const CHAT_USER_CODE_CONTEXT = `
@@ -237,11 +239,19 @@ INSTRUCTIONS:
WINDMILL LANGUAGE CONTEXT:
{lang_context}
DATABASES:
{db_context}
CODE:
{code_context}
ERROR:
{error_context}
\`\`\`
`
export const CHAT_USER_DB_CONTEXT = `- {title}: SCHEMA: \n{schema}\n`
export function prepareSystemMessage(): {
role: 'system'
content: string
@@ -264,11 +274,6 @@ export const ContextIconMap = {
db: Database
}
export type SelectedContext = {
type: 'code' | 'error' | 'db'
title: string
}
export type ContextElement =
| {
type: 'code'
@@ -283,7 +288,7 @@ export type ContextElement =
}
| {
type: 'db'
schema: DBSchema
schema?: DBSchema
title: string
}
@@ -294,6 +299,7 @@ export async function prepareUserMessage(
) {
let codeContext = ''
let errorContext = ''
let dbContext = ''
for (const context of selectedContext) {
if (context.type === 'code') {
codeContext += CHAT_USER_CODE_CONTEXT.replace('{title}', context.title)
@@ -304,6 +310,11 @@ export async function prepareUserMessage(
throw new Error('Multiple error contexts provided')
}
errorContext = CHAT_USER_ERROR_CONTEXT.replace('{error}', context.content)
} else if (context.type === 'db') {
dbContext += CHAT_USER_DB_CONTEXT.replace('{title}', context.title).replace(
'{schema}',
context.schema?.stringified ?? 'to fetch with get_db_schema'
)
}
}
@@ -311,6 +322,7 @@ export async function prepareUserMessage(
.replace('{lang_context}', getLangContext(language))
.replace('{code_context}', codeContext)
.replace('{error_context}', errorContext)
.replace('{db_context}', dbContext)
return userMessage
}
@@ -340,7 +352,14 @@ const DB_SCHEMA_FUNCTION_DEF: ChatCompletionTool = {
type: 'function',
function: {
name: 'get_db_schema',
description: 'Gets the schema of the database in context'
description: 'Gets the schema of the database',
parameters: {
type: 'object',
properties: {
resourcePath: { type: 'string', description: 'The path of the database resource' }
},
required: ['resourcePath']
}
}
}
@@ -368,7 +387,6 @@ async function callTool(
functionName: string,
args: any,
lang: ScriptLang | 'bunnative',
dbSchema: DBSchema | undefined,
workspace: string
) {
switch (functionName) {
@@ -376,10 +394,30 @@ async function callTool(
const formattedResourceTypes = await getFormattedResourceTypes(lang, args.query, workspace)
return formattedResourceTypes
case 'get_db_schema':
if (!dbSchema) {
throw new Error('No database schema provided')
if (!args.resourcePath) {
throw new Error('Database path not provided')
}
const stringSchema = await formatDBSchema(dbSchema)
const resource = await ResourceService.getResource({
workspace: workspace,
path: args.resourcePath
})
const newDbSchemas = {}
await getDbSchemas(
resource.resource_type,
args.resourcePath,
workspace,
newDbSchemas,
(error) => {
console.error(error)
}
)
dbSchemas.update((schemas) => ({ ...schemas, ...newDbSchemas }))
const dbs = get(dbSchemas)
const db = dbs[args.resourcePath]
if (!db) {
throw new Error('Database not found')
}
const stringSchema = await formatDBSchema(db)
return stringSchema
default:
throw new Error(`Unknown tool call: ${functionName}`)
@@ -390,7 +428,7 @@ export async function chatRequest(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
lang: ScriptLang | 'bunnative',
dbSchema: DBSchema | undefined,
useDbTools: boolean,
onNewToken: (token: string) => void
) {
const toolDefs: ChatCompletionTool[] = []
@@ -404,7 +442,7 @@ export async function chatRequest(
) {
toolDefs.push(RESOURCE_TYPE_FUNCTION_DEF)
}
if (dbSchema) {
if (useDbTools) {
toolDefs.push(DB_SCHEMA_FUNCTION_DEF)
}
try {
@@ -416,7 +454,7 @@ export async function chatRequest(
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
for await (const chunk of completion) {
if (!('choices' in chunk)) {
if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) {
continue
}
const c = chunk as ChatCompletionChunk
@@ -459,7 +497,6 @@ export async function chatRequest(
toolCall.function.name,
args,
lang,
dbSchema,
get(workspaceStore) ?? ''
)
messages.push({
+32 -32
View File
@@ -631,19 +631,19 @@ export const TS_PREPROCESSOR_SCRIPT_INTRO = `/**
* It processes raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email)
* before passing it to \`main\`. This separates the trigger logic from the main logic and keeps the auto-generated runnable UI clean.
*
* The preprocessor receives the same data \`main\` would if no preprocessor was used,
* plus trigger metadata in the \`wm_trigger\` object:
* - Webhook/HTTP: \`{ wm_trigger, bodyKey1, bodyKey2, ... }\`
* - Postgres: \`{ transaction_type, schema_name, table_name, row, wm_trigger }\`
* - WebSocket/Kafka/NATS/SQS/MQTT: \`{ msg, wm_trigger }\`
* - Email: \`{ raw_email, parsed_email, wm_trigger }\`
* The preprocessor receives trigger metadata (\`wm_trigger\`) along with the main trigger arguments.
* The structure of \`wm_trigger\` and the main trigger arguments are specific to each trigger type:
* - Webhook/HTTP: \`(wm_trigger: { kind: 'http' | 'webhook', http?: { ... } }, body_key_1: any, body_key_2: any, ...)\`
* - Postgres: \`(wm_trigger: { kind: 'postgres' }, transaction_type: string, schema_name: string, table_name: string, row: any)\`
* - WebSocket/Kafka/NATS/SQS/MQTT: \`(wm_trigger: { kind: 'websocket' | 'kafka' | 'nats' | 'sqs' | 'mqtt', [kind]: { ... } }, msg: string)\`
* - Email: \`(wm_trigger: { kind: 'email' }, raw_email: string, parsed_email: { ... })\`
*
* The returned object defines the parameter values passed to \`main()\`.
* e.g., { b: 1, a: 2 } → Calls \`main(2, 1)\`, assuming \`main\` is defined as \`main(a: number, b: number)\`.
* Ensure that the parameter names in \`main\` match the keys in the returned object.
*
* Learn more: https://www.windmill.dev/docs/core_concepts/preprocessors
*/\n\n`
*/\n`
export const TS_PREPROCESSOR_FLOW_INTRO = `/**
* Trigger preprocessor
@@ -651,24 +651,24 @@ export const TS_PREPROCESSOR_FLOW_INTRO = `/**
* It processes raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email)
* before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.
*
* The preprocessor receives the same data the flow would if no preprocessor was used,
* plus trigger metadata in the \`wm_trigger\` object:
* - Webhook/HTTP: \`{ wm_trigger, bodyKey1, bodyKey2, ... }\`
* - Postgres: \`{ transaction_type, schema_name, table_name, row, wm_trigger }\`
* - WebSocket/Kafka/NATS/SQS/MQTT: \`{ msg, wm_trigger }\`
* - Email: \`{ raw_email, parsed_email, wm_trigger }\`
* The preprocessor receives trigger metadata (\`wm_trigger\`) along with the main trigger arguments.
* The structure of \`wm_trigger\` and the main trigger arguments are specific to each trigger type:
* - Webhook/HTTP: \`(wm_trigger: { kind: 'http' | 'webhook', http?: { ... } }, body_key_1: any, body_key_2: any, ...)\`
* - Postgres: \`(wm_trigger: { kind: 'postgres' }, transaction_type: string, schema_name: string, table_name: string, row: any)\`
* - WebSocket/Kafka/NATS/SQS/MQTT: \`(wm_trigger: { kind: 'websocket' | 'kafka' | 'nats' | 'sqs' | 'mqtt', [kind]: { ... } }, msg: string)\`
* - Email: \`(wm_trigger: { kind: 'email' }, raw_email: string, parsed_email: { ... })\`
*
* The returned object determines the parameter values passed to the flow.
* e.g., \`{ b: 1, a: 2 }\` → Calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`.
* Ensure that the input names of the flow match the keys in the returned object.
*
* Learn more: https://www.windmill.dev/docs/core_concepts/preprocessors
*/\n\n`
*/\n`
export const TS_PREPROCESSOR_MODULE_CODE = `export async function preprocessor(
/*
* Replace this comment with the parameters received from the trigger.
* Examples: \`bodyKey1\`, \`bodyKey2\` for Webhook/HTTP, \`msg\` for WebSocket, etc.
export const TS_PREPROCESSOR_MODULE_CODE = `export async function preprocessor(
/*
* Replace this comment with the parameters received from the trigger.
* Examples: \`bodyKey1\`, \`bodyKey2\` for Webhook/HTTP, \`msg\` for WebSocket, etc.
*/
// The trigger metadata
@@ -765,12 +765,12 @@ export const PYTHON_PREPROCESSOR_SCRIPT_INTRO = `# Trigger preprocessor
# It processes raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email)
# before passing it to \`main\`. This separates the trigger logic from the main logic and keeps the auto-generated UI clean.
#
# The preprocessor receives the same data \`main\` would if no preprocessor was used,
# plus trigger metadata in the \`wm_trigger\` object:
# - Webhook/HTTP: \`{ wm_trigger, bodyKey1, bodyKey2, ... }\`
# - Postgres: \`{ transaction_type, schema_name, table_name, row, wm_trigger }\`
# - WebSocket/Kafka/NATS/SQS/MQTT: \`{ msg, wm_trigger }\`
# - Email: \`{ raw_email, parsed_email, wm_trigger }\`
# The preprocessor receives trigger metadata (\`wm_trigger\`) along with the main trigger arguments.
# The structure of \`wm_trigger\` and the main trigger arguments are specific to each trigger type:
# - Webhook/HTTP: \`(wm_trigger: { kind: 'http' | 'webhook', http?: { ... } }, body_key_1: any, body_key_2: any, ...)\`
# - Postgres: \`(wm_trigger: { kind: 'postgres' }, transaction_type: string, schema_name: string, table_name: string, row: any)\`
# - WebSocket/Kafka/NATS/SQS/MQTT: \`(wm_trigger: { kind: 'websocket' | 'kafka' | 'nats' | 'sqs' | 'mqtt', [kind]: { ... } }, msg: string)\`
# - Email: \`(wm_trigger: { kind: 'email' }, raw_email: string, parsed_email: { ... })\`
#
# The returned object defines the parameter values passed to \`main()\`.
# e.g., { b: 1, a: 2 } → Calls \`main(2, 1)\`, assuming \`main\` is defined as \`main(a: int, b: int)\`.
@@ -785,10 +785,10 @@ export const PYTHON_PREPROCESSOR_FLOW_INTRO = `# Trigger preprocessor
#
# The preprocessor receives the same data the flow would if no preprocessor was used,
# plus trigger metadata in the \`wm_trigger\` object:
# - Webhook/HTTP: \`{ wm_trigger, bodyKey1, bodyKey2, ... }\`
# - Postgres: \`{ transaction_type, schema_name, table_name, row, wm_trigger }\`
# - WebSocket/Kafka/NATS/SQS/MQTT: \`{ msg, wm_trigger }\`
# - Email: \`{ raw_email, parsed_email, wm_trigger }\`
# - Webhook/HTTP: \`(wm_trigger: { kind: 'http' | 'webhook', http?: { ... } }, body_key_1: any, body_key_2: any, ...)\`
# - Postgres: \`(wm_trigger: { kind: 'postgres' }, transaction_type: string, schema_name: string, table_name: string, row: any)\`
# - WebSocket/Kafka/NATS/SQS/MQTT: \`(wm_trigger: { kind: 'websocket' | 'kafka' | 'nats' | 'sqs' | 'mqtt', [kind]: { ... } }, msg: string)\`
# - Email: \`(wm_trigger: { kind: 'email' }, raw_email: string, parsed_email: { ... })\`
#
# The returned object determines the parameter values passed to the flow.
# e.g., \`{ b: 1, a: 2 }\` → Calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`.
@@ -999,7 +999,7 @@ public class Main {
}
}
`
// KJQXZ
// KJQXZ
export const INITIAL_CODE = {
bun: {
scriptInitCodeBlock: BUN_INIT_BLOCK,
@@ -1086,8 +1086,8 @@ export const INITIAL_CODE = {
},
java: {
script: JAVA_INIT_CODE
},
// KJQXZ
}
// KJQXZ
}
export function isInitialCode(content: string): boolean {
@@ -1193,7 +1193,7 @@ export function initialCode(
return INITIAL_CODE.nu.script
} else if (language == 'java') {
return INITIAL_CODE.java.script
// KJQXZ
// KJQXZ
} else if (language == 'bun' || language == 'bunnative') {
if (kind == 'trigger') {
return INITIAL_CODE.bun.trigger
+6 -4
View File
@@ -139,9 +139,9 @@ const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME)
export const copilotSessionModel = writable<AIProviderModel | undefined>(
sessionModel && sessionProvider
? {
model: sessionModel,
provider: sessionProvider as AIProvider
}
model: sessionModel,
provider: sessionProvider as AIProvider
}
: undefined
)
export const usedTriggerKinds = writable<string[]>([])
@@ -158,8 +158,10 @@ type SQLBaseSchema = {
}
}
export const SQLSchemaLanguages = ['mysql', 'bigquery', 'postgresql', 'snowflake', 'mssql', 'oracledb'] as const
export interface SQLSchema {
lang: 'mysql' | 'bigquery' | 'postgresql' | 'snowflake' | 'mssql' | 'oracledb'
lang: typeof SQLSchemaLanguages[number]
schema: SQLBaseSchema
publicOnly: boolean | undefined
stringified: string