diff --git a/runtime/ai/router_agent.go b/runtime/ai/router_agent.go index 1224fe8693f..3f30dfc412f 100644 --- a/runtime/ai/router_agent.go +++ b/runtime/ai/router_agent.go @@ -7,6 +7,7 @@ import ( "regexp" "slices" "strings" + "unicode/utf8" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -229,14 +230,34 @@ You must answer with a single agent choice and no further explanation. Pick only var whitespaceRegexp = regexp.MustCompile(`\s+`) // promptToTitle generates a truncated conversation title from a prompt. +// Chat references are kept as-is so that the UI can render them with up-to-date labels. func promptToTitle(message string) string { // Collapse whitespace to single spaces. title := whitespaceRegexp.ReplaceAllString(message, " ") title = strings.TrimSpace(title) - // Truncate to 50 characters. - if len(title) > 50 { - title = title[:47] + "..." + // Truncate to 50 runes. + // A chat reference counts as 10 runes since the UI renders it as a short label. + // It is never split, so the UI can still parse it; if it doesn't fit, the cut is placed before it. + refs := chatReferenceRegexp.FindAllStringIndex(title, -1) + length, cut := 0, -1 + for i := 0; i < len(title); { + end, size := 0, 0 + if len(refs) > 0 && refs[0][0] == i { + end, size = refs[0][1], 10 + refs = refs[1:] + } else { + _, n := utf8.DecodeRuneInString(title[i:]) + end, size = i+n, 1 + } + if cut < 0 && length+size > 47 { + cut = i + } + length += size + i = end + } + if length > 50 { + title = strings.TrimRight(title[:cut], " ") + "..." } // Fallback title if empty. diff --git a/runtime/ai/router_agent_internal_test.go b/runtime/ai/router_agent_internal_test.go new file mode 100644 index 00000000000..7d5b50c0f37 --- /dev/null +++ b/runtime/ai/router_agent_internal_test.go @@ -0,0 +1,69 @@ +package ai + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPromptToTitle(t *testing.T) { + mvRef := `type="metricsView" metricsView="orders"` + + cases := []struct { + name string + prompt string + title string + }{ + { + name: "empty", + prompt: " \n ", + title: "New Conversation", + }, + { + name: "whitespace is collapsed", + prompt: " How did\n\nrevenue change? ", + title: "How did revenue change?", + }, + { + name: "long prompt is truncated", + prompt: strings.Repeat("a", 60), + title: strings.Repeat("a", 47) + "...", + }, + { + name: "length is counted in runes", + prompt: strings.Repeat("ú", 50), + title: strings.Repeat("ú", 50), + }, + { + name: "truncation doesn't split a rune", + prompt: strings.Repeat("ú", 51), + title: strings.Repeat("ú", 47) + "...", + }, + { + name: "reference counts as 10 runes", + prompt: mvRef + " " + strings.Repeat("a", 39), + title: mvRef + " " + strings.Repeat("a", 39), + }, + { + name: "text after a reference is truncated", + prompt: mvRef + " " + strings.Repeat("a", 50), + title: mvRef + " " + strings.Repeat("a", 36) + "...", + }, + { + name: "reference that doesn't fit is cut before", + prompt: strings.Repeat("a", 40) + " " + mvRef + " by month", + title: strings.Repeat("a", 40) + "...", + }, + { + name: "reference that fits exactly is kept", + prompt: strings.Repeat("a", 37) + mvRef + " by month", + title: strings.Repeat("a", 37) + mvRef + "...", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.title, promptToTitle(c.prompt)) + }) + } +} diff --git a/web-common/src/features/chat/core/context/conversation-title.spec.ts b/web-common/src/features/chat/core/context/conversation-title.spec.ts new file mode 100644 index 00000000000..e80129193d5 --- /dev/null +++ b/web-common/src/features/chat/core/context/conversation-title.spec.ts @@ -0,0 +1,131 @@ +import { formatConversationTitle } from "@rilldata/web-common/features/chat/core/context/conversation-title.ts"; +import type { InlineContextMetadata } from "@rilldata/web-common/features/chat/core/context/metadata.ts"; +import { describe, expect, it } from "vitest"; + +const meta: InlineContextMetadata = { + metricsViewSpecs: { + orders: { + metricsViewSpec: { displayName: "Sales" }, + measures: { revenue: { name: "revenue", displayName: "Net Revenue" } }, + dimensions: { + country: { name: "country", displayName: "Customer Country" }, + }, + }, + }, + canvasSpecs: { overview: { displayName: "Sales Overview" } }, + componentSpecs: {}, +}; + +const emptyMeta: InlineContextMetadata = { + metricsViewSpecs: {}, + canvasSpecs: {}, + componentSpecs: {}, +}; + +describe("formatConversationTitle", () => { + const testCases: { + title: string; + input: string; + meta?: InlineContextMetadata; + expected: string; + }[] = [ + { + title: "plain text", + input: "How did revenue change?", + expected: "How did revenue change?", + }, + { + title: "metrics view", + input: `type="metricsView" metricsView="orders" by month`, + expected: "Sales by month", + }, + { + title: "measure", + input: `What drives type="measure" metricsView="orders" measure="revenue"?`, + expected: "What drives Net Revenue?", + }, + { + title: "dimension", + input: `Revenue by type="dimension" metricsView="orders" dimension="country"`, + expected: "Revenue by Customer Country", + }, + { + title: "canvas", + input: `Summarize type="canvas" canvas="overview"`, + expected: "Summarize Sales Overview", + }, + { + title: "skill", + input: `type="skill" skill="monthly-close" for March`, + expected: "/monthly-close for March", + }, + { + title: "column", + input: `Nulls in type="column" model="orders" column="revenue"`, + expected: "Nulls in revenue", + }, + { + title: "falls back to names without metadata", + input: `type="metricsView" metricsView="orders" by month`, + meta: emptyMeta, + expected: "orders by month", + }, + { + title: "unknown type is dropped", + input: `type="unknown" unknown="x" hello`, + expected: "hello", + }, + { + title: "inherited object property as type is dropped", + input: `type="toString" type="constructor"hello`, + expected: "hello", + }, + { + title: "dimension values without values", + input: `type="dimensionValues" metricsView="orders" dimension="country"`, + expected: "Customer Country:", + }, + { + title: "dimension values with values attribute", + input: `type="dimensionValues" metricsView="orders" dimension="country" values="US,IN"`, + expected: "Customer Country:", + }, + { + title: "reference truncated by the backend", + input: `type="metricsView" metricsView="orders"...`, + expected: "Sales...", + }, + { + title: "partial reference in an older title is dropped", + input: `Compare type="metricsView" metricsVi...`, + expected: "Compare...", + }, + { + title: "older title cut within the opening tag name", + // The old backend truncated to `title[:47] + "..."`, so a reference starting at byte 41 was cut to `type="metricsView" metricsVi...`, + expected: "", + }, + ]; + + for (const { title, input, meta: m, expected } of testCases) { + it(title, () => { + expect(formatConversationTitle(input, m ?? meta)).toBe(expected); + }); + } +}); diff --git a/web-common/src/features/chat/core/context/conversation-title.ts b/web-common/src/features/chat/core/context/conversation-title.ts new file mode 100644 index 00000000000..885705dcebf --- /dev/null +++ b/web-common/src/features/chat/core/context/conversation-title.ts @@ -0,0 +1,42 @@ +import { InlineContextConfig } from "@rilldata/web-common/features/chat/core/context/config.ts"; +import { + convertPromptValueToContext, + INLINE_CHAT_CONTEXT_TAG, + InlineContextType, +} from "@rilldata/web-common/features/chat/core/context/inline-context.ts"; +import type { InlineContextMetadata } from "@rilldata/web-common/features/chat/core/context/metadata.ts"; + +const CHAT_REFERENCE_REGEX = new RegExp( + `<${INLINE_CHAT_CONTEXT_TAG}\\b([\\s\\S]*?)`, + "g", +); +// Older titles were truncated to 47 bytes followed by "..." without regard for references, +// so they can end in a partial reference, even one cut within its tag name (e.g. ` + INLINE_CHAT_CONTEXT_TAG.slice(0, i), +).join("|"); +const PARTIAL_CHAT_REFERENCE_REGEX = new RegExp( + `<(?:${INLINE_CHAT_CONTEXT_TAG}\\b[\\s\\S]*|(?:${TAG_NAME_PREFIXES})\\.\\.\\.)$`, +); + +/** + * Converts a conversation title to plain text by replacing the chat references in it with their labels. + * The backend keeps references in titles as-is so that labels are always up-to-date with the project. + */ +export function formatConversationTitle( + title: string, + meta: InlineContextMetadata, +) { + const text = title.replace(CHAT_REFERENCE_REGEX, (_, attrs: string) => { + const ctx = convertPromptValueToContext(attrs); + if (!ctx) return ""; + const label = InlineContextConfig[ctx.type].getLabel(ctx, meta); + return ctx.type === InlineContextType.Skill ? `/${label}` : label; + }); + + const withoutPartial = text.replace(PARTIAL_CHAT_REFERENCE_REGEX, "").trim(); + if (withoutPartial !== text.trim() && withoutPartial) { + return `${withoutPartial}...`; + } + return withoutPartial; +} 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 8b9210f8ab7..c2a2bae5a80 100644 --- a/web-common/src/features/chat/core/context/inline-context.ts +++ b/web-common/src/features/chat/core/context/inline-context.ts @@ -92,7 +92,7 @@ export function normalizeInlineContext(ctx: InlineContext) { break; case InlineContextType.DimensionValues: - normalisedContext.value = normalisedContext.values!.join(","); + normalisedContext.value = (normalisedContext.values ?? []).join(","); break; case InlineContextType.Skill: @@ -125,6 +125,7 @@ export function convertContextToInlinePrompt(ctx: InlineContext) { } const PARTS_REGEX = /(\w+?)="([^"]+?)"/g; +const INLINE_CONTEXT_TYPES = new Set(Object.values(InlineContextType)); export function convertPromptValueToContext( contextValue: string, @@ -134,10 +135,12 @@ export function convertPromptValueToContext( const ctx = {}; for (const [, key, value] of parts) { + // `values` is never serialized by convertContextToInlinePrompt, so a string here is invalid. + if (key === "values") continue; ctx[key] = value; } - if (!ctx.type) return null; + if (!INLINE_CONTEXT_TYPES.has(ctx.type)) return null; return normalizeInlineContext(ctx); } diff --git a/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte b/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte index 2d0d5a58914..3898352273b 100644 --- a/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte +++ b/web-common/src/features/chat/layouts/fullpage/ConversationSidebar.svelte @@ -6,8 +6,12 @@ import Spinner from "../../../entity-management/Spinner.svelte"; import { EntityStatus } from "../../../entity-management/types"; import type { ConversationManager } from "../../core/conversation-manager"; + import type { V1Conversation } from "../../../../runtime-client"; import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; import MemoryButton from "../../memory/MemoryButton.svelte"; + import { formatConversationTitle } from "@rilldata/web-common/features/chat/core/context/conversation-title.ts"; + import { getInlineChatContextMetadata } from "@rilldata/web-common/features/chat/core/context/metadata.ts"; + import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; export let conversationManager: ConversationManager; export let basePath: string; @@ -22,6 +26,12 @@ $: listConversationsQuery = conversationManager.listConversationsQuery(); + const contextMetadata = getInlineChatContextMetadata(useRuntimeClient()); + // Reactive so that titles are re-rendered once the metadata used for reference labels loads. + $: getTitle = (conversation: V1Conversation) => + formatConversationTitle(conversation.title ?? "", $contextMetadata) || + m.chat_new_conversation(); + $: conversations = ($listConversationsQuery.data?.conversations ?? []).filter( (c) => c.userAgent !== "rill/report", ); @@ -108,11 +118,11 @@ class:active={conversation.id === currentConversationDto?.id} data-testid="conversation-item" data-conversation-id={conversation.id} - title={conversation.title || m.chat_new_conversation()} + title={getTitle(conversation)} onclick={handleConversationItemClick} >
- {conversation.title || m.chat_new_conversation()} + {getTitle(conversation)}
{/each} diff --git a/web-common/src/features/chat/layouts/sidebar/ConversationHistoryItem.svelte b/web-common/src/features/chat/layouts/sidebar/ConversationHistoryItem.svelte index ae897c84421..41cacf4d995 100644 --- a/web-common/src/features/chat/layouts/sidebar/ConversationHistoryItem.svelte +++ b/web-common/src/features/chat/layouts/sidebar/ConversationHistoryItem.svelte @@ -2,8 +2,11 @@ import * as DropdownMenu from "../../../../components/dropdown-menu"; import { getRelativeTime } from "../../../../lib/time/relative-time"; import type { V1Conversation } from "../../../../runtime-client"; + import { formatConversationTitle } from "../../core/context/conversation-title"; + import type { InlineContextMetadata } from "../../core/context/metadata"; export let conversation: V1Conversation; + export let contextMetadata: InlineContextMetadata; export let isCurrentChat: boolean = false; export let showRelativeTime: boolean = false; export let onSelect: (conversation: V1Conversation) => void; @@ -23,7 +26,8 @@ >
- {conversation.title || "New Conversation"} + {formatConversationTitle(conversation.title ?? "", contextMetadata) || + "New Conversation"}
diff --git a/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte b/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte index eba0b0fd53c..1225652b6e2 100644 --- a/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte +++ b/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte @@ -4,6 +4,7 @@ import IconButton from "../../../../components/button/IconButton.svelte"; import * as DropdownMenu from "../../../../components/dropdown-menu"; import type { V1Conversation } from "../../../../runtime-client"; + import type { InlineContextMetadata } from "../../core/context/metadata"; import ConversationHistoryItem from "./ConversationHistoryItem.svelte"; import { GROUP_ORDER, @@ -13,6 +14,7 @@ export let conversations: V1Conversation[] = []; export let currentConversationId: string | undefined = undefined; + export let contextMetadata: InlineContextMetadata; export let onSelect: (conversation: V1Conversation) => void; let currentConversationIdSnapshot: string | undefined = currentConversationId; @@ -62,6 +64,7 @@ {#each groupedConversations[groupKey] as conv} void; const { adminServer } = featureFlags; + const contextMetadata = getInlineChatContextMetadata(useRuntimeClient()); $: organization = $page.params.organization; $: project = $page.params.project; @@ -41,7 +45,12 @@
- {currentConversationDto?.title || ""} + {formatConversationTitle( + currentConversationDto?.title ?? "", + $contextMetadata, + )}
@@ -68,6 +77,7 @@