+ import Popover from '$lib/components/meltComponents/Popover.svelte'
+ import AvailableContextList from './AvailableContextList.svelte'
+ import ContextElementBadge from './ContextElementBadge.svelte'
+ import ContextTextarea from './ContextTextarea.svelte'
+ import autosize from '$lib/autosize'
+ import type { ContextElement } from './context'
+ import { aiChatManager } from './AIChatManager.svelte'
+
+ interface Props {
+ availableContext: ContextElement[]
+ selectedContext: ContextElement[]
+ isFirstMessage?: boolean
+ disabled?: boolean
+ placeholder?: string
+ initialInstructions?: string
+ editingMessageIndex?: number | null
+ onEditEnd?: () => void
+ }
+
+ let {
+ availableContext,
+ selectedContext = $bindable([]),
+ disabled = false,
+ isFirstMessage = false,
+ placeholder = 'Ask anything',
+ initialInstructions = '',
+ editingMessageIndex = null,
+ onEditEnd = () => {}
+ }: Props = $props()
+
+ let contextTextareaComponent: ContextTextarea | undefined = $state()
+ let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state()
+ let instructions = $state(initialInstructions)
+
+ export function focusInput() {
+ if (aiChatManager.mode === 'script') {
+ contextTextareaComponent?.focus()
+ } else {
+ instructionsTextareaComponent?.focus()
+ }
+ }
+
+ function clickOutside(node: HTMLElement) {
+ function handleClick(event: MouseEvent) {
+ if (node && !node.contains(event.target as Node) && editingMessageIndex !== null) {
+ onEditEnd()
+ }
+ }
+
+ document.addEventListener('click', handleClick, true)
+ return {
+ destroy() {
+ document.removeEventListener('click', handleClick, true)
+ }
+ }
+ }
+
+ function addContextToSelection(contextElement: ContextElement) {
+ if (
+ selectedContext &&
+ availableContext &&
+ !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]
+ }
+ }
+
+ function sendRequest() {
+ if (aiChatManager.loading) {
+ return
+ }
+ if (editingMessageIndex !== null) {
+ aiChatManager.restartGeneration(editingMessageIndex, instructions)
+ onEditEnd()
+ } else {
+ aiChatManager.sendRequest({ instructions })
+ instructions = ''
+ }
+ }
+
+ $effect(() => {
+ if (editingMessageIndex !== null) {
+ focusInput()
+ }
+ })
+
+
+
+ {#if aiChatManager.mode === 'script'}
+
+
addContextToSelection(contextElement)}
+ onSendRequest={() => {
+ sendRequest()
+ }}
+ {disabled}
+ onEscape={onEditEnd}
+ />
+ {:else}
+
+
+
+ {/if}
+
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
index 2480304522..ebff218afe 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
+++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts
@@ -32,7 +32,6 @@ import type { DBSchemas } from '$lib/stores'
import { askTools, prepareAskSystemMessage } from './ask/core'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
-
export enum AIMode {
SCRIPT = 'script',
FLOW = 'flow',
@@ -217,6 +216,32 @@ class AIChatManager {
}
}
+ retryRequest = (messageIndex: number) => {
+ const message = this.displayMessages[messageIndex]
+ if (message && message.role === 'user') {
+ this.restartGeneration(messageIndex)
+ message.error = false
+ } else {
+ throw new Error('No user message found at the specified index')
+ }
+ }
+
+ private getLastUserMessage = () => {
+ for (let i = this.displayMessages.length - 1; i >= 0; i--) {
+ const message = this.displayMessages[i]
+ if (message.role === 'user') {
+ return message
+ }
+ }
+ }
+
+ private flagLastMessageAsError = () => {
+ const lastUserMessage = this.getLastUserMessage()
+ if (lastUserMessage) {
+ lastUserMessage.error = true
+ }
+ }
+
private chatRequest = async ({
messages,
abortController,
@@ -403,7 +428,8 @@ class AIChatManager {
role: 'user',
content: this.instructions,
contextElements: this.mode === AIMode.SCRIPT ? oldSelectedContext : undefined,
- snapshot
+ snapshot,
+ index: this.messages.length // matching with actual messages index. not -1 because it's not yet added to the messages array
}
]
const oldInstructions = this.instructions
@@ -423,8 +449,8 @@ class AIChatManager {
: this.mode === AIMode.NAVIGATOR
? prepareNavigatorUserMessage(oldInstructions)
: await prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
- isPreprocessor
- })
+ isPreprocessor
+ })
this.messages.push(userMessage)
await this.historyManager.saveChat(this.displayMessages, this.messages)
@@ -478,6 +504,7 @@ class AIChatManager {
await this.historyManager.saveChat(this.displayMessages, this.messages)
} catch (err) {
console.error(err)
+ this.flagLastMessageAsError()
if (err instanceof Error) {
sendUserToast('Failed to send request: ' + err.message, true)
} else {
@@ -492,7 +519,7 @@ class AIChatManager {
this.abortController?.abort()
}
- restartLastGeneration = (displayMessageIndex: number) => {
+ restartGeneration = (displayMessageIndex: number, newContent?: string) => {
const userMessage = this.displayMessages[displayMessageIndex]
if (!userMessage || userMessage.role !== 'user') {
@@ -502,23 +529,17 @@ class AIChatManager {
// Remove all messages including and after the specified user message
this.displayMessages = this.displayMessages.slice(0, displayMessageIndex)
- // Find the last user message in actual messages and remove it and everything after it
- let lastActualUserMessageIndex = -1
- for (let i = this.messages.length - 1; i >= 0; i--) {
- if (this.messages[i].role === 'user') {
- lastActualUserMessageIndex = i
- break
- }
- }
+ // Find corresponding message in actual messages and remove it and everything after it
+ let actualMessageIndex = this.messages.findIndex((_, i) => i === userMessage.index)
- if (lastActualUserMessageIndex === -1) {
+ if (actualMessageIndex === -1) {
throw new Error('No actual user message found to restart from')
}
- this.messages = this.messages.slice(0, lastActualUserMessageIndex)
+ this.messages = this.messages.slice(0, actualMessageIndex)
// Resend the request with the same instructions
- this.instructions = userMessage.content
+ this.instructions = newContent ?? userMessage.content
this.sendRequest()
}
@@ -648,15 +669,15 @@ class AIChatManager {
const editorRelated =
currentEditor && currentEditor.type === 'script' && currentEditor.stepId === module.id
? {
- diffMode: currentEditor.diffMode,
- lastDeployedCode: currentEditor.lastDeployedCode,
- lastSavedCode: undefined
- }
+ diffMode: currentEditor.diffMode,
+ lastDeployedCode: currentEditor.lastDeployedCode,
+ lastSavedCode: undefined
+ }
: {
- diffMode: false,
- lastDeployedCode: undefined,
- lastSavedCode: undefined
- }
+ diffMode: false,
+ lastDeployedCode: undefined,
+ lastSavedCode: undefined
+ }
return {
args: moduleState?.previewArgs ?? {},
diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte
new file mode 100644
index 0000000000..66f6585a40
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte
@@ -0,0 +1,112 @@
+
+
+
0 && 'mt-6',
+ 'mb-2',
+ message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
+ )}
+ role="button"
+ tabindex="0"
+ onclick={() => editMessage()}
+ onkeydown={() => {}}
+>
+ {#if message.role === 'user' && message.contextElements && editingMessageIndex !== messageIndex}
+
+ {#each message.contextElements as element}
+
+ {/each}
+
+ {/if}
+ {#if message.role === 'user' && editingMessageIndex === messageIndex}
+
(editingMessageIndex = null)}
+ />
+ {:else}
+
+ {#if message.role === 'assistant'}
+
+ {:else}
+ {message.content}
+ {/if}
+
+ {/if}
+ {#if message.role === 'user' && message.snapshot}
+
+ Saved a flow snapshot
+
+
+ {/if}
+
+{#if message.role === 'user' && message.error}
+
+
+
+{/if}
diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte
index cfec386ba3..b348862048 100644
--- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte
+++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte
@@ -3,28 +3,31 @@
import { tick } from 'svelte'
import type { ContextElement } from './context'
import AvailableContextList from './AvailableContextList.svelte'
- import { aiChatManager } from './AIChatManager.svelte'
import Portal from '$lib/components/Portal.svelte'
import { zIndexes } from '$lib/zIndexes'
interface Props {
+ value: string
availableContext: ContextElement[]
selectedContext: ContextElement[]
isFirstMessage: boolean
+ placeholder: string
disabled: boolean
- onUpdateInstructions: (value: string) => void
onSendRequest: () => void
onAddContext: (contextElement: ContextElement) => void
+ onEscape: () => void
}
- const {
+ let {
+ value = $bindable(''),
availableContext,
selectedContext,
isFirstMessage,
+ placeholder,
disabled,
- onUpdateInstructions,
onSendRequest,
- onAddContext
+ onAddContext,
+ onEscape
}: Props = $props()
let showContextTooltip = $state(false)
@@ -163,11 +166,10 @@
}
function updateInstructionsWithContext(contextElement: ContextElement) {
- const index = aiChatManager.instructions.lastIndexOf('@')
+ const index = value.lastIndexOf('@')
if (index !== -1) {
- const newInstructions =
- aiChatManager.instructions.substring(0, index) + `@${contextElement.title}`
- onUpdateInstructions(newInstructions)
+ const newInstructions = value.substring(0, index) + `@${contextElement.title}`
+ value = newInstructions
}
}
@@ -252,7 +254,7 @@
function handleInput(e: Event) {
textarea = e.target as HTMLTextAreaElement
- const words = aiChatManager.instructions.split(/\s+/)
+ const words = value.split(/\s+/)
const lastWord = words[words.length - 1]
if (
@@ -267,7 +269,6 @@
contextTooltipWord = ''
selectedSuggestionIndex = 0
}
- onUpdateInstructions(aiChatManager.instructions)
}
function handleKeyPress(e: KeyboardEvent) {
@@ -283,10 +284,7 @@
(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 &&
- aiChatManager.instructions.split(' ').pop() === '@' + contextElement.title
- ) {
+ if (isInSelectedContext && value.split(' ').pop() === '@' + contextElement.title) {
onSendRequest()
return
}
@@ -301,6 +299,10 @@
}
function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ onEscape()
+ }
+
if (!showContextTooltip) return
const filteredContext = availableContext.filter(
@@ -337,14 +339,14 @@
diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts
index f1fa174f38..e0ef74bb40 100644
--- a/frontend/src/lib/components/copilot/chat/shared.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.ts
@@ -8,18 +8,29 @@ import type { ContextElement } from './context'
import { workspaceStore } from '$lib/stores'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
-export type DisplayMessage =
- | {
- role: 'user' | 'assistant'
- content: string
- contextElements?: ContextElement[]
- snapshot?: ExtendedOpenFlow
- }
- | {
- role: 'tool'
- tool_call_id: string
- content: string
- }
+type BaseDisplayMessage = {
+ content: string
+ contextElements?: ContextElement[]
+ snapshot?: ExtendedOpenFlow
+}
+
+export type UserDisplayMessage = BaseDisplayMessage & {
+ role: 'user'
+ index: number // Used to match index with actual chat messages
+ error?: boolean
+}
+
+export type ToolDisplayMessage = {
+ role: 'tool'
+ tool_call_id: string
+ content: string
+}
+
+export type AssistantDisplayMessage = BaseDisplayMessage & {
+ role: 'assistant'
+}
+
+export type DisplayMessage = UserDisplayMessage | ToolDisplayMessage | AssistantDisplayMessage
async function callTool
({
tools,