Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions runtime/ai/router_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"regexp"
"slices"
"strings"
"unicode/utf8"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions runtime/ai/router_agent_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package ai

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestPromptToTitle(t *testing.T) {
mvRef := `<chat-reference>type="metricsView" metricsView="orders"</chat-reference>`

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))
})
}
}
131 changes: 131 additions & 0 deletions web-common/src/features/chat/core/context/conversation-title.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<chat-reference>type="metricsView" metricsView="orders"</chat-reference> by month`,
expected: "Sales by month",
},
{
title: "measure",
input: `What drives <chat-reference>type="measure" metricsView="orders" measure="revenue"</chat-reference>?`,
expected: "What drives Net Revenue?",
},
{
title: "dimension",
input: `Revenue by <chat-reference>type="dimension" metricsView="orders" dimension="country"</chat-reference>`,
expected: "Revenue by Customer Country",
},
{
title: "canvas",
input: `Summarize <chat-reference>type="canvas" canvas="overview"</chat-reference>`,
expected: "Summarize Sales Overview",
},
{
title: "skill",
input: `<chat-reference>type="skill" skill="monthly-close"</chat-reference> for March`,
expected: "/monthly-close for March",
},
{
title: "column",
input: `Nulls in <chat-reference>type="column" model="orders" column="revenue"</chat-reference>`,
expected: "Nulls in revenue",
},
{
title: "falls back to names without metadata",
input: `<chat-reference>type="metricsView" metricsView="orders"</chat-reference> by month`,
meta: emptyMeta,
expected: "orders by month",
},
{
title: "unknown type is dropped",
input: `<chat-reference>type="unknown" unknown="x"</chat-reference> hello`,
expected: "hello",
},
{
title: "inherited object property as type is dropped",
input: `<chat-reference>type="toString"</chat-reference> <chat-reference>type="constructor"</chat-reference>hello`,
expected: "hello",
},
{
title: "dimension values without values",
input: `<chat-reference>type="dimensionValues" metricsView="orders" dimension="country"</chat-reference>`,
expected: "Customer Country:",
},
{
title: "dimension values with values attribute",
input: `<chat-reference>type="dimensionValues" metricsView="orders" dimension="country" values="US,IN"</chat-reference>`,
expected: "Customer Country:",
},
{
title: "reference truncated by the backend",
input: `<chat-reference>type="metricsView" metricsView="orders"</chat-reference>...`,
expected: "Sales...",
},
{
title: "partial reference in an older title is dropped",
input: `Compare <chat-reference>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 `<chat-`.
input: `${"a".repeat(40)} <chat-...`,
expected: `${"a".repeat(40)}...`,
},
{
title: "older title cut right after the opening angle bracket",
input: `${"a".repeat(46)}<...`,
expected: `${"a".repeat(46)}...`,
},
{
title: "text resembling a tag name prefix without truncation is kept",
input: "Is x <ch y?",
expected: "Is x <ch y?",
},
{
title: "title that is only a partial reference",
input: `<chat-reference>type="metricsView" metricsVi...`,
expected: "",
},
];

for (const { title, input, meta: m, expected } of testCases) {
it(title, () => {
expect(formatConversationTitle(input, m ?? meta)).toBe(expected);
});
}
});
42 changes: 42 additions & 0 deletions web-common/src/features/chat/core/context/conversation-title.ts
Original file line number Diff line number Diff line change
@@ -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]*?)</${INLINE_CHAT_CONTEXT_TAG}>`,
"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. `<chat-...`).
const TAG_NAME_PREFIXES = Array.from(INLINE_CHAT_CONTEXT_TAG, (_, i) =>
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})\\.\\.\\.)$`,
);
Comment thread
AdityaHegde marked this conversation as resolved.

/**
* 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;
}
7 changes: 5 additions & 2 deletions web-common/src/features/chat/core/context/inline-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -125,6 +125,7 @@ export function convertContextToInlinePrompt(ctx: InlineContext) {
}

const PARTS_REGEX = /(\w+?)="([^"]+?)"/g;
const INLINE_CONTEXT_TYPES = new Set<string>(Object.values(InlineContextType));

export function convertPromptValueToContext(
contextValue: string,
Expand All @@ -134,10 +135,12 @@ export function convertPromptValueToContext(
const ctx = <InlineContext>{};

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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
);
Expand Down Expand Up @@ -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}
>
<div class="conversation-title" data-testid="conversation-title">
{conversation.title || m.chat_new_conversation()}
{getTitle(conversation)}
</div>
</a>
{/each}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +26,8 @@
>
<div class="min-w-0">
<div class="text-xs text-fg-primary truncate">
{conversation.title || "New Conversation"}
{formatConversationTitle(conversation.title ?? "", contextMetadata) ||
"New Conversation"}
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
Expand Down
Loading
Loading