Skip to content

Commit ba4f8eb

Browse files
committed
Add AI Chat as resource #951
1 parent df29418 commit ba4f8eb

33 files changed

+2197
-715
lines changed

browser/data-browser/package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@
2020
"@radix-ui/react-scroll-area": "^1.2.0",
2121
"@radix-ui/react-tabs": "^1.1.1",
2222
"@tanstack/react-router": "^1.95.1",
23-
"@tiptap/extension-image": "^2.9.1",
24-
"@tiptap/extension-link": "^2.9.1",
25-
"@tiptap/extension-placeholder": "^2.9.1",
26-
"@tiptap/extension-typography": "^2.9.1",
27-
"@tiptap/pm": "^2.9.1",
28-
"@tiptap/react": "^2.9.1",
29-
"@tiptap/starter-kit": "^2.9.1",
30-
"@tiptap/suggestion": "^2.9.1",
23+
"@tiptap/extension-image": "^2.11.7",
24+
"@tiptap/extension-link": "^2.11.7",
25+
"@tiptap/extension-mention": "^2.11.7",
26+
"@tiptap/extension-placeholder": "^2.11.7",
27+
"@tiptap/extension-typography": "^2.11.7",
28+
"@tiptap/pm": "^2.11.7",
29+
"@tiptap/react": "^2.11.7",
30+
"@tiptap/starter-kit": "^2.11.7",
31+
"@tiptap/suggestion": "^2.11.7",
3132
"@tomic/react": "workspace:*",
3233
"ai": "^4.1.61",
3334
"emoji-mart": "^5.6.0",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { EditorContent, useEditor, type JSONContent } from '@tiptap/react';
2+
import { styled } from 'styled-components';
3+
import StarterKit from '@tiptap/starter-kit';
4+
import Mention from '@tiptap/extension-mention';
5+
import { TiptapContextProvider } from '../TiptapContext';
6+
import { EditorWrapperBase } from '../EditorWrapperBase';
7+
import { searchSuggestionBuilder } from './resourceSuggestions';
8+
import { useEffect, useRef, useState } from 'react';
9+
import { EditorEvents } from '../EditorEvents';
10+
import { Markdown } from 'tiptap-markdown';
11+
import { useStore } from '@tomic/react';
12+
import { useSettings } from '../../../helpers/AppSettings';
13+
import type { Node } from '@tiptap/pm/model';
14+
import Placeholder from '@tiptap/extension-placeholder';
15+
16+
// Modify the Mention extension to allow serializing to markdown.
17+
const SerializableMention = Mention.extend({
18+
addStorage() {
19+
return {
20+
markdown: {
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
serialize(state: any, node: Node) {
23+
state.write('@' + (node.attrs.label || ''));
24+
state.renderContent(node);
25+
state.flushClose(1);
26+
state.closeBlock(node);
27+
},
28+
},
29+
};
30+
},
31+
});
32+
33+
interface AsyncAIChatInputProps {
34+
onMentionUpdate: (mentions: string[]) => void;
35+
onChange: (markdown: string) => void;
36+
onSubmit: () => void;
37+
}
38+
39+
const AsyncAIChatInput: React.FC<AsyncAIChatInputProps> = ({
40+
onMentionUpdate,
41+
onChange,
42+
onSubmit,
43+
}) => {
44+
const store = useStore();
45+
const { drive } = useSettings();
46+
const [markdown, setMarkdown] = useState('');
47+
const markdownRef = useRef(markdown);
48+
const onSubmitRef = useRef(onSubmit);
49+
50+
const editor = useEditor({
51+
extensions: [
52+
Markdown.configure({
53+
html: true,
54+
}),
55+
StarterKit.extend({
56+
addKeyboardShortcuts() {
57+
return {
58+
Enter: () => {
59+
// Check if the cursor is in a code block, if so allow the user to press enter.
60+
// Pressing shift + enter will exit the code block.
61+
if ('language' in this.editor.getAttributes('codeBlock')) {
62+
return false;
63+
}
64+
65+
// The content has to be read from a ref because this callback is not updated often leading to stale content.
66+
onSubmitRef.current();
67+
setMarkdown('');
68+
this.editor.commands.clearContent();
69+
70+
return true;
71+
},
72+
};
73+
},
74+
}).configure({
75+
blockquote: false,
76+
bulletList: false,
77+
orderedList: false,
78+
// paragraph: false,
79+
heading: false,
80+
listItem: false,
81+
horizontalRule: false,
82+
bold: false,
83+
strike: false,
84+
italic: false,
85+
}),
86+
SerializableMention.configure({
87+
HTMLAttributes: {
88+
class: 'ai-chat-mention',
89+
},
90+
suggestion: searchSuggestionBuilder(store, drive),
91+
renderText({ options, node }) {
92+
return `${options.suggestion.char}${node.attrs.title}`;
93+
},
94+
}),
95+
Placeholder.configure({
96+
placeholder: 'Ask me anything...',
97+
}),
98+
],
99+
});
100+
101+
const handleChange = (value: string) => {
102+
setMarkdown(value);
103+
markdownRef.current = value;
104+
onChange(value);
105+
106+
if (!editor) {
107+
return;
108+
}
109+
110+
console.log('update', editor.getJSON());
111+
const mentions = digForMentions(editor.getJSON());
112+
onMentionUpdate(Array.from(new Set(mentions)));
113+
};
114+
115+
useEffect(() => {
116+
markdownRef.current = markdown;
117+
onSubmitRef.current = onSubmit;
118+
}, [markdown, onSubmit]);
119+
120+
return (
121+
<EditorWrapper hideEditor={false}>
122+
<TiptapContextProvider editor={editor}>
123+
<EditorContent editor={editor} />
124+
<EditorEvents onChange={handleChange} />
125+
</TiptapContextProvider>
126+
</EditorWrapper>
127+
);
128+
};
129+
130+
export default AsyncAIChatInput;
131+
132+
const EditorWrapper = styled(EditorWrapperBase)`
133+
padding: ${p => p.theme.size(2)};
134+
font-size: 16px;
135+
line-height: 1.5;
136+
137+
.ai-chat-mention {
138+
background-color: ${p => p.theme.colors.mainSelectedBg};
139+
color: ${p => p.theme.colors.mainSelectedFg};
140+
border-radius: 5px;
141+
padding-inline: ${p => p.theme.size(1)};
142+
}
143+
`;
144+
145+
function digForMentions(data: JSONContent): string[] {
146+
if (data.type === 'mention') {
147+
return [data.attrs!.id];
148+
}
149+
150+
if (data.content) {
151+
return data.content.flatMap(digForMentions);
152+
}
153+
154+
return [];
155+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
2+
import styled from 'styled-components';
3+
4+
export type SearchSuggestion = {
5+
id: string;
6+
label: string;
7+
};
8+
export interface MentionListProps {
9+
items: SearchSuggestion[];
10+
command: (item: SearchSuggestion) => void;
11+
}
12+
13+
export interface MentionListRef {
14+
onKeyDown: ({ event }: { event: KeyboardEvent }) => boolean;
15+
}
16+
17+
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
18+
({ items, command }, ref) => {
19+
const [selectedIndex, setSelectedIndex] = useState(0);
20+
21+
const selectItem = (index: number) => {
22+
const item = items[index];
23+
24+
if (item) {
25+
command(item);
26+
}
27+
};
28+
29+
const upHandler = () => {
30+
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
31+
};
32+
33+
const downHandler = () => {
34+
setSelectedIndex((selectedIndex + 1) % items.length);
35+
};
36+
37+
const enterHandler = () => {
38+
selectItem(selectedIndex);
39+
};
40+
41+
useEffect(() => setSelectedIndex(0), [items]);
42+
43+
useImperativeHandle(ref, () => ({
44+
onKeyDown: ({ event }) => {
45+
if (event.key === 'ArrowUp') {
46+
upHandler();
47+
48+
return true;
49+
}
50+
51+
if (event.key === 'ArrowDown') {
52+
downHandler();
53+
54+
return true;
55+
}
56+
57+
if (event.key === 'Enter') {
58+
enterHandler();
59+
60+
return true;
61+
}
62+
63+
return false;
64+
},
65+
}));
66+
67+
return (
68+
<DropdownMenu>
69+
{items.length ? (
70+
items.map((item, index) => (
71+
<button
72+
className={index === selectedIndex ? 'is-selected' : ''}
73+
key={item.id}
74+
onClick={() => selectItem(index)}
75+
>
76+
{item.label}
77+
</button>
78+
))
79+
) : (
80+
<div className='item'>No result</div>
81+
)}
82+
</DropdownMenu>
83+
);
84+
},
85+
);
86+
87+
MentionList.displayName = 'MentionList';
88+
89+
const DropdownMenu = styled.div`
90+
background: ${p => p.theme.colors.bg};
91+
border-radius: 0.7rem;
92+
box-shadow: ${p => p.theme.boxShadowIntense};
93+
display: flex;
94+
flex-direction: column;
95+
gap: 0.1rem;
96+
overflow: auto;
97+
padding: 0.4rem;
98+
position: relative;
99+
100+
button {
101+
background: transparent;
102+
appearance: none;
103+
border: none;
104+
border-radius: ${p => p.theme.radius};
105+
display: flex;
106+
gap: 0.25rem;
107+
text-align: left;
108+
width: 100%;
109+
110+
&:hover,
111+
&.is-selected {
112+
background-color: ${p => p.theme.colors.mainSelectedBg};
113+
color: ${p => p.theme.colors.mainSelectedFg};
114+
}
115+
}
116+
`;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ReactRenderer } from '@tiptap/react';
2+
import tippy, { type Instance } from 'tippy.js';
3+
import {
4+
MentionList,
5+
type MentionListProps,
6+
type MentionListRef,
7+
type SearchSuggestion,
8+
} from './MentionList';
9+
import type { Store } from '@tomic/react';
10+
import type { SuggestionOptions } from '@tiptap/suggestion';
11+
12+
export const searchSuggestionBuilder = (
13+
store: Store,
14+
drive: string,
15+
): Partial<SuggestionOptions> => ({
16+
items: async ({ query }: { query: string }): Promise<SearchSuggestion[]> => {
17+
const results = await store.search(query, {
18+
limit: 10,
19+
include: true,
20+
parents: [drive],
21+
});
22+
23+
const resultResources = await Promise.all(
24+
results.map(subject => store.getResource(subject)),
25+
);
26+
27+
return resultResources.map(resource => ({
28+
id: resource.subject,
29+
label: resource.title,
30+
}));
31+
},
32+
33+
render: () => {
34+
let component: ReactRenderer<MentionListRef, MentionListProps>;
35+
let popup: Instance[];
36+
37+
return {
38+
onStart: props => {
39+
component = new ReactRenderer(MentionList, {
40+
props,
41+
editor: props.editor,
42+
});
43+
44+
if (!props.clientRect) {
45+
return;
46+
}
47+
48+
popup = tippy('body', {
49+
getReferenceClientRect: props.clientRect as () => DOMRect,
50+
appendTo: () => document.body,
51+
content: component.element,
52+
showOnCreate: true,
53+
interactive: true,
54+
trigger: 'manual',
55+
placement: 'bottom-start',
56+
});
57+
},
58+
59+
onUpdate(props) {
60+
component.updateProps(props);
61+
62+
if (!props.clientRect) {
63+
return;
64+
}
65+
66+
popup[0].setProps({
67+
getReferenceClientRect: props.clientRect as () => DOMRect,
68+
});
69+
},
70+
71+
onKeyDown(props) {
72+
if (props.event.key === 'Escape') {
73+
popup[0].hide();
74+
75+
return true;
76+
}
77+
78+
if (!component.ref) {
79+
return false;
80+
}
81+
82+
return component.ref.onKeyDown(props);
83+
},
84+
85+
onExit() {
86+
popup[0].destroy();
87+
component.destroy();
88+
},
89+
};
90+
},
91+
});

0 commit comments

Comments
 (0)