Skip to content

Commit 5d0fd77

Browse files
Feature/reference entities (#324)
* Add support for mentions * Add logs to debug * Improve styling * Fix rendering using simple spans * handle hover colors * highlight in textarea * Fix inline renderer * Fix debounced mention rendering in chat content * Improve performance for large tables/ models * Fix spaces in table or model names * Fix special characters problem * Show schema and collection names --------- Co-authored-by: nuwandavek <vivekaithal44@gmail.com>
1 parent 1fb37e2 commit 5d0fd77

File tree

6 files changed

+842
-15
lines changed

6 files changed

+842
-15
lines changed

web/src/components/common/ChatContent.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22
import { useSelector } from 'react-redux';
33
import { ChatMessageContent } from '../../state/chat/reducer'
44
import { Markdown } from './Markdown';
55
import { processModelToUIText } from '../../helpers/utils';
66
import { getApp } from '../../helpers/app';
77
import { RootState } from '../../state/store';
88
import { getOrigin, getParsedIframeInfo } from '../../helpers/origin';
9+
import { createMentionItems, convertMentionsToDisplay } from '../../helpers/mentionUtils';
10+
import { MetabaseContext } from 'apps/types';
911

1012

1113
const useAppStore = getApp().useStore()
@@ -15,19 +17,30 @@ export const ChatContent: React.FC<{content: ChatMessageContent, messageIndex?:
1517
messageIndex,
1618
role
1719
}) => {
18-
const url = useAppStore((state) => state.toolContext)?.url || ''
20+
const toolContext: MetabaseContext = useAppStore((state) => state.toolContext)
21+
const url = toolContext?.url || ''
1922
const origin = url ? new URL(url).origin : '';
20-
const pageType = useAppStore((state) => state.toolContext)?.pageType || ''
23+
const pageType = toolContext?.pageType || ''
2124
const embedConfigs = useSelector((state: RootState) => state.configs.embed);
2225

26+
// Create mention items for parsing storage format mentions
27+
const mentionItems = useMemo(() => {
28+
if (!toolContext?.dbInfo) return []
29+
const tables = toolContext.dbInfo.tables || []
30+
const models = toolContext.dbInfo.models || []
31+
return createMentionItems(tables, models)
32+
}, [toolContext?.dbInfo]);
33+
2334
if (content.type == 'DEFAULT') {
24-
const contentText = ((pageType === 'dashboard' || pageType === 'unknown') && role === 'assistant') ? `${content.text} {{MX_LAST_QUERY_URL}}` : content.text;
35+
const baseContentText = ((pageType === 'dashboard' || pageType === 'unknown') && role === 'assistant') ? `${content.text} {{MX_LAST_QUERY_URL}}` : content.text;
36+
// Convert storage format mentions (@{type:table,id:123}) to special code syntax ([mention:table:table_name])
37+
const contentTextWithMentionTags = convertMentionsToDisplay(baseContentText, mentionItems);
2538
return (
2639
<div>
2740
{content.images.map(image => (
2841
<img src={image.url} key={image.url} />
2942
))}
30-
<Markdown content={processModelToUIText(contentText, origin, embedConfigs)} messageIndex={messageIndex} />
43+
<Markdown content={processModelToUIText(contentTextWithMentionTags, origin, embedConfigs)} messageIndex={messageIndex} />
3144
</div>
3245
)
3346
} else {

web/src/components/common/ChatInputArea.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ import {
55
Text,
66
Badge
77
} from '@chakra-ui/react'
8-
import React, { forwardRef, useState, useEffect, useCallback } from 'react'
8+
import React, { forwardRef, useState, useEffect, useCallback, useMemo } from 'react'
99
import { useDispatch, useSelector } from 'react-redux'
1010
import { RootState } from '../../state/store'
1111
import RunTaskButton from './RunTaskButton'
1212
import AbortTaskButton from './AbortTaskButton'
13-
import AutosizeTextarea from './AutosizeTextarea'
13+
import { MentionTextarea } from './MentionTextarea'
1414
import { abortPlan } from '../../state/chat/reducer'
1515
import { setInstructions as setTaskInstructions } from '../../state/thumbnails/reducer'
1616
import { setConfirmChanges } from '../../state/settings/reducer'
1717
import { configs } from '../../constants'
1818
import _ from 'lodash'
1919
import { MetabaseContext } from 'apps/types'
2020
import { getApp } from '../../helpers/app'
21+
import { createMentionItems, convertMentionsToStorage } from '../../helpers/mentionUtils'
2122

2223
interface ChatInputAreaProps {
2324
isRecording: boolean
@@ -48,6 +49,14 @@ const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputAreaProps>(
4849
dispatch(setConfirmChanges(value))
4950
}
5051

52+
// Create mention items from tables and models
53+
const mentionItems = useMemo(() => {
54+
if (!toolContext.dbInfo) return []
55+
const tables = toolContext.dbInfo.tables || []
56+
const models = toolContext.dbInfo.models || []
57+
return createMentionItems(tables, models)
58+
}, [toolContext.dbInfo])
59+
5160
// Sync with redux instructions when they change
5261
useEffect(() => {
5362
setInstructions(reduxInstructions)
@@ -67,15 +76,17 @@ const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputAreaProps>(
6776
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
6877
if (e.key === 'Enter' && !e.shiftKey) {
6978
e.preventDefault()
70-
runTask(instructions)
79+
// Convert mentions to storage format before sending
80+
const instructionsWithStorageMentions = convertMentionsToStorage(instructions, mentionItems)
81+
runTask(instructionsWithStorageMentions)
7182
}
7283
}
7384

7485
return (
7586
<>
7687
{appEnabledStatus.inputBox && (
7788
<Stack aria-label="chat-input-area" position={"relative"}>
78-
<AutosizeTextarea
89+
<MentionTextarea
7990
ref={ref}
8091
autoFocus
8192
aria-label='chat-input'
@@ -84,6 +95,7 @@ const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputAreaProps>(
8495
onChange={(e) => setInstructions(e.target.value)}
8596
onKeyDown={onKeyDown}
8697
style={{ width: '100%', height: "100%" }}
98+
mentionItems={mentionItems}
8799
/>
88100
<HStack aria-label="chat-controls" position={"absolute"} bottom={0} width={"100%"} p={2}>
89101
<HStack justify={"space-between"} width={"100%"}>
@@ -120,7 +132,10 @@ const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputAreaProps>(
120132
{taskInProgress ? (
121133
<AbortTaskButton abortTask={() => dispatch(abortPlan())} disabled={!taskInProgress}/>
122134
) : (
123-
<RunTaskButton runTask={() => runTask(instructions)} disabled={taskInProgress} />
135+
<RunTaskButton runTask={() => {
136+
const instructionsWithStorageMentions = convertMentionsToStorage(instructions, mentionItems)
137+
runTask(instructionsWithStorageMentions)
138+
}} disabled={taskInProgress} />
124139
)}
125140
</HStack>
126141
</HStack>

web/src/components/common/Markdown.tsx

Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect, useState, useMemo } from 'react';
22
import MarkdownComponent from 'react-markdown'
33
import remarkGfm from 'remark-gfm'
44
import './ChatContent.css'
@@ -10,7 +10,7 @@ import { renderString } from '../../helpers/templatize'
1010
import { getOrigin, getParsedIframeInfo } from '../../helpers/origin'
1111
import { getApp } from '../../helpers/app'
1212
import { getAllTemplateTagsInQuery, replaceLLMFriendlyIdentifiersInSqlWithModels } from 'apps'
13-
import type { MetabaseModel } from 'apps/types'
13+
import type { MetabaseModel, MetabaseContext } from 'apps/types'
1414
import { type EmbedConfigs } from '../../state/configs/reducer'
1515
import { Badge } from "@chakra-ui/react";
1616
import { CodeBlock } from './CodeBlock';
@@ -19,6 +19,7 @@ import { BsBarChartFill } from "react-icons/bs";
1919
import { dispatch } from '../../state/dispatch';
2020
import { updateIsDevToolsOpen } from '../../state/settings/reducer';
2121
import { setMinusxMode } from '../../app/rpc';
22+
import { createMentionItems, MentionItem } from '../../helpers/mentionUtils';
2223

2324

2425
function LinkRenderer(props: any) {
@@ -83,10 +84,60 @@ function ModifiedCode(props: any) {
8384
const text = props.children?.toString() || '';
8485

8586
if (text.startsWith('[badge]')) {
86-
return <Badge color={"minusxGreen.600"} aria-label='mx-badge'>{text.replace('[badge]', '')}</Badge>;
87+
return <Badge color={"minusxGreen.600"} aria-label='mx-badge'>{text.replace('[badge]', '')}</Badge>;
8788
}
8889
if (text.startsWith('[badge_mx]')) {
89-
return <><br></br><Badge aria-label='mx-badge' borderLeftColor={"minusxGreen.600"} borderLeft={"2px solid"} color={"minusxGreen.600"} fontSize={"sm"} mt={2}>{text.replace('[badge_mx]', '')}</Badge><br></br></>;
90+
return <><br></br><Badge aria-label='mx-badge' borderLeftColor={"minusxGreen.600"} borderLeft={"2px solid"} color={"minusxGreen.600"} fontSize={"sm"} mt={2}>{text.replace('[badge_mx]', '')}</Badge><br></br></>;
91+
}
92+
if (text.startsWith('[mention:table:')) {
93+
const tableName = text.replace('[mention:table:', '').replace(']', '');
94+
const mentionItem = (props as any).mentionItems?.find((item: MentionItem) =>
95+
item.name === tableName && item.type === 'table'
96+
);
97+
98+
let tooltipText = '';
99+
if (mentionItem) {
100+
tooltipText = `Name: ${mentionItem.originalName}`;
101+
if (mentionItem.schema) tooltipText += ` | Schema: ${mentionItem.schema}`;
102+
}
103+
104+
return (
105+
<Tooltip label={tooltipText} placement="top" hasArrow>
106+
<span style={{ color: '#3182ce', fontWeight: 500 }}>
107+
@{tableName}
108+
</span>
109+
</Tooltip>
110+
);
111+
}
112+
if (text.startsWith('[mention:model:')) {
113+
const modelName = text.replace('[mention:model:', '').replace(']', '');
114+
const mentionItem = (props as any).mentionItems?.find((item: MentionItem) =>
115+
item.name === modelName && item.type === 'model'
116+
);
117+
118+
let tooltipText = '';
119+
if (mentionItem) {
120+
tooltipText = `Name: ${mentionItem.originalName}`;
121+
if (mentionItem.collection) tooltipText += ` | Collection: ${mentionItem.collection}`;
122+
}
123+
124+
return (
125+
<Tooltip label={tooltipText} placement="top" hasArrow>
126+
<span style={{ color: '#805ad5', fontWeight: 500 }}>
127+
@{modelName}
128+
</span>
129+
</Tooltip>
130+
);
131+
}
132+
if (text.startsWith('[mention:missing:')) {
133+
const parts = text.replace('[mention:missing:', '').replace(']', '').split(':');
134+
const type = parts[0];
135+
const id = parts[1];
136+
return (
137+
<span style={{ color: '#718096' }}>
138+
@[{type}:{id}]
139+
</span>
140+
);
90141
}
91142
}
92143

@@ -551,6 +602,90 @@ function extractLastQueryFromMessages(messages: any[], currentMessageIndex: numb
551602

552603
const useAppStore = getApp().useStore();
553604

605+
// Component to detect @ mentions in text and render with colors
606+
function MentionAwareText({ children }: { children: React.ReactNode }) {
607+
const toolContext: MetabaseContext = useAppStore((state) => state.toolContext)
608+
609+
// Create mention items for determining colors
610+
const mentionItems = useMemo(() => {
611+
if (!toolContext?.dbInfo) return []
612+
const tables = toolContext.dbInfo.tables || []
613+
const models = toolContext.dbInfo.models || []
614+
return createMentionItems(tables, models)
615+
}, [toolContext?.dbInfo])
616+
617+
// Create lookup map for determining mention types
618+
const mentionMap = useMemo(() => {
619+
const map = new Map<string, 'table' | 'model'>()
620+
mentionItems.forEach((item: MentionItem) => {
621+
map.set(item.name.toLowerCase(), item.type)
622+
})
623+
return map
624+
}, [mentionItems])
625+
626+
// Only process string children
627+
if (typeof children !== 'string') {
628+
return <>{children}</>
629+
}
630+
631+
const renderTextWithColors = (text: string) => {
632+
// Regex to find @ mentions (@ followed by word characters)
633+
const mentionRegex = /@(\w+)/g
634+
const parts: (string | JSX.Element)[] = []
635+
let lastIndex = 0
636+
let match
637+
638+
while ((match = mentionRegex.exec(text)) !== null) {
639+
// Add text before the mention
640+
if (match.index > lastIndex) {
641+
parts.push(text.slice(lastIndex, match.index))
642+
}
643+
644+
const mentionName = match[1]
645+
const mentionType = mentionMap.get(mentionName.toLowerCase())
646+
647+
if (mentionType) {
648+
// Render as colored mention
649+
const color = mentionType === 'table' ? '#3182ce' : '#805ad5' // Blue for tables, purple for models
650+
parts.push(
651+
<span
652+
key={`mention-${match.index}`}
653+
style={{ color, fontWeight: 500 }}
654+
>
655+
@{mentionName}
656+
</span>
657+
)
658+
} else {
659+
// Check if it looks like a missing reference pattern @[type:id]
660+
if (text.slice(match.index).match(/^@\[[^:]+:\w+\]/)) {
661+
parts.push(
662+
<span
663+
key={`mention-missing-${match.index}`}
664+
style={{ color: '#718096' }}
665+
>
666+
{match[0]}
667+
</span>
668+
)
669+
} else {
670+
// Regular @ mention, not in our database
671+
parts.push(match[0])
672+
}
673+
}
674+
675+
lastIndex = match.index + match[0].length
676+
}
677+
678+
// Add remaining text
679+
if (lastIndex < text.length) {
680+
parts.push(text.slice(lastIndex))
681+
}
682+
683+
return parts.length > 1 ? <>{parts}</> : text
684+
}
685+
686+
return <>{renderTextWithColors(children)}</>
687+
}
688+
554689
export function Markdown({content, messageIndex}: {content: string, messageIndex?: number}) {
555690
const currentThread = useSelector((state: RootState) =>
556691
state.chat.threads[state.chat.activeThread]
@@ -566,6 +701,14 @@ export function Markdown({content, messageIndex}: {content: string, messageIndex
566701
const mxModels = useSelector((state: RootState) => state.cache.mxModels);
567702

568703
// Process template variables like {{MX_LAST_QUERY_URL}}
704+
// Create mention items from toolContext
705+
const mentionItems = useMemo(() => {
706+
if (!toolContext?.dbInfo) return []
707+
const tables = toolContext.dbInfo.tables || []
708+
const models = toolContext.dbInfo.models || []
709+
return createMentionItems(tables, models)
710+
}, [toolContext?.dbInfo])
711+
569712
const processedContent = React.useMemo(() => {
570713
if (content.includes('{{MX_LAST_QUERY_URL}}')) {
571714
try {
@@ -594,7 +737,38 @@ export function Markdown({content, messageIndex}: {content: string, messageIndex
594737
return content;
595738
}, [content, currentThread?.messages, toolContext?.dbId, messageIndex, settings, mxModels]);
596739

740+
// Create a wrapped ModifiedCode component that has access to mentionItems
741+
const ModifiedCodeWithMentions = (props: any) => (
742+
<ModifiedCode {...props} mentionItems={mentionItems} />
743+
)
744+
597745
return (
598-
<MarkdownComponent remarkPlugins={[remarkGfm]} className={"markdown"} components={{ a: LinkRenderer, p: ModifiedParagraph, ul: ModifiedUL, ol: ModifiedOL, img: ImageComponent, pre: ModifiedPre, blockquote: ModifiedBlockquote, code: ModifiedCode, hr: HorizontalLine, h1: ModifiedH1, h2: ModifiedH2, h3: ModifiedH3, h4: ModifiedH4, table: ModifiedTable, thead: ModifiedThead, tbody: ModifiedTbody, tr: ModifiedTr, th: ModifiedTh, td: ModifiedTd }}>{processedContent}</MarkdownComponent>
746+
<MarkdownComponent
747+
remarkPlugins={[remarkGfm]}
748+
className={"markdown"}
749+
components={{
750+
a: LinkRenderer,
751+
p: ModifiedParagraph,
752+
ul: ModifiedUL,
753+
ol: ModifiedOL,
754+
img: ImageComponent,
755+
pre: ModifiedPre,
756+
blockquote: ModifiedBlockquote,
757+
code: ModifiedCodeWithMentions,
758+
hr: HorizontalLine,
759+
h1: ModifiedH1,
760+
h2: ModifiedH2,
761+
h3: ModifiedH3,
762+
h4: ModifiedH4,
763+
table: ModifiedTable,
764+
thead: ModifiedThead,
765+
tbody: ModifiedTbody,
766+
tr: ModifiedTr,
767+
th: ModifiedTh,
768+
td: ModifiedTd,
769+
}}
770+
>
771+
{processedContent}
772+
</MarkdownComponent>
599773
)
600774
}

0 commit comments

Comments
 (0)