mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add session chat slash commands (#9748)
This commit is contained in:
@@ -325,9 +325,10 @@ export class AIChatManager {
|
||||
sessionId: string | undefined = undefined
|
||||
|
||||
// Workspace AI skills (name + description) advertised in the GLOBAL system
|
||||
// prompt. Loaded asynchronously when entering GLOBAL mode; the system message
|
||||
// is rebuilt once they resolve.
|
||||
private globalSkills: AiSkillListItem[] = []
|
||||
// prompt and surfaced as slash commands in session chat. Loaded
|
||||
// asynchronously when entering GLOBAL mode; the system message is rebuilt
|
||||
// once they resolve.
|
||||
globalSkills = $state<AiSkillListItem[]>([])
|
||||
private globalSkillsRefreshId = 0
|
||||
|
||||
allowedModes: Record<AIMode, boolean> = $derived({
|
||||
@@ -844,7 +845,7 @@ export class AIChatManager {
|
||||
// Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild
|
||||
// the system message so the next chat-loop iteration advertises them. Ignore
|
||||
// stale resolves so workspace changes cannot overwrite newer skills.
|
||||
private refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
|
||||
refreshGlobalSkills = async (workspace = get(workspaceStore) ?? '') => {
|
||||
const refreshId = ++this.globalSkillsRefreshId
|
||||
const skills = await loadWorkspaceSkills(workspace)
|
||||
if (refreshId !== this.globalSkillsRefreshId) {
|
||||
@@ -859,6 +860,22 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
private expandGlobalSkillCommand = (instructions: string): string => {
|
||||
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
|
||||
return instructions
|
||||
}
|
||||
const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions)
|
||||
if (!match) {
|
||||
return instructions
|
||||
}
|
||||
const skill = this.globalSkills.find((s) => s.name === match[1])
|
||||
if (!skill) {
|
||||
return instructions
|
||||
}
|
||||
const rest = match[2]?.trim()
|
||||
return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.`
|
||||
}
|
||||
|
||||
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
|
||||
|
||||
private changeModeTool = {
|
||||
@@ -1355,6 +1372,10 @@ export class AIChatManager {
|
||||
// The LLM gets the full pasted content; the display message above keeps
|
||||
// the compact tokens + registry so the bubble can render/expand chips.
|
||||
const oldInstructions = expanded(chatDraft(this.instructions, pastes))
|
||||
const modelInstructions =
|
||||
this.mode === AIMode.GLOBAL
|
||||
? this.expandGlobalSkillCommand(oldInstructions)
|
||||
: oldInstructions
|
||||
this.instructions = ''
|
||||
|
||||
if (this.mode === AIMode.SCRIPT && !this.scriptEditorOptions && !options.lang) {
|
||||
@@ -1387,7 +1408,7 @@ export class AIChatManager {
|
||||
userMessage = prepareApiUserMessage(oldInstructions)
|
||||
break
|
||||
case AIMode.GLOBAL:
|
||||
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext, {
|
||||
userMessage = prepareGlobalUserMessage(modelInstructions, oldSelectedContext, {
|
||||
workspace: get(workspaceStore)
|
||||
})
|
||||
break
|
||||
|
||||
@@ -244,6 +244,31 @@ describe('AIChatManager global skills', () => {
|
||||
expect(manager.systemMessage.content).toContain('child-skill')
|
||||
expect(manager.systemMessage.content).not.toContain('parent-skill')
|
||||
})
|
||||
|
||||
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
|
||||
mocks.listAiSkills.mockResolvedValue([
|
||||
{ name: 'review-code', description: 'review code for bugs' }
|
||||
])
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
const userMessage = config.messages[config.messages.length - 1]
|
||||
expect(userMessage.content).toContain('Use the "review-code" skill. find bugs')
|
||||
expect(userMessage.content).not.toContain('/review-code find bugs')
|
||||
const message = { role: 'assistant' as const, content: 'done' }
|
||||
config.addedMessages?.push(message)
|
||||
return {
|
||||
addedMessages: [message],
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
|
||||
await manager.sendRequest({ instructions: '/review-code find bugs', mode: AIMode.GLOBAL })
|
||||
|
||||
expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager autonomy mode', () => {
|
||||
@@ -915,7 +940,7 @@ describe('AIChatManager context compaction', () => {
|
||||
|
||||
// The request that went out begins with the summary user message, then the
|
||||
// recent tail verbatim, then the new question.
|
||||
const sent = mocks.runChatLoop.mock.calls[0][0].messages
|
||||
const sent = mocks.runChatLoop.mock.calls[mocks.runChatLoop.mock.calls.length - 1][0].messages
|
||||
expect(sent).toHaveLength(4)
|
||||
expect(sent[0].role).toBe('user')
|
||||
expect(sent[0].content).toContain('SUMMARY TEXT')
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Sparkles } from 'lucide-svelte'
|
||||
import DrillPicker from '$lib/components/DrillPicker.svelte'
|
||||
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
|
||||
import type { AiSkillListItem } from './global/core'
|
||||
|
||||
interface Props {
|
||||
skills: AiSkillListItem[]
|
||||
onSelect: (skill: AiSkillListItem) => void
|
||||
setShowing?: (showing: boolean) => void
|
||||
externalFilter?: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
let { skills, onSelect, setShowing, externalFilter, autoFocus = true }: Props = $props()
|
||||
|
||||
type DrillPickerHandle = {
|
||||
handleKeydown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
let inner = $state<DrillPickerHandle | undefined>(undefined)
|
||||
|
||||
const tree = $derived<DrillNode<AiSkillListItem>[]>(
|
||||
skills.map((skill) => ({
|
||||
type: 'leaf' as const,
|
||||
key: `skill:${skill.name}`,
|
||||
label: `/${skill.name}`,
|
||||
secondary: skill.description,
|
||||
searchableText: `${skill.name} ${skill.description}`,
|
||||
data: skill
|
||||
}))
|
||||
)
|
||||
|
||||
export function handleKeydown(e: KeyboardEvent) {
|
||||
inner?.handleKeydown(e)
|
||||
}
|
||||
|
||||
function handlePick(leaf: DrillLeaf<AiSkillListItem>) {
|
||||
onSelect(leaf.data)
|
||||
}
|
||||
|
||||
function onDocumentKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) {
|
||||
setShowing?.(false)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
return () => document.removeEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet skillIcon(_leaf: DrillLeaf<AiSkillListItem>)}
|
||||
<Sparkles size={12} class="shrink-0 text-tertiary" />
|
||||
{/snippet}
|
||||
|
||||
<div class="w-[min(340px,calc(100vw-20px))] max-h-64 overflow-hidden">
|
||||
<DrillPicker
|
||||
bind:this={inner}
|
||||
{tree}
|
||||
onPick={handlePick}
|
||||
{externalFilter}
|
||||
{autoFocus}
|
||||
leafIcon={skillIcon}
|
||||
flush
|
||||
/>
|
||||
</div>
|
||||
@@ -2,6 +2,8 @@
|
||||
import autosize from '$lib/autosize'
|
||||
import { tick } from 'svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import ChatCommandPicker from './ChatCommandPicker.svelte'
|
||||
import ChatContextPicker from './ChatContextPicker.svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
@@ -68,11 +70,22 @@
|
||||
|
||||
let showContextTooltip = $state(false)
|
||||
let contextTooltipWord = $state('')
|
||||
let showCommandTooltip = $state(false)
|
||||
let commandTooltipWord = $state('')
|
||||
let textarea = $state<HTMLTextAreaElement | undefined>(undefined)
|
||||
let tooltipElement = $state<HTMLDivElement | undefined>(undefined)
|
||||
let chatContextPicker: ChatContextPicker | undefined = $state()
|
||||
let chatCommandPicker: ChatCommandPicker | undefined = $state()
|
||||
let commandSkillsRefreshInFlight = false
|
||||
|
||||
// Virtual reference anchored at the `@` that opened the mention (not the
|
||||
const commandSkills = $derived(
|
||||
aiChatManager.mode === AIMode.GLOBAL && aiChatManager.isSessionChat
|
||||
? aiChatManager.globalSkills
|
||||
: []
|
||||
)
|
||||
const activeTooltipWord = $derived(showContextTooltip ? contextTooltipWord : commandTooltipWord)
|
||||
|
||||
// Virtual reference anchored at the trigger that opened the picker (not the
|
||||
// caret), so the picker stays put while the user types the query.
|
||||
// svelte-floating-ui's `createVirtualElement` takes a raw ClientRect and
|
||||
// wraps it in a function internally — re-`update()` on each anchor move.
|
||||
@@ -526,19 +539,33 @@
|
||||
showContextTooltip = false
|
||||
}
|
||||
|
||||
function refreshCommandSkills() {
|
||||
if (commandSkillsRefreshInFlight) return
|
||||
commandSkillsRefreshInFlight = true
|
||||
void aiChatManager.refreshGlobalSkills().finally(() => {
|
||||
commandSkillsRefreshInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
function getCommandFilter(text: string): string | undefined {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined
|
||||
const match = /^\/([a-z0-9-]*)$/.exec(text)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
function updateAnchorRect() {
|
||||
if (!textarea) return
|
||||
const triggerWord = activeTooltipWord
|
||||
if (!triggerWord) return
|
||||
try {
|
||||
// Index of the `@` that started the current mention. handleInput
|
||||
// only opens the picker when `contextTooltipWord` (= `@xxx`) is the
|
||||
// LAST whitespace-separated word in `value`, so the `@` always sits
|
||||
// at `value.length - contextTooltipWord.length`.
|
||||
const atIndex = value.length - contextTooltipWord.length
|
||||
const coords = getCaretCoordinates(textarea, atIndex)
|
||||
// Inline `@` anchors to the last word; slash commands only open when
|
||||
// `/...` is the whole input, so the trigger sits at index 0.
|
||||
const triggerIndex = triggerWord.startsWith('/') ? 0 : value.length - triggerWord.length
|
||||
const coords = getCaretCoordinates(textarea, triggerIndex)
|
||||
const rect = textarea.getBoundingClientRect()
|
||||
// getCaretCoordinates returns content-relative coords; subtract the
|
||||
// textarea's own scroll so the anchor tracks the `@` once the input is
|
||||
// capped (max-height) and scrolls internally.
|
||||
// textarea's own scroll so the anchor tracks the trigger once the input
|
||||
// is capped (max-height) and scrolls internally.
|
||||
anchorRect = new DOMRect(
|
||||
rect.left + coords.left - textarea.scrollLeft,
|
||||
rect.top + coords.top - textarea.scrollTop,
|
||||
@@ -558,6 +585,19 @@
|
||||
function handleInput(e: Event) {
|
||||
textarea = e.target as HTMLTextAreaElement
|
||||
|
||||
const commandFilter = getCommandFilter(value)
|
||||
if (commandFilter !== undefined) {
|
||||
const wasShowing = showCommandTooltip
|
||||
showCommandTooltip = true
|
||||
commandTooltipWord = `/${commandFilter}`
|
||||
showContextTooltip = false
|
||||
contextTooltipWord = ''
|
||||
if (!wasShowing) refreshCommandSkills()
|
||||
return
|
||||
}
|
||||
showCommandTooltip = false
|
||||
commandTooltipWord = ''
|
||||
|
||||
const words = value.split(/\s+/)
|
||||
const lastWord = words[words.length - 1]
|
||||
|
||||
@@ -574,6 +614,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandSelection(skill: { name: string }) {
|
||||
value = `/${skill.name} `
|
||||
showCommandTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Pass to parent first if provided
|
||||
if (onKeyDown) {
|
||||
@@ -585,6 +631,22 @@
|
||||
return
|
||||
}
|
||||
|
||||
if (showCommandTooltip) {
|
||||
if (
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'Enter' ||
|
||||
e.key === 'Tab' ||
|
||||
e.key === 'Escape'
|
||||
) {
|
||||
chatCommandPicker?.handleKeydown(e)
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showContextTooltip) {
|
||||
// Forward navigation keys to the picker so the textarea-focused
|
||||
// user can drive it. The picker preventDefault/stopPropagation's
|
||||
@@ -622,11 +684,11 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-track on every value change. The `@` position can shift when the
|
||||
// user adds/deletes text BEFORE it (line wrap, etc.); the picker should
|
||||
// follow. floating-ui's autoUpdate only fires on scroll/resize.
|
||||
// Re-track on every value change. The trigger position can shift when
|
||||
// the user adds/deletes text before it (line wrap, etc.); the picker
|
||||
// should follow. floating-ui's autoUpdate only fires on scroll/resize.
|
||||
void value
|
||||
if (showContextTooltip) updateAnchorRect()
|
||||
if (showContextTooltip || showCommandTooltip) updateAnchorRect()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
@@ -700,9 +762,9 @@
|
||||
ondragstart={handlePasteDragStart}
|
||||
onscroll={(e) => {
|
||||
scrollTop = e.currentTarget.scrollTop
|
||||
// Keep the `@` picker pinned to its anchor while the input scrolls
|
||||
// Keep the picker pinned to its anchor while the input scrolls
|
||||
// internally (autoUpdate can't observe a virtual ref's scroll).
|
||||
if (showContextTooltip) updateAnchorRect()
|
||||
if (showContextTooltip || showCommandTooltip) updateAnchorRect()
|
||||
}}
|
||||
onblur={() => {
|
||||
setTimeout(() => {
|
||||
@@ -711,6 +773,7 @@
|
||||
return
|
||||
}
|
||||
showContextTooltip = false
|
||||
showCommandTooltip = false
|
||||
}, 200)
|
||||
}}
|
||||
{placeholder}
|
||||
@@ -724,7 +787,7 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if showContextTooltip}
|
||||
{#if showContextTooltip || showCommandTooltip}
|
||||
<Portal target="body">
|
||||
<div
|
||||
bind:this={tooltipElement}
|
||||
@@ -732,33 +795,46 @@
|
||||
class="bg-surface border border-gray-200 dark:border-gray-700 rounded-md shadow-lg overflow-hidden"
|
||||
style="z-index: {zIndexes.tooltip};"
|
||||
>
|
||||
<ChatContextPicker
|
||||
bind:this={chatContextPicker}
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
handleContextSelection(element)
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
onAddContext(element)
|
||||
updateInstructionsWithContext(element)
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
externalFilter={contextTooltipWord.slice(1)}
|
||||
autoFocus={false}
|
||||
setShowing={(showing) => {
|
||||
showContextTooltip = showing
|
||||
}}
|
||||
onSelectFile={(name) => {
|
||||
// Replace the in-progress `@word` with the chosen mention (bracketed if the
|
||||
// filename has spaces, so the highlighter captures it whole).
|
||||
const index = value.lastIndexOf('@')
|
||||
value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} `
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
/>
|
||||
{#if showCommandTooltip}
|
||||
<ChatCommandPicker
|
||||
bind:this={chatCommandPicker}
|
||||
skills={commandSkills}
|
||||
onSelect={handleCommandSelection}
|
||||
externalFilter={commandTooltipWord.slice(1)}
|
||||
autoFocus={false}
|
||||
setShowing={(showing) => {
|
||||
showCommandTooltip = showing
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<ChatContextPicker
|
||||
bind:this={chatContextPicker}
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
handleContextSelection(element)
|
||||
}}
|
||||
onSelectWorkspaceItem={(element) => {
|
||||
onAddContext(element)
|
||||
updateInstructionsWithContext(element)
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
externalFilter={contextTooltipWord.slice(1)}
|
||||
autoFocus={false}
|
||||
setShowing={(showing) => {
|
||||
showContextTooltip = showing
|
||||
}}
|
||||
onSelectFile={(name) => {
|
||||
// Replace the in-progress `@word` with the chosen mention (bracketed if the
|
||||
// filename has spaces, so the highlighter captures it whole).
|
||||
const index = value.lastIndexOf('@')
|
||||
value = (index !== -1 ? value.substring(0, index) : value) + `${formatMention(name)} `
|
||||
showContextTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user