diff --git a/runtime/ai/analyst_agent.go b/runtime/ai/analyst_agent.go index 6f85e801807a..7a2cb41aa3e0 100644 --- a/runtime/ai/analyst_agent.go +++ b/runtime/ai/analyst_agent.go @@ -182,6 +182,19 @@ func (t *AnalystAgent) Handler(ctx context.Context, args *AnalystAgentArgs) (*An } } + // Pre-invoke the load_skill tool for each analyst skill the user referenced in the prompt, on every invocation. + // A skill already loaded in this conversation is skipped: the model has it, and loading it again would repeat its whole body in the context. + loaded := loadedSkills(s) + for _, sk := range referencedSkills(args.Prompt, skillsForAgent(skills, parser.SkillAgentAnalyst)) { + if loaded[sk.Name] { + continue + } + _, err := s.CallTool(ctx, RoleAssistant, LoadSkillName, nil, &LoadSkillArgs{Name: sk.Name}) + if err != nil && errors.Is(err, ctx.Err()) { // Don't exit on non-context errors + return nil, err + } + } + // Determine tools that can be used tools := []string{} if args.Explore == "" { diff --git a/runtime/ai/skill_references.go b/runtime/ai/skill_references.go new file mode 100644 index 000000000000..626ed95d785f --- /dev/null +++ b/runtime/ai/skill_references.go @@ -0,0 +1,50 @@ +package ai + +import ( + "regexp" + "slices" +) + +var ( + // chatReferenceRegexp matches the references that the chat UI writes into prompts, e.g. type="skill" skill="monthly-close". + chatReferenceRegexp = regexp.MustCompile(`(?s)`) + // chatReferenceAttrRegexp matches a key="value" pair in a chat reference. + chatReferenceAttrRegexp = regexp.MustCompile(`(\w+)="([^"]*)"`) +) + +// referencedSkills returns the skills referenced in a prompt with a chat reference of type "skill", once each and in order of first reference. +// References to skills that are not in the given list are ignored. +func referencedSkills(prompt string, skills []*Skill) []*Skill { + var res []*Skill + for _, ref := range chatReferenceRegexp.FindAllStringSubmatch(prompt, -1) { + attrs := map[string]string{} + for _, attr := range chatReferenceAttrRegexp.FindAllStringSubmatch(ref[1], -1) { + attrs[attr[1]] = attr[2] + } + if attrs["type"] != "skill" { + continue + } + + idx := slices.IndexFunc(skills, func(sk *Skill) bool { return sk.Name == attrs["skill"] }) + if idx == -1 || slices.Contains(res, skills[idx]) { + continue + } + res = append(res, skills[idx]) + } + return res +} + +// loadedSkills returns the names of the skills already loaded in the session, whether pre-invoked or called by the model. +func loadedSkills(s *Session) map[string]bool { + res := map[string]bool{} + for _, msg := range s.Messages(FilterByType(MessageTypeCall), FilterByTool(LoadSkillName)) { + content, err := s.UnmarshalMessageContent(msg) + if err != nil { + continue + } + if args, ok := content.(*LoadSkillArgs); ok { + res[args.Name] = true + } + } + return res +} diff --git a/runtime/ai/skill_references_test.go b/runtime/ai/skill_references_test.go new file mode 100644 index 000000000000..503293eb3acd --- /dev/null +++ b/runtime/ai/skill_references_test.go @@ -0,0 +1,192 @@ +package ai_test + +import ( + "context" + "sync" + "testing" + + aiv1 "github.com/rilldata/rill/proto/gen/rill/ai/v1" + "github.com/rilldata/rill/runtime/ai" + "github.com/rilldata/rill/runtime/drivers" + "github.com/rilldata/rill/runtime/testruntime" + "github.com/stretchr/testify/require" +) + +// turnFunc produces the assistant message the simulated model returns for one completion call. +type turnFunc func(opts *drivers.CompleteOptions) *aiv1.CompletionMessage + +// scriptedAIService is a deterministic drivers.AIService for tests. Each Complete call consumes the next scripted +// turn (falling back to a plain "done" reply once turns are exhausted) and records the messages it was given, so +// tests can assert what the model saw. +type scriptedAIService struct { + turns []turnFunc + + mu sync.Mutex + calls int + inputs [][]*aiv1.CompletionMessage +} + +var _ drivers.AIService = (*scriptedAIService)(nil) + +func (s *scriptedAIService) Complete(_ context.Context, opts *drivers.CompleteOptions) (*drivers.CompleteResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.inputs = append(s.inputs, opts.Messages) + + var turn turnFunc + if s.calls < len(s.turns) { + turn = s.turns[s.calls] + } + s.calls++ + if turn == nil { + turn = textTurn("done") + } + + return &drivers.CompleteResult{ + Message: turn(opts), + Provider: "scripted", + InputTokens: 1, + OutputTokens: 1, + }, nil +} + +// textTurn makes the model reply with a plain text message (ending the tool loop). +func textTurn(text string) turnFunc { + return func(_ *drivers.CompleteOptions) *aiv1.CompletionMessage { + return &aiv1.CompletionMessage{ + Role: "assistant", + Content: []*aiv1.ContentBlock{{BlockType: &aiv1.ContentBlock_Text{Text: text}}}, + } + } +} + +// newSkillReferencesSession creates a session on a project with analyst, developer and always-apply skills, backed by the given simulated model. +func newSkillReferencesSession(t *testing.T, script *scriptedAIService) *ai.Session { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: map[string]string{ + "rill.yaml": ``, + "skills/monthly-close/SKILL.md": `--- +description: Runs the monthly close analysis. +agents: [analyst] +--- +Compare revenue month over month.`, + "skills/churn-review/SKILL.md": `--- +description: Reviews customer churn. +agents: [analyst, developer] +--- +List the countries with the most churned customers.`, + "skills/glossary/SKILL.md": `--- +description: Business glossary. +agents: [analyst] +always_apply: true +--- +ARPU excludes trial users.`, + // Without agents, a skill only applies to the developer agent. + "skills/dev-conventions/SKILL.md": `--- +description: Development conventions. +--- +Name models in snake_case.`, + }, + }) + testruntime.RequireReconcileState(t, rt, instanceID, 5, 0, 0) + + s := newSession(t, rt, instanceID) + s.SetLLM(func(_ context.Context) (drivers.AIService, func(), error) { + return script, func() {}, nil + }) + return s +} + +// loadedSkillNames returns the names of the skills loaded with load_skill as sub-calls of the given call, in order. +func loadedSkillNames(s *ai.Session, callID string) []string { + var names []string + for _, call := range s.Messages(ai.FilterByParent(callID), ai.FilterByType(ai.MessageTypeCall), ai.FilterByTool(ai.LoadSkillName)) { + names = append(names, s.MustUnmarshalMessageContent(call).(*ai.LoadSkillArgs).Name) + } + return names +} + +// loadSkillResults returns the tool result content of each load_skill call in the completion messages, by skill name. +func loadSkillResults(messages []*aiv1.CompletionMessage) map[string]string { + names := map[string]string{} // tool call ID -> skill name + res := map[string]string{} + for _, m := range messages { + for _, block := range m.Content { + if call := block.GetToolCall(); call != nil && call.Name == ai.LoadSkillName { + names[call.Id] = call.Input.AsMap()["name"].(string) + } + if result := block.GetToolResult(); result != nil { + if name, ok := names[result.Id]; ok { + res[name] = result.Content + } + } + } + } + return res +} + +// TestAnalystLoadsReferencedSkills verifies that the analyst loads the skills referenced with a chat-reference tag in the prompt before the model's first turn, +// once per distinct analyst skill, and ignores references to skills that don't exist or don't apply to the analyst. +func TestAnalystLoadsReferencedSkills(t *testing.T) { + script := &scriptedAIService{turns: []turnFunc{textTurn("done")}} + s := newSkillReferencesSession(t, script) + + prompt := `type="skill" skill="monthly-close" for March, then ` + + `skill="churn-review" type="skill" and ` + + `skill="monthly-close" type="skill" again. ` + + `type="skill" skill="does-not-exist" ` + + `type="skill" skill="dev-conventions" ` + + `type="skill" skill="glossary" ` + + `type="metricsView" metricsView="orders"` + res, err := s.CallTool(t.Context(), ai.RoleUser, ai.AnalystAgentName, nil, &ai.AnalystAgentArgs{Prompt: prompt}) + require.NoError(t, err) + + // The always-apply glossary is pre-loaded once; the referenced analyst skills are loaded once each, in order of first reference. + require.Equal(t, []string{"glossary", "monthly-close", "churn-review"}, loadedSkillNames(s, res.Call.ID)) + + // The referenced skills' bodies were in the model's input on its first turn. + require.NotEmpty(t, script.inputs) + results := loadSkillResults(script.inputs[0]) + require.Contains(t, results["monthly-close"], "Compare revenue month over month.") + require.Contains(t, results["churn-review"], "List the countries with the most churned customers.") + require.NotContains(t, results, "dev-conventions") + require.NotContains(t, results, "does-not-exist") +} + +// TestAnalystSkipsSkillsAlreadyLoaded verifies that a skill whose body is already in the conversation is not loaded again when referenced in a later turn. +func TestAnalystSkipsSkillsAlreadyLoaded(t *testing.T) { + script := &scriptedAIService{turns: []turnFunc{textTurn("first"), textTurn("second")}} + s := newSkillReferencesSession(t, script) + + prompt := `type="skill" skill="monthly-close" for March` + res1, err := s.CallTool(t.Context(), ai.RoleUser, ai.AnalystAgentName, nil, &ai.AnalystAgentArgs{Prompt: prompt}) + require.NoError(t, err) + require.Equal(t, []string{"glossary", "monthly-close"}, loadedSkillNames(s, res1.Call.ID)) + + // The same skill and the always-apply one, referenced again in a later turn. + prompt = `type="skill" skill="monthly-close" and ` + + `type="skill" skill="glossary" for April` + res2, err := s.CallTool(t.Context(), ai.RoleUser, ai.AnalystAgentName, nil, &ai.AnalystAgentArgs{Prompt: prompt}) + require.NoError(t, err) + require.Empty(t, loadedSkillNames(s, res2.Call.ID)) +} + +// TestAnalystLoadsReferencedSkillsOnEveryTurn verifies that skills referenced in a later turn of the conversation are loaded in that turn. +func TestAnalystLoadsReferencedSkillsOnEveryTurn(t *testing.T) { + script := &scriptedAIService{turns: []turnFunc{textTurn("first"), textTurn("second")}} + s := newSkillReferencesSession(t, script) + + res1, err := s.CallTool(t.Context(), ai.RoleUser, ai.AnalystAgentName, nil, &ai.AnalystAgentArgs{Prompt: "Hello"}) + require.NoError(t, err) + require.Equal(t, []string{"glossary"}, loadedSkillNames(s, res1.Call.ID)) + + prompt := `type="skill" skill="monthly-close" for March` + res2, err := s.CallTool(t.Context(), ai.RoleUser, ai.AnalystAgentName, nil, &ai.AnalystAgentArgs{Prompt: prompt}) + require.NoError(t, err) + require.Equal(t, []string{"monthly-close"}, loadedSkillNames(s, res2.Call.ID)) + + // The body was in the model's input on the second turn's first completion. + require.Len(t, script.inputs, 2) + require.Contains(t, loadSkillResults(script.inputs[1])["monthly-close"], "Compare revenue month over month.") +} diff --git a/web-common/src/features/chat/core/context/config.ts b/web-common/src/features/chat/core/context/config.ts index 58a43dda01dc..0b3161ff0634 100644 --- a/web-common/src/features/chat/core/context/config.ts +++ b/web-common/src/features/chat/core/context/config.ts @@ -18,6 +18,7 @@ import { getLabelForComponent, } from "@rilldata/web-common/features/canvas/components/util.ts"; import type { ChartSpec } from "@rilldata/web-common/features/components/charts/types.ts"; +import { SquareSlashIcon } from "lucide-svelte"; type ContextConfigPerType = { editable: boolean; @@ -140,4 +141,10 @@ export const InlineContextConfig: Record< `From ${InlineContextConfig[InlineContextType.Model].getLabel(ctx, meta)}`, getIcon: (ctx) => fieldTypeToSymbol(ctx.columnType ?? ""), }, + + [InlineContextType.Skill]: { + editable: false, + getLabel: (ctx) => ctx.skill ?? "", + getIcon: () => SquareSlashIcon, + }, }; diff --git a/web-common/src/features/chat/core/context/editor-plugins.spec.ts b/web-common/src/features/chat/core/context/editor-plugins.spec.ts new file mode 100644 index 000000000000..cb1a03f291c4 --- /dev/null +++ b/web-common/src/features/chat/core/context/editor-plugins.spec.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; +import { readable } from "svelte/store"; +import { Editor } from "@tiptap/core"; +import { TextSelection, type Transaction } from "@tiptap/pm/state"; +import { getEditorPlugins } from "@rilldata/web-common/features/chat/core/context/editor-plugins.svelte.ts"; +import InlineContextPicker from "@rilldata/web-common/features/chat/core/context/picker/InlineContextPicker.svelte"; +import { + type InlineContext, + InlineContextType, +} from "@rilldata/web-common/features/chat/core/context/inline-context.ts"; + +const { mountedPickers } = vi.hoisted(() => ({ + mountedPickers: new Set>(), +})); + +// Pickers need a runtime client, so only track which pickers are open. +vi.mock("svelte", async (importOriginal) => { + const svelte = await importOriginal(); + return { + ...svelte, + getAllContexts: () => new Map(), + mount: ( + component: unknown, + { props }: { props: Record }, + ) => { + const comp = { props, closeDropdown: () => {} }; + if (component === InlineContextPicker) mountedPickers.add(comp); + return comp; + }, + unmount: (comp: Record) => mountedPickers.delete(comp), + }; +}); + +describe("editor plugins", () => { + let editor: Editor; + let onSubmit: Mock<() => void>; + + beforeEach(() => { + mountedPickers.clear(); + onSubmit = vi.fn<() => void>(); + const element = document.body.appendChild(document.createElement("div")); + editor = new Editor({ + element, + extensions: getEditorPlugins({ + placeholder: "", + onSubmit, + // Only which picker is open is asserted here, so its options are never read. + skillOptions: () => readable([]), + }), + }); + return () => { + editor.destroy(); + element.remove(); + }; + }); + + // Suggestions open and close asynchronously after a transaction. + async function dispatch(tr: Transaction) { + editor.view.dispatch(tr); + await new Promise((resolve) => setTimeout(resolve)); + } + + // Inserts text at the cursor the way typing does, without scrolling into view (not available in jsdom). + function type(text: string) { + return dispatch(editor.state.tr.insertText(text)); + } + + function pressEnter() { + const event = new KeyboardEvent("keydown", { key: "Enter" }); + editor.view.someProp("handleKeyDown", (f) => f(editor.view, event)); + } + + function openPickers() { + return [...mountedPickers].map((c) => c.props as Record); + } + + it("opens the skills picker with /", async () => { + await type("/mon"); + expect(openPickers()).toHaveLength(1); + expect(openPickers()[0].multiple).toBe(false); + }); + + it("does not open the skills picker while the @ picker is open", async () => { + await type("@orders"); + expect(openPickers()).toHaveLength(1); + + await type(" /mon"); + expect(openPickers()).toHaveLength(1); + expect(openPickers()[0].multiple).toBe(true); + expect(openPickers()[0].searchText).toBe("orders /mon"); + + pressEnter(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("opens a single picker when both triggers match at once", async () => { + await type("@orders /mon"); + expect(openPickers()).toHaveLength(1); + expect(openPickers()[0].multiple).toBe(true); + }); + + it("opens the skills picker after a space, but not inside a word", async () => { + await type("close the month /mon"); + expect(openPickers()).toHaveLength(1); + expect(openPickers()[0].multiple).toBe(false); + + await type(" 12/08"); + expect(openPickers()).toHaveLength(0); + + pressEnter(); + expect(onSubmit).toHaveBeenCalledOnce(); + }); + + it("starts a skill after the word the cursor is on", async () => { + await type("hola"); + editor.commands.startSkill(); + await dispatch(editor.state.tr); + expect(editor.getText()).toBe("hola /"); + expect(openPickers()).toHaveLength(1); + expect(openPickers()[0].multiple).toBe(false); + + // Mention collapses the DOM selection after inserting, which jsdom only has once something is selected. + document.getSelection()?.selectAllChildren(editor.view.dom); + (openPickers()[0].onSelect as (ctx: InlineContext) => void)({ + type: InlineContextType.Skill, + skill: "monthly-close", + value: "monthly-close", + }); + await dispatch(editor.state.tr); + expect(editor.getText()).toBe( + `hola type="skill" skill="monthly-close" `, + ); + }); + + it("does not start a skill while a picker is open", async () => { + await type("@orders"); + expect(editor.commands.startSkill()).toBe(false); + await dispatch(editor.state.tr); + expect(editor.getText()).toBe("@orders"); + expect(openPickers()).toHaveLength(1); + expect(openPickers()[0].multiple).toBe(true); + }); + + it("submits with Enter once the picker is closed", async () => { + await type("@orders /mon"); + await dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1)), + ); + expect(openPickers()).toHaveLength(0); + + pressEnter(); + expect(onSubmit).toHaveBeenCalledOnce(); + }); +}); diff --git a/web-common/src/features/chat/core/context/editor-plugins.svelte.ts b/web-common/src/features/chat/core/context/editor-plugins.svelte.ts index 9eb9ac88a0ce..e8fb695833a0 100644 --- a/web-common/src/features/chat/core/context/editor-plugins.svelte.ts +++ b/web-common/src/features/chat/core/context/editor-plugins.svelte.ts @@ -4,6 +4,7 @@ import InlineContextPicker from "@rilldata/web-common/features/chat/core/context import type { ConversationManager } from "@rilldata/web-common/features/chat/core/conversation-manager.ts"; import InlineContextComponent from "@rilldata/web-common/features/chat/core/context/InlineContext.svelte"; import type { EditorView } from "@tiptap/pm/view"; +import { PluginKey, type EditorState } from "@tiptap/pm/state"; import Document from "@tiptap/extension-document"; import Paragraph from "@tiptap/extension-paragraph"; import Text from "@tiptap/extension-text"; @@ -16,13 +17,17 @@ import { normalizeInlineContext, parseInlineAttr, } from "@rilldata/web-common/features/chat/core/context/inline-context.ts"; +import type { PickerOptionsGetter } from "@rilldata/web-common/features/chat/core/context/picker/filters.ts"; export function getEditorPlugins({ placeholder, onSubmit, + skillOptions, }: { placeholder: string; onSubmit: () => void; + // Adds a "/" picker listing the skills this getter returns. + skillOptions?: PickerOptionsGetter; }) { const sharedEditorStore = new SharedEditorStore(); @@ -34,7 +39,7 @@ export function getEditorPlugins({ placeholder, }), EditorSubmitExtension.configure({ onSubmit, sharedEditorStore }), - configureInlineContextTipTapExtension(sharedEditorStore), + configureInlineContextTipTapExtension(sharedEditorStore, skillOptions), UndoRedo, ]; @@ -100,11 +105,12 @@ type InlineContextOptions = MentionOptions & { allParentContexts: Map; }; -// Add the startMention command to the Commands type. +// Add the startMention and startSkill commands to the Commands type. declare module "@tiptap/core" { interface Commands { mention: { startMention: () => ReturnType; + startSkill: () => ReturnType; }; } } @@ -139,6 +145,7 @@ const InlineContextExtension = Mention.extend({ model: createAttributeEntry(null, "model"), column: createAttributeEntry(null, "column"), columnType: createAttributeEntry(null, "columnType"), + skill: createAttributeEntry(null, "skill"), }; }, @@ -155,6 +162,22 @@ const InlineContextExtension = Mention.extend({ view.dispatchEvent(new KeyboardEvent("keyup", { key: "@" })); return true; }, + startSkill: + () => + ({ tr, view, commands }) => { + commands.focus(); + // Only focus the editor if context is already open. + if (this.options.sharedEditorStore.contextOpen) return false; + + // The picker only opens at the start of a line or after a space, so add one when typing right after a word. + const before = tr.doc.textBetween( + Math.max(tr.selection.from - 1, 0), + tr.selection.from, + ); + tr.insertText(before && before !== " " ? " /" : "/"); + view.dispatchEvent(new KeyboardEvent("keyup", { key: "/" })); + return true; + }, }; }, @@ -227,71 +250,131 @@ const InlineContextExtension = Mention.extend({ /** * Configures the InlineContextExtension to show a dropdown when the user types "@". + * With `skillOptions`, also shows a dropdown listing skills when the user types "/". * Renders the InlineContextPicker svelte component. */ export function configureInlineContextTipTapExtension( sharedEditorStore: SharedEditorStore, + skillOptions?: PickerOptionsGetter, ) { - let comp: Record | null = null; - const pickerProps: Record = $state({}); - let selected = false; - const allParentContexts = getAllContexts(); - return InlineContextExtension.configure({ - sharedEditorStore, - allParentContexts, - suggestion: { + const suggestions = [ + getPickerSuggestion(sharedEditorStore, allParentContexts, { char: "@", allowSpaces: true, - items: () => [], // TODO: would it make sense to manage the options here? - render: () => ({ - onStart: (props) => { - if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment - selected = false; - - pickerProps.refNode = props.decorationNode; - pickerProps.onSelect = (item: InlineContext) => { - selected = true; - props.command(item); - }; - pickerProps.focusEditor = () => props.editor.commands.focus(); - comp = mount(InlineContextPicker, { - target: document.body, - props: pickerProps, - context: allParentContexts, - }); - sharedEditorStore.contextOpen = true; - }, + }), + ]; + if (skillOptions) { + suggestions.push( + getPickerSuggestion(sharedEditorStore, allParentContexts, { + char: "/", + getOptions: skillOptions, + // A prompt references one skill at a time. + multiple: false, + }), + ); + } - onUpdate(props) { - if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment - pickerProps.searchText = props.query; - pickerProps.refNode = props.decorationNode; - }, + // Only one picker can be open at a time. "@orders /mon" matches both "@" (spaces are allowed) and "/", + // so a trigger is not allowed while the suggestion of another trigger is active. + const pluginKeys = suggestions.map( + () => new PluginKey<{ active: boolean }>(), + ); + suggestions.forEach((suggestion, i) => { + suggestion.pluginKey = pluginKeys[i]; + suggestion.allow = ({ editor, state, range }) => + isMentionAllowedAt(state, range.from) && + pluginKeys.every( + (key, j) => + i === j || + // The state of a later plugin is not computed yet in the new state, so fall back to the current one. + !(key.getState(state) ?? key.getState(editor.state))?.active, + ); + }); - onExit: ({ editor, range }) => { - if (!comp) return; - unmount(comp); - comp = null; - sharedEditorStore.contextOpen = false; - - if (!selected) return; - // Remove the query text and replace with space. - // This is not automatically removed by tiptap - editor.view.dispatch( - editor.view.state.tr.replaceRangeWith( - range.from + 1, - range.to + 1, - editor.state.schema.text(" "), - ), - ); - }, - }), - }, + return InlineContextExtension.configure({ + sharedEditorStore, + allParentContexts, + suggestions, }); } +// Mention's default `allow`, which is replaced when `allow` is set. +function isMentionAllowedAt(state: EditorState, pos: number) { + const type = state.schema.nodes[InlineContextExtension.name]; + return !!state.doc.resolve(pos).parent.type.contentMatch.matchType(type); +} + +function getPickerSuggestion( + sharedEditorStore: SharedEditorStore, + allParentContexts: InlineContextOptions["allParentContexts"], + { + char, + allowSpaces = false, + getOptions, + multiple = true, + }: { + char: string; + allowSpaces?: boolean; + getOptions?: PickerOptionsGetter; + multiple?: boolean; + }, +): InlineContextOptions["suggestion"] { + let comp: Record | null = null; + const pickerProps: Record = $state({ getOptions, multiple }); + let selected = false; + + return { + char, + allowSpaces, + items: () => [], // TODO: would it make sense to manage the options here? + render: () => ({ + onStart: (props) => { + if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment + selected = false; + + pickerProps.refNode = props.decorationNode; + pickerProps.onSelect = (item: InlineContext) => { + selected = true; + props.command(item); + }; + pickerProps.focusEditor = () => props.editor.commands.focus(); + comp = mount(InlineContextPicker, { + target: document.body, + props: pickerProps, + context: allParentContexts, + }); + sharedEditorStore.contextOpen = true; + }, + + onUpdate(props) { + if (!(props.decorationNode instanceof HTMLElement)) return; // type safety, non-html will be in non-dom environment + pickerProps.searchText = props.query; + pickerProps.refNode = props.decorationNode; + }, + + onExit: ({ editor, range }) => { + if (!comp) return; + unmount(comp); + comp = null; + sharedEditorStore.contextOpen = false; + + if (!selected) return; + // Remove the query text and replace with space. + // This is not automatically removed by tiptap + editor.view.dispatch( + editor.view.state.tr.replaceRangeWith( + range.from + 1, + range.to + 1, + editor.state.schema.text(" "), + ), + ); + }, + }), + }; +} + type InlineContextExports = { closeDropdown: () => void }; /** @@ -339,7 +422,8 @@ function getTransactionForContext( .setNodeAttribute(pos, "timeRange", inlineChatContext.timeRange) .setNodeAttribute(pos, "model", inlineChatContext.model) .setNodeAttribute(pos, "column", inlineChatContext.column) - .setNodeAttribute(pos, "columnType", inlineChatContext.columnType); + .setNodeAttribute(pos, "columnType", inlineChatContext.columnType) + .setNodeAttribute(pos, "skill", inlineChatContext.skill); } function createAttributeEntry(defaultValue: string | null, key: string) { diff --git a/web-common/src/features/chat/core/context/inline-context.spec.ts b/web-common/src/features/chat/core/context/inline-context.spec.ts index c46a4c568c9f..ea8f85419509 100644 --- a/web-common/src/features/chat/core/context/inline-context.spec.ts +++ b/web-common/src/features/chat/core/context/inline-context.spec.ts @@ -77,6 +77,16 @@ describe("should convert to and from inline prompt", () => { }, expectedPrompt: `type="column" model="adbids_model" column="pub"`, }, + + { + title: "skill", + ctx: { + type: InlineContextType.Skill, + skill: "monthly-close", + value: "monthly-close", + }, + expectedPrompt: `type="skill" skill="monthly-close"`, + }, ]; for (const { title, ctx, expectedPrompt } of testCases) { diff --git a/web-common/src/features/chat/core/context/inline-context.ts b/web-common/src/features/chat/core/context/inline-context.ts index 65bbedc3def5..8b9210f8ab7b 100644 --- a/web-common/src/features/chat/core/context/inline-context.ts +++ b/web-common/src/features/chat/core/context/inline-context.ts @@ -10,6 +10,7 @@ export enum InlineContextType { DimensionValues = "dimensionValues", Model = "model", Column = "column", + Skill = "skill", } export type InlineContext = { @@ -30,6 +31,7 @@ export type InlineContext = { model?: string; column?: string; columnType?: string; // TODO: is this needed here? + skill?: string; }; export function getIdForContext(ctx: InlineContext) { @@ -92,6 +94,10 @@ export function normalizeInlineContext(ctx: InlineContext) { case InlineContextType.DimensionValues: normalisedContext.value = normalisedContext.values!.join(","); break; + + case InlineContextType.Skill: + normalisedContext.value = normalisedContext.skill!; + break; } return normalisedContext; diff --git a/web-common/src/features/chat/core/context/picker/InlineContextPicker.svelte b/web-common/src/features/chat/core/context/picker/InlineContextPicker.svelte index c0ac30799969..eff6a27ebe0c 100644 --- a/web-common/src/features/chat/core/context/picker/InlineContextPicker.svelte +++ b/web-common/src/features/chat/core/context/picker/InlineContextPicker.svelte @@ -16,7 +16,10 @@ import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-svelte"; import * as Kbd from "@rilldata/web-common/components/kbd"; import { ContextPickerUIState } from "@rilldata/web-common/features/chat/core/context/picker/ui-state.ts"; - import { getFilteredPickerItems } from "@rilldata/web-common/features/chat/core/context/picker/filters.ts"; + import { + getFilteredPickerItems, + type PickerOptionsGetter, + } from "@rilldata/web-common/features/chat/core/context/picker/filters.ts"; import { buildPickerTree } from "@rilldata/web-common/features/chat/core/context/picker/picker-tree.ts"; import { KeyboardNavigationManager } from "@rilldata/web-common/features/chat/core/context/picker/keyboard-navigation.ts"; import ExpandableOption from "@rilldata/web-common/features/chat/core/context/picker/ExpandableOption.svelte"; @@ -28,6 +31,10 @@ export let refNode: HTMLElement; export let onSelect: (ctx: InlineContext) => void; export let focusEditor: () => void; + // Defaults to metrics views, canvases and models. + export let getOptions: PickerOptionsGetter | undefined = undefined; + // Like a select's `multiple`: several options are picked, so each one shows whether it is selected. + export let multiple = true; $: selectedItemId = selectedChatContext ? getIdForContext(selectedChatContext) @@ -44,8 +51,11 @@ runtimeClient, uiState, searchTextStore, + getOptions, ); $: pickerTree = buildPickerTree($filteredOptions); + // Nothing to open or close when no option has children. + $: expandable = pickerTree.rootNodes.some((node) => node.item.hasChildren); const keyboardNavigationManager = new KeyboardNavigationManager(uiState); $: keyboardNavigationManager.setPickerItems( @@ -129,6 +139,7 @@ {selectedChatContext} {keyboardNavigationManager} {onSelect} + {multiple} /> {/if} {:else} @@ -140,9 +151,11 @@ Navigate, - - - Open/Close, + {#if expandable} + + + Open/Close, + {/if} Enter Select diff --git a/web-common/src/features/chat/core/context/picker/SimpleOption.svelte b/web-common/src/features/chat/core/context/picker/SimpleOption.svelte index ebf6a5d5477f..25534d1bac0b 100644 --- a/web-common/src/features/chat/core/context/picker/SimpleOption.svelte +++ b/web-common/src/features/chat/core/context/picker/SimpleOption.svelte @@ -14,6 +14,9 @@ export let selectedChatContext: InlineContext | null; export let keyboardNavigationManager: KeyboardNavigationManager; export let onSelect: (ctx: InlineContext) => void; + // Like a select's `multiple`: in a single-choice picker an option is only picked, never shown as + // selected, so there is no room for the check. + export let multiple = true; const runtimeClient = useRuntimeClient(); @@ -33,15 +36,18 @@ diff --git a/web-common/src/features/chat/core/types.ts b/web-common/src/features/chat/core/types.ts index bd9e96fe5dcb..00f777fb110d 100644 --- a/web-common/src/features/chat/core/types.ts +++ b/web-common/src/features/chat/core/types.ts @@ -81,4 +81,6 @@ export type ChatConfig = { emptyChatLabel: string; placeholder: string; minChatHeight: string; + // The project's skills for this chat's agent can be picked with "/". + skills?: boolean; }; diff --git a/web-common/src/features/project/chat-context.ts b/web-common/src/features/project/chat-context.ts index ef9368fb70db..1e984a276d24 100644 --- a/web-common/src/features/project/chat-context.ts +++ b/web-common/src/features/project/chat-context.ts @@ -15,4 +15,5 @@ export const projectChat = { return m.chat_placeholder_analyst(); }, minChatHeight: "min-h-[2.5rem]", + skills: true, } satisfies ChatConfig; diff --git a/web-common/src/lib/i18n/messages/en.json b/web-common/src/lib/i18n/messages/en.json index a366f4353061..dfb2d167e67f 100644 --- a/web-common/src/lib/i18n/messages/en.json +++ b/web-common/src/lib/i18n/messages/en.json @@ -623,6 +623,7 @@ "chat_group_yesterday": "Yesterday", "chat_happy_to_explore": "Happy to help explore your data", "chat_how_can_i_help": "How can I help you today?", + "chat_insert_skill": "Insert a skill", "chat_loading_conversations": "Loading conversations...", "chat_new_conversation": "New conversation", "chat_no_conversations": "No conversations yet.", diff --git a/web-common/src/lib/i18n/messages/es.json b/web-common/src/lib/i18n/messages/es.json index 7e3d39ee8cfb..17a841da68da 100644 --- a/web-common/src/lib/i18n/messages/es.json +++ b/web-common/src/lib/i18n/messages/es.json @@ -623,6 +623,7 @@ "chat_group_yesterday": "Ayer", "chat_happy_to_explore": "Encantado de ayudarte a explorar tus datos", "chat_how_can_i_help": "¿Cómo puedo ayudarte hoy?", + "chat_insert_skill": "Insertar una skill", "chat_loading_conversations": "Cargando conversaciones...", "chat_new_conversation": "Nueva conversación", "chat_no_conversations": "Aún no hay conversaciones.",