mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 00:04:10 +00:00
feat: allow adding workspace scripts and flows as AI chat context (#7882)
* feat: allow adding workspace scripts and flows as AI chat context Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * cleaning * cleaning * cleaning * better --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.5
parent
c63fe9770d
commit
2d3202b151
@@ -110,30 +110,39 @@
|
||||
}
|
||||
|
||||
function addContextToSelection(contextElement: ContextElement) {
|
||||
if (!selectedContext || !availableContext) return
|
||||
|
||||
const alreadySelected = selectedContext.find(
|
||||
(c) => c.type === contextElement.type && c.title === contextElement.title
|
||||
)
|
||||
if (alreadySelected) return
|
||||
|
||||
// Workspace items are fetched on-demand and not in availableContext,
|
||||
// so skip the availableContext check for them
|
||||
const isWorkspaceItem =
|
||||
contextElement.type === 'workspace_script' || contextElement.type === 'workspace_flow'
|
||||
if (
|
||||
selectedContext &&
|
||||
availableContext &&
|
||||
!selectedContext.find(
|
||||
(c) => c.type === contextElement.type && c.title === contextElement.title
|
||||
) &&
|
||||
availableContext.find(
|
||||
!isWorkspaceItem &&
|
||||
!availableContext.find(
|
||||
(c) => c.type === contextElement.type && c.title === contextElement.title
|
||||
)
|
||||
) {
|
||||
selectedContext = [...selectedContext, contextElement]
|
||||
return
|
||||
}
|
||||
|
||||
// If it's a datatable table, add it to the app's whitelisted tables
|
||||
if (
|
||||
contextElement.type === 'app_datatable' &&
|
||||
aiChatManager.mode === AIMode.APP &&
|
||||
aiChatManager.appAiChatHelpers
|
||||
) {
|
||||
aiChatManager.appAiChatHelpers.addTableToWhitelist(
|
||||
contextElement.datatableName,
|
||||
contextElement.schemaName,
|
||||
contextElement.tableName
|
||||
)
|
||||
}
|
||||
selectedContext = [...selectedContext, contextElement]
|
||||
|
||||
// If it's a datatable table, add it to the app's whitelisted tables
|
||||
if (
|
||||
contextElement.type === 'app_datatable' &&
|
||||
aiChatManager.mode === AIMode.APP &&
|
||||
aiChatManager.appAiChatHelpers
|
||||
) {
|
||||
aiChatManager.appAiChatHelpers.addTableToWhitelist(
|
||||
contextElement.datatableName,
|
||||
contextElement.schemaName,
|
||||
contextElement.tableName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +377,10 @@
|
||||
addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
|
||||
@@ -2,13 +2,28 @@
|
||||
import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { FlowModule } from '$lib/gen/types.gen'
|
||||
import { ContextIconMap, type ContextElement } from './context'
|
||||
import { ArrowLeft, Diff, Database, ChevronRight } from 'lucide-svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { workspaceRunnablesSearch, MAX_RUNNABLE_CONTENT_LENGTH } from './shared'
|
||||
import {
|
||||
ContextIconMap,
|
||||
type ContextElement,
|
||||
type WorkspaceScriptElement,
|
||||
type WorkspaceFlowElement
|
||||
} from './context'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Diff,
|
||||
Database,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
availableContext: ContextElement[]
|
||||
selectedContext: ContextElement[]
|
||||
onSelect: (element: ContextElement) => void
|
||||
onSelectWorkspaceItem?: (element: ContextElement) => void
|
||||
setShowing?: (showing: boolean) => void
|
||||
showAllAvailable?: boolean
|
||||
stringSearch?: string
|
||||
@@ -19,6 +34,7 @@
|
||||
availableContext,
|
||||
selectedContext,
|
||||
onSelect,
|
||||
onSelectWorkspaceItem,
|
||||
setShowing,
|
||||
showAllAvailable = false,
|
||||
stringSearch = '',
|
||||
@@ -26,19 +42,34 @@
|
||||
}: Props = $props()
|
||||
|
||||
// Current view state: 'categories' or specific category type
|
||||
let currentView = $state<'categories' | 'diffs' | 'modules' | 'databases'>('categories')
|
||||
let currentView = $state<
|
||||
'categories' | 'diffs' | 'modules' | 'databases' | 'scripts' | 'flows'
|
||||
>('categories')
|
||||
|
||||
// Selected index for keyboard navigation
|
||||
let itemSelectedIndex = $state(0)
|
||||
let categorySelectedIndex = $state(0)
|
||||
|
||||
// Workspace search state
|
||||
let workspaceSearchQuery = $state('')
|
||||
let workspaceSearchResults = $state<{ path: string; summary: string }[]>([])
|
||||
let workspaceSearchLoading = $state(false)
|
||||
let searchInputElement = $state<HTMLInputElement | undefined>(undefined)
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
// Category definitions
|
||||
const categories = [
|
||||
{ id: 'diffs', label: 'Diffs', icon: Diff },
|
||||
{ id: 'modules', label: 'Modules', icon: BarsStaggered },
|
||||
{ id: 'databases', label: 'Databases', icon: Database }
|
||||
{ id: 'diffs' as const, label: 'Diffs', icon: Diff, searchable: false },
|
||||
{ id: 'modules' as const, label: 'Modules', icon: BarsStaggered, searchable: false },
|
||||
{ id: 'databases' as const, label: 'Databases', icon: Database, searchable: false },
|
||||
{ id: 'scripts' as const, label: 'Scripts', icon: Code2, searchable: true },
|
||||
{ id: 'flows' as const, label: 'Flows', icon: BarsStaggered, searchable: true }
|
||||
]
|
||||
|
||||
const isSearchableView = $derived(
|
||||
currentView === 'scripts' || currentView === 'flows'
|
||||
)
|
||||
|
||||
const filteredAvailableContext = $derived(
|
||||
availableContext.filter((context) => {
|
||||
const filtered =
|
||||
@@ -68,12 +99,14 @@
|
||||
})
|
||||
|
||||
const currentCategoryItems = $derived(
|
||||
currentView !== 'categories' ? contextByCategory[currentView] : []
|
||||
currentView !== 'categories' && !isSearchableView ? contextByCategory[currentView] : []
|
||||
)
|
||||
|
||||
// Filter to only show categories with items
|
||||
// Filter to only show categories with items (non-searchable) or always show (searchable)
|
||||
const availableCategories = $derived(
|
||||
categories.filter((cat) => contextByCategory[cat.id].length > 0)
|
||||
categories.filter(
|
||||
(cat) => cat.searchable || contextByCategory[cat.id]?.length > 0
|
||||
)
|
||||
)
|
||||
|
||||
// Report view changes
|
||||
@@ -81,6 +114,8 @@
|
||||
if (onViewChange) {
|
||||
if (currentView === 'categories') {
|
||||
onViewChange(availableCategories.length)
|
||||
} else if (isSearchableView) {
|
||||
onViewChange(workspaceSearchResults.length + 2) // +2 for back button and search input
|
||||
} else {
|
||||
onViewChange(currentCategoryItems.length + 1)
|
||||
}
|
||||
@@ -89,15 +124,126 @@
|
||||
|
||||
function handleCategoryClick(categoryId: string) {
|
||||
currentView = categoryId as typeof currentView
|
||||
itemSelectedIndex = 0
|
||||
if (categoryId === 'scripts' || categoryId === 'flows') {
|
||||
workspaceSearchQuery = ''
|
||||
workspaceSearchResults = []
|
||||
setTimeout(() => searchInputElement?.focus(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackClick() {
|
||||
currentView = 'categories'
|
||||
itemSelectedIndex = 0
|
||||
workspaceSearchQuery = ''
|
||||
workspaceSearchResults = []
|
||||
}
|
||||
|
||||
async function searchWorkspaceItems(query: string) {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace) return
|
||||
|
||||
workspaceSearchLoading = true
|
||||
try {
|
||||
const type = currentView === 'scripts' ? 'scripts' : 'flows'
|
||||
const results = await workspaceRunnablesSearch.search(query, workspace, type)
|
||||
workspaceSearchResults = results.map((r) => ({
|
||||
path: r.path,
|
||||
summary: r.summary
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error searching workspace items', err)
|
||||
workspaceSearchResults = []
|
||||
} finally {
|
||||
workspaceSearchLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput() {
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
searchWorkspaceItems(workspaceSearchQuery)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleWorkspaceItemSelect(path: string) {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace || !onSelectWorkspaceItem) return
|
||||
|
||||
try {
|
||||
if (currentView === 'scripts') {
|
||||
const script = await workspaceRunnablesSearch.getScript(path, workspace)
|
||||
const element: WorkspaceScriptElement & { deletable: boolean } = {
|
||||
type: 'workspace_script',
|
||||
path: script.path,
|
||||
title: script.path,
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
content: script.content,
|
||||
schema: script.schema,
|
||||
deletable: true
|
||||
}
|
||||
onSelectWorkspaceItem(element)
|
||||
} else if (currentView === 'flows') {
|
||||
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
|
||||
const flowValue = JSON.stringify(flow.value, null, 2)
|
||||
const truncatedValue =
|
||||
flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH
|
||||
? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
|
||||
: flowValue
|
||||
const element: WorkspaceFlowElement & { deletable: boolean } = {
|
||||
type: 'workspace_flow',
|
||||
path: flow.path,
|
||||
title: flow.path,
|
||||
summary: flow.summary,
|
||||
description: flow.description || '',
|
||||
value: truncatedValue,
|
||||
schema: flow.schema,
|
||||
deletable: true
|
||||
}
|
||||
onSelectWorkspaceItem(element)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching workspace item', err)
|
||||
}
|
||||
currentView = 'categories'
|
||||
workspaceSearchQuery = ''
|
||||
workspaceSearchResults = []
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (stringSearch.length > 0) {
|
||||
if (isSearchableView) {
|
||||
// Navigation in workspace search view
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (workspaceSearchResults.length > 0) {
|
||||
itemSelectedIndex = (itemSelectedIndex + 1) % workspaceSearchResults.length
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (workspaceSearchResults.length > 0) {
|
||||
itemSelectedIndex =
|
||||
(itemSelectedIndex - 1 + workspaceSearchResults.length) %
|
||||
workspaceSearchResults.length
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
// Only select if not typing in the search input, or if results exist
|
||||
if (workspaceSearchResults.length > 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const selectedItem = workspaceSearchResults[itemSelectedIndex]
|
||||
if (selectedItem) {
|
||||
handleWorkspaceItemSelect(selectedItem.path)
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBackClick()
|
||||
}
|
||||
} else if (stringSearch.length > 0) {
|
||||
// Navigation in search view (flat list)
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
@@ -188,13 +334,24 @@
|
||||
itemSelectedIndex = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger initial search when entering a searchable category
|
||||
$effect(() => {
|
||||
if (isSearchableView) {
|
||||
searchWorkspaceItems('')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 text-primary text-xs p-1 pr-0 min-w-24 max-h-48 overflow-y-scroll"
|
||||
onmousedown={(e) =>
|
||||
onmousedown={(e) => {
|
||||
// avoids triggering onblur on the textinput and closing the tooltip
|
||||
e.preventDefault()}
|
||||
// but allow input elements to receive focus for the search input
|
||||
if (!(e.target instanceof HTMLInputElement)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
role="listbox"
|
||||
tabindex={0}
|
||||
>
|
||||
@@ -245,6 +402,68 @@
|
||||
{#if availableCategories.length === 0}
|
||||
<div class="text-center text-primary text-xs py-2">No available context</div>
|
||||
{/if}
|
||||
{:else if isSearchableView}
|
||||
<!-- Workspace search view (scripts/flows) -->
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md text-left flex flex-row gap-1 items-center font-normal transition-colors mb-1"
|
||||
onclick={handleBackClick}
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
<span class="text-xs">Go back</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
bind:this={searchInputElement}
|
||||
bind:value={workspaceSearchQuery}
|
||||
oninput={handleSearchInput}
|
||||
type="text"
|
||||
placeholder="Search {currentView}..."
|
||||
class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-gray-700 bg-surface mb-1 outline-none focus:border-blue-500"
|
||||
/>
|
||||
|
||||
{#if workspaceSearchLoading}
|
||||
<div class="flex items-center justify-center py-2 gap-1">
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
<span class="text-xs text-secondary">Searching...</span>
|
||||
</div>
|
||||
{:else if workspaceSearchResults.length === 0}
|
||||
<div class="text-center text-secondary text-xs py-2">
|
||||
No results found
|
||||
</div>
|
||||
{:else}
|
||||
{#each workspaceSearchResults as item, i}
|
||||
{@const isAlreadySelected = selectedContext.some(
|
||||
(c) =>
|
||||
((c.type === 'workspace_script' && currentView === 'scripts') ||
|
||||
(c.type === 'workspace_flow' && currentView === 'flows')) &&
|
||||
c.title === item.path
|
||||
)}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-col font-normal transition-colors {i ===
|
||||
itemSelectedIndex
|
||||
? 'bg-surface-hover'
|
||||
: ''} {isAlreadySelected ? 'opacity-50' : ''}"
|
||||
onclick={() => {
|
||||
if (!isAlreadySelected) {
|
||||
handleWorkspaceItemSelect(item.path)
|
||||
}
|
||||
}}
|
||||
disabled={isAlreadySelected}
|
||||
>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if currentView === 'scripts'}
|
||||
<Code2 size={14} class="shrink-0" />
|
||||
{:else}
|
||||
<BarsStaggered size={14} class="shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate">{item.path}</span>
|
||||
</div>
|
||||
{#if item.summary}
|
||||
<span class="truncate text-secondary pl-5">{item.summary}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Category items view -->
|
||||
<button
|
||||
|
||||
@@ -156,8 +156,11 @@
|
||||
|
||||
function getHighlightedText(text: string) {
|
||||
return text.replace(/@[\w/.\-\[\]]+/g, (match) => {
|
||||
const contextElement = availableContext.find((c) => c.title === match.slice(1))
|
||||
if (contextElement) {
|
||||
const title = match.slice(1)
|
||||
const inContext =
|
||||
availableContext.find((c) => c.title === title) ||
|
||||
selectedContext.find((c) => c.title === title)
|
||||
if (inContext) {
|
||||
return `<span class="bg-black dark:bg-white text-white dark:text-black z-10">${match}</span>`
|
||||
}
|
||||
return match
|
||||
@@ -316,6 +319,10 @@
|
||||
oninput={handleInput}
|
||||
onblur={() => {
|
||||
setTimeout(() => {
|
||||
// Don't close if focus moved to inside the tooltip (e.g., search input)
|
||||
if (tooltipElement?.contains(document.activeElement)) {
|
||||
return
|
||||
}
|
||||
showContextTooltip = false
|
||||
}, 200)
|
||||
}}
|
||||
@@ -342,6 +349,13 @@
|
||||
onSelect={(element) => {
|
||||
handleContextSelection(element)
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
onAddContext(element)
|
||||
updateInstructionsWithContext(element)
|
||||
showContextTooltip = false
|
||||
// Refocus the textarea since focus may have been on the search input
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
showAllAvailable={true}
|
||||
stringSearch={contextTooltipWord.slice(1)}
|
||||
onViewChange={(newNumber) => {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Code, Database, TriangleAlert, Diff, FileCode, Code2, TextSelect, Table2 } from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen/types.gen'
|
||||
import {
|
||||
Code,
|
||||
Database,
|
||||
TriangleAlert,
|
||||
Diff,
|
||||
FileCode,
|
||||
Code2,
|
||||
TextSelect,
|
||||
Table2
|
||||
} from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { ScriptLang, Script, OpenFlow } from '$lib/gen/types.gen'
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import { type Change } from 'diff'
|
||||
import type { BackendRunnable } from './app/core'
|
||||
@@ -13,7 +23,9 @@ export const ContextIconMap = {
|
||||
app_frontend_file: FileCode,
|
||||
app_backend_runnable: Code2,
|
||||
app_code_selection: TextSelect,
|
||||
app_datatable: Table2
|
||||
app_datatable: Table2,
|
||||
workspace_script: Code2,
|
||||
workspace_flow: BarsStaggered
|
||||
// flow_module type is handled with FlowModuleIcon
|
||||
}
|
||||
|
||||
@@ -128,6 +140,23 @@ export interface AppDatatableElement {
|
||||
columns: Record<string, string>
|
||||
}
|
||||
|
||||
/** Workspace script context element — reference to a script in the workspace */
|
||||
export interface WorkspaceScriptElement
|
||||
extends Pick<Script, 'path' | 'summary' | 'language' | 'content' | 'schema'> {
|
||||
type: 'workspace_script'
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Workspace flow context element — reference to a flow in the workspace */
|
||||
export interface WorkspaceFlowElement extends Pick<OpenFlow, 'summary' | 'schema'> {
|
||||
type: 'workspace_flow'
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
/** Full flow value, JSON-stringified and possibly truncated */
|
||||
value: string
|
||||
}
|
||||
|
||||
export type ContextElement = (
|
||||
| CodeElement
|
||||
| ErrorElement
|
||||
@@ -140,6 +169,8 @@ export type ContextElement = (
|
||||
| AppBackendRunnableElement
|
||||
| AppCodeSelectionElement
|
||||
| AppDatatableElement
|
||||
| WorkspaceScriptElement
|
||||
| WorkspaceFlowElement
|
||||
) & {
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ export function buildContextString(selectedContext: ContextElement[]): string {
|
||||
let hasDiff = false
|
||||
let hasFlowModule = false
|
||||
let hasError = false
|
||||
let workspaceItemsContext = ''
|
||||
|
||||
let result = '\n\n'
|
||||
for (const context of selectedContext) {
|
||||
@@ -311,6 +312,22 @@ export function buildContextString(selectedContext: ContextElement[]): string {
|
||||
} else if (context.type === 'flow_module') {
|
||||
hasFlowModule = true
|
||||
flowModuleContext += `${context.id}\n`
|
||||
} else if (context.type === 'workspace_script') {
|
||||
workspaceItemsContext += `\nWORKSPACE SCRIPT (${context.path}):\n`
|
||||
workspaceItemsContext += `Summary: ${context.summary}\n`
|
||||
workspaceItemsContext += `Language: ${context.language}\n`
|
||||
if (context.schema) {
|
||||
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
|
||||
}
|
||||
workspaceItemsContext += `Code:\n${context.content}\n`
|
||||
} else if (context.type === 'workspace_flow') {
|
||||
workspaceItemsContext += `\nWORKSPACE FLOW (${context.path}):\n`
|
||||
workspaceItemsContext += `Summary: ${context.summary}\n`
|
||||
workspaceItemsContext += `Description: ${context.description}\n`
|
||||
if (context.schema) {
|
||||
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
|
||||
}
|
||||
workspaceItemsContext += `Value:\n${context.value}\n`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +346,9 @@ export function buildContextString(selectedContext: ContextElement[]): string {
|
||||
if (hasFlowModule) {
|
||||
result += '\n' + flowModuleContext
|
||||
}
|
||||
if (workspaceItemsContext) {
|
||||
result += '\n' + workspaceItemsContext
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -668,7 +688,7 @@ export async function buildSchemaForTool(
|
||||
// Constants for result formatting
|
||||
const MAX_RESULT_LENGTH = 12000
|
||||
const MAX_LOG_LENGTH = 4000
|
||||
const MAX_RUNNABLE_CONTENT_LENGTH = 20000
|
||||
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
|
||||
|
||||
export interface TestRunConfig {
|
||||
jobStarter: () => Promise<string>
|
||||
@@ -906,6 +926,9 @@ export class WorkspaceRunnablesSearch {
|
||||
private flowsWorkspace: string | undefined = undefined
|
||||
private scripts: Script[] | undefined = undefined
|
||||
private flows: Flow[] | undefined = undefined
|
||||
private scriptCache: Map<string, Awaited<ReturnType<typeof ScriptService.getScriptByPath>>> =
|
||||
new Map()
|
||||
private flowCache: Map<string, Awaited<ReturnType<typeof FlowService.getFlowByPath>>> = new Map()
|
||||
|
||||
constructor() {
|
||||
this.uf = new uFuzzy()
|
||||
@@ -930,10 +953,19 @@ export class WorkspaceRunnablesSearch {
|
||||
const scripts = this.scripts
|
||||
if (!scripts) return []
|
||||
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return scripts.map((s) => ({
|
||||
type: 'script' as const,
|
||||
path: s.path,
|
||||
summary: s.summary
|
||||
}))
|
||||
}
|
||||
|
||||
const haystack = scripts.map((s) =>
|
||||
emptyString(s.summary) ? s.path : s.summary + ' (' + s.path + ')'
|
||||
)
|
||||
const [idxs, , order] = this.uf.search(haystack, query.trim())
|
||||
const [idxs, , order] = this.uf.search(haystack, trimmed)
|
||||
if (!idxs || !order) return []
|
||||
return order.map((orderIdx) => {
|
||||
const haystackIdx = idxs[orderIdx]
|
||||
@@ -950,10 +982,19 @@ export class WorkspaceRunnablesSearch {
|
||||
const flows = this.flows
|
||||
if (!flows) return []
|
||||
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return flows.map((f) => ({
|
||||
type: 'flow' as const,
|
||||
path: f.path,
|
||||
summary: f.summary
|
||||
}))
|
||||
}
|
||||
|
||||
const haystack = flows.map((f) =>
|
||||
emptyString(f.summary) ? f.path : f.summary + ' (' + f.path + ')'
|
||||
)
|
||||
const [idxs, , order] = this.uf.search(haystack, query.trim())
|
||||
const [idxs, , order] = this.uf.search(haystack, trimmed)
|
||||
if (!idxs || !order) return []
|
||||
return order.map((orderIdx) => {
|
||||
const haystackIdx = idxs[orderIdx]
|
||||
@@ -977,6 +1018,26 @@ export class WorkspaceRunnablesSearch {
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async getScript(path: string, workspace: string) {
|
||||
const key = `${workspace}:${path}`
|
||||
let cached = this.scriptCache.get(key)
|
||||
if (!cached) {
|
||||
cached = await ScriptService.getScriptByPath({ workspace, path })
|
||||
this.scriptCache.set(key, cached)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
async getFlow(path: string, workspace: string) {
|
||||
const key = `${workspace}:${path}`
|
||||
let cached = this.flowCache.get(key)
|
||||
if (!cached) {
|
||||
cached = await FlowService.getFlowByPath({ workspace, path })
|
||||
this.flowCache.set(key, cached)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
const searchWorkspaceSchema = z.object({
|
||||
@@ -996,7 +1057,7 @@ const searchWorkspaceToolDef = createToolDef(
|
||||
'Search for scripts and flows in the workspace. Use this when a user asks about existing building blocks, wants to find a script/flow, or asks "what do I have for X". ALWAYS search really broadly.'
|
||||
)
|
||||
|
||||
const workspaceRunnablesSearch = new WorkspaceRunnablesSearch()
|
||||
export const workspaceRunnablesSearch = new WorkspaceRunnablesSearch()
|
||||
|
||||
export const createSearchWorkspaceTool = () => ({
|
||||
def: searchWorkspaceToolDef,
|
||||
@@ -1069,7 +1130,7 @@ export const createGetRunnableDetailsTool = () => ({
|
||||
|
||||
try {
|
||||
if (type === 'script') {
|
||||
const script = await ScriptService.getScriptByPath({ workspace, path })
|
||||
const script = await workspaceRunnablesSearch.getScript(path, workspace)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Retrieved script details for "${path}"`
|
||||
})
|
||||
@@ -1091,7 +1152,7 @@ export const createGetRunnableDetailsTool = () => ({
|
||||
2
|
||||
)
|
||||
} else {
|
||||
const flow = await FlowService.getFlowByPath({ workspace, path })
|
||||
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Retrieved flow details for "${path}"`
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user