Skip to content

Commit

Permalink
feat: Link Mention (#33)
Browse files Browse the repository at this point in the history
* implement AutoLinkMentionPlugin

* add MentionableUrlBadge & remove unnecessary onAddMention on NewMentionsPlugin

* set displayed mentionable on mention added

* process html to markdown and include in prompt
  • Loading branch information
glowingjade authored Oct 24, 2024
1 parent d682f69 commit 1e4ee3e
Show file tree
Hide file tree
Showing 10 changed files with 330 additions and 29 deletions.
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/turndown": "^5.0.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
Expand Down Expand Up @@ -70,6 +71,7 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0",
"turndown": "^7.2.0",
"uuid": "^10.0.0",
"zod": "^3.23.8"
}
Expand Down
27 changes: 8 additions & 19 deletions src/components/chat-view/chat-input/ChatUserInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { MemoizedSyntaxHighlighterWrapper } from '../SyntaxHighlighterWrapper'

import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
import AutoLinkMentionPlugin from './plugins/mention/AutoLinkMentionPlugin'
import { MentionNode } from './plugins/mention/MentionNode'
import MentionPlugin from './plugins/mention/MentionPlugin'
import NoFormatPlugin from './plugins/no-format/NoFormatPlugin'
Expand Down Expand Up @@ -127,21 +128,6 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
[app],
)

const handleMentionFile = (mentionable: Mentionable) => {
const mentionableKey = getMentionableKey(
serializeMentionable(mentionable),
)
if (
mentionables.some(
(m) => getMentionableKey(serializeMentionable(m)) === mentionableKey,
)
) {
return
}
setMentionables([...mentionables, mentionable])
setDisplayedMentionableKey(mentionableKey)
}

const handleMentionNodeMutation = (
mutations: NodeMutations<MentionNode>,
) => {
Expand Down Expand Up @@ -195,6 +181,11 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
.filter((v) => !!v),
),
)
if (addedMentionables.length > 0) {
setDisplayedMentionableKey(
getMentionableKey(addedMentionables[addedMentionables.length - 1]),
)
}
}

const handleMentionableDelete = (mentionable: Mentionable) => {
Expand Down Expand Up @@ -331,10 +322,7 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
/>
<HistoryPlugin />
{autoFocus && <AutoFocusPlugin />}
<MentionPlugin
searchResultByQuery={searchResultByQuery}
onAddMention={handleMentionFile}
/>
<MentionPlugin searchResultByQuery={searchResultByQuery} />
<OnChangePlugin
onChange={(editorState) => {
onChange(editorState.toJSON())
Expand All @@ -354,6 +342,7 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
/>
<EditorRefPlugin editorRef={editorRef} />
<NoFormatPlugin />
<AutoLinkMentionPlugin />
</LexicalComposer>
<div className="smtcmp-chat-user-input-controls">
<ModelSelect />
Expand Down
38 changes: 36 additions & 2 deletions src/components/chat-view/chat-input/MentionableBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MentionableCurrentFile,
MentionableFile,
MentionableFolder,
MentionableUrl,
MentionableVault,
} from '../../../types/mentionable'

Expand Down Expand Up @@ -133,7 +134,7 @@ function CurrentFileBadge({
className="smtcmp-chat-user-input-file-badge-name-icon"
/>
)}
<span>{`${mentionable.file.name}`}</span>
<span>{mentionable.file.name}</span>
</div>
<div className="smtcmp-chat-user-input-file-badge-name-block-suffix">
{' (Current File)'}
Expand Down Expand Up @@ -161,7 +162,7 @@ function BlockBadge({
className="smtcmp-chat-user-input-file-badge-name-block-name-icon"
/>
)}
<span>{`${mentionable.file.name}`}</span>
<span>{mentionable.file.name}</span>
</div>
<div className="smtcmp-chat-user-input-file-badge-name-block-suffix">
{` (${mentionable.startLine}:${mentionable.endLine})`}
Expand All @@ -170,6 +171,31 @@ function BlockBadge({
)
}

function UrlBadge({
mentionable,
onDelete,
onClick,
}: {
mentionable: MentionableUrl
onDelete: () => void
onClick: () => void
}) {
const Icon = getMentionableIcon(mentionable)
return (
<BadgeBase onDelete={onDelete} onClick={onClick}>
<div className="smtcmp-chat-user-input-file-badge-name">
{Icon && (
<Icon
size={10}
className="smtcmp-chat-user-input-file-badge-name-icon"
/>
)}
<span>{mentionable.url}</span>
</div>
</BadgeBase>
)
}

export default function MentionableBadge({
mentionable,
onDelete,
Expand Down Expand Up @@ -220,5 +246,13 @@ export default function MentionableBadge({
onClick={onClick}
/>
)
case 'url':
return (
<UrlBadge
mentionable={mentionable}
onDelete={onDelete}
onClick={onClick}
/>
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$createTextNode,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
PASTE_COMMAND,
PasteCommandType,
TextNode,
} from 'lexical'
import { useEffect } from 'react'

import { Mentionable, MentionableUrl } from '../../../../../types/mentionable'
import {
getMentionableName,
serializeMentionable,
} from '../../../../../utils/mentionable'

import { $createMentionNode } from './MentionNode'

const URL_MATCHER =
/^((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/

type URLMatch = {
index: number
length: number
text: string
url: string
}

function findURLs(text: string): URLMatch[] {
const urls: URLMatch[] = []

let lastIndex = 0
for (const word of text.split(' ')) {
if (URL_MATCHER.test(word)) {
urls.push({
index: lastIndex,
length: word.length,
text: word,
url: word.startsWith('http') ? word : `https://${word}`,
// attributes: { rel: 'noreferrer', target: '_blank' }, // Optional link attributes
})
}

lastIndex += word.length + 1 // +1 for space
}

return urls
}

function $textNodeTransform(node: TextNode) {
if (!node.isSimpleText()) {
return
}

const text = node.getTextContent()

// Find only 1st occurrence as transform will be re-run anyway for the rest
// because newly inserted nodes are considered to be dirty
const urlMatches = findURLs(text)
if (urlMatches.length === 0) {
return
}
const urlMatch = urlMatches[0]

// Get the current selection
const selection = $getSelection()

// Check if the selection is a RangeSelection and the cursor is at the end of the URL
if (
$isRangeSelection(selection) &&
selection.anchor.key === node.getKey() &&
selection.focus.key === node.getKey() &&
selection.anchor.offset === urlMatch.index + urlMatch.length &&
selection.focus.offset === urlMatch.index + urlMatch.length
) {
// If the cursor is at the end of the URL, don't transform
return
}

let targetNode
if (urlMatch.index === 0) {
// First text chunk within string, splitting into 2 parts
;[targetNode] = node.splitText(urlMatch.index + urlMatch.length)
} else {
// In the middle of a string
;[, targetNode] = node.splitText(
urlMatch.index,
urlMatch.index + urlMatch.length,
)
}

const mentionable: MentionableUrl = {
type: 'url',
url: urlMatch.url,
}

const mentionNode = $createMentionNode(
getMentionableName(mentionable),
serializeMentionable(mentionable),
)

targetNode.replace(mentionNode)

const spaceNode = $createTextNode(' ')
mentionNode.insertAfter(spaceNode)

spaceNode.select()
}

function $handlePaste(event: PasteCommandType) {
const clipboardData =
event instanceof ClipboardEvent ? event.clipboardData : null

if (!clipboardData) return false

const text = clipboardData.getData('text/plain')

const urlMatches = findURLs(text)
if (urlMatches.length === 0) {
return false
}

const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}

const nodes = []
const addedMentionables: Mentionable[] = []
let lastIndex = 0

urlMatches.forEach((urlMatch) => {
// Add text node for unmatched part
if (urlMatch.index > lastIndex) {
nodes.push($createTextNode(text.slice(lastIndex, urlMatch.index)))
}

const mentionable: MentionableUrl = {
type: 'url',
url: urlMatch.url,
}

// Add mention node
nodes.push(
$createMentionNode(urlMatch.text, serializeMentionable(mentionable)),
)
addedMentionables.push(mentionable)

lastIndex = urlMatch.index + urlMatch.length

// Add space node after mention if next character is not space or end of string
if (lastIndex >= text.length || text[lastIndex] !== ' ') {
nodes.push($createTextNode(' '))
}
})

// Add remaining text if any
if (lastIndex < text.length) {
nodes.push($createTextNode(text.slice(lastIndex)))
}

selection.insertNodes(nodes)
return true
}

export default function AutoLinkMentionPlugin() {
const [editor] = useLexicalComposerContext()

useEffect(() => {
editor.registerCommand(PASTE_COMMAND, $handlePaste, COMMAND_PRIORITY_LOW)

editor.registerNodeTransform(TextNode, $textNodeTransform)
}, [editor])

return null
}
Loading

0 comments on commit 1e4ee3e

Please sign in to comment.