├── .env
├── .gitignore
├── LICENSE
├── README.md
├── build.sh
├── build
└── main.js
├── extension.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
├── RoamAIMenu.tsx
├── dom.ts
├── index.css
└── index.ts
├── tailwind.config.js
└── tsconfig.json
/.env:
--------------------------------------------------------------------------------
1 | ROAMJS_VERSION=2022-07-24-17-45
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | dist
4 | out
5 | .env
6 | .env.local
7 | extension.js
8 | extension.js.LICENSE.txt
9 | report.html
10 | stats.json
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 null
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Roam AI
2 |
3 | The AI extension for Roam.
4 |
5 | ## Get started
6 |
7 | - Add your own Open AI API key in the Roam Depot settings. You can [sign up for an API key here](https://openai.com/api/).
8 | - To open the menu, type `qq` in any block.
9 |
10 | ## Capabilities
11 |
12 | ### ▶️ Run
13 |
14 | Have a conversation based on the context in the current page
15 |
16 | AI rseponses are tagged with `[assistant]:`
17 |
18 | ### 🧱 Run (load context)
19 |
20 | Load existing pages as context.
21 |
22 | https://www.loom.com/share/046f983192cb4cbb954ba3b8541f3645
23 |
24 | #### what gets included in the prompt:
25 |
26 | The content of all [[]] and (()) above the current block on this page will be loaded
27 |
28 | ### 🌅 Generate Image
29 |
30 | Generate an image using DALL-E 3 or DALL-E 2.
31 |
32 | This reads only the current block.
33 |
34 | ## Troubleshooting
35 |
36 | - If you are unable to open the menu again, press "Esc" to reset.
37 | - For all questions / suggestions, DM me [@Lay_Bacc](https://twitter.com/Lay_Bacc/)
38 |
39 | ## Custom models
40 |
41 | The format for configuring custom models then looks like this:
42 |
43 | ```
44 | [{ "name": "model A", "endpoint": "closedai.com/v1/completions", "displayName": "model A", "model": "model-001" }]
45 | ```
46 |
47 | Note: the `endpoint` parameter is used internally by the plugin. And it's unrelated to the deprecated parameter in OpenAI's API.
48 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | npm run build:roam
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "roam-ai",
3 | "version": "1.0.0",
4 | "description": "AI extension for Roam",
5 | "main": "./build/main.js",
6 | "scripts": {
7 | "prebuild:roam": "npm install",
8 | "build:roam": "roamjs-scripts build --depot",
9 | "dev": "roamjs-scripts dev",
10 | "dev:roam": "roamjs-scripts dev --depot",
11 | "start": "roamjs-scripts dev"
12 | },
13 | "license": "MIT",
14 | "devDependencies": {
15 | "@headlessui/react": "^1.6.6",
16 | "hashids": "^2.2.11",
17 | "roamjs-scripts": "^0.21.3"
18 | },
19 | "dependencies": {
20 | "roamjs-components": "^0.70.7"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/RoamAIMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback,
3 | useEffect,
4 | useMemo,
5 | useRef,
6 | useState,
7 | } from "react";
8 | import ReactDOM from "react-dom";
9 | import './index.css';
10 |
11 | import { getCoords } from "./dom";
12 | import { Menu } from '@headlessui/react'
13 | import type { OnloadArgs } from "roamjs-components/types/native";
14 |
15 | type Props = {
16 | textarea: HTMLTextAreaElement;
17 | triggerStart: number;
18 | triggerRegex: RegExp;
19 | extensionAPI: OnloadArgs["extensionAPI"];
20 | sendRequest: any;
21 | };
22 |
23 | const OPTIONS = [
24 | {
25 | id: 'chat',
26 | name: '▶️ Run',
27 | outputType: 'chat',
28 | endpoint: 'chat'
29 | },
30 | {
31 | id: 'run-with-context',
32 | name: '🧱 Run (load context)',
33 | outputType: 'chat',
34 | endpoint: 'chat',
35 | loadPages: true
36 | },
37 | {
38 | id: 'open-chatroam',
39 | name: '➕ New ChatRoam (blank page)',
40 | outputType: 'none',
41 | endpoint: 'none'
42 | },
43 | {
44 | id: 'generate-image',
45 | name: '🌅 Generate image',
46 | scope: 'local',
47 | outputType: 'image',
48 | endpoint: 'image'
49 | }
50 | ]
51 |
52 | const MODELS: any = {
53 | none: [{name: '-', displayName: '-' }],
54 | image: [{name: 'dall-e-3', displayName: 'dall-e-3' }, {name: 'dall-e-2', displayName: 'dall-e-2' }],
55 | chat: [
56 | {
57 | name: 'gpt-3.5-turbo-0125',
58 | displayName: 'gpt-3.5-turbo-0125'
59 | },
60 | {
61 | name: 'gpt-3.5-turbo',
62 | displayName: 'gpt-3.5-turbo'
63 | },
64 | {
65 | name: 'gpt-4-0125-preview',
66 | displayName: 'gpt-4-0125-preview'
67 | },
68 | {
69 | name: 'gpt-4',
70 | displayName: 'gpt-4'
71 | },
72 | {
73 | name: 'gpt-4-32k',
74 | displayName: 'gpt-4-32k'
75 | }
76 | ]
77 | }
78 |
79 | const RoamAIMenu = ({
80 | onClose,
81 | textarea,
82 | triggerStart,
83 | triggerRegex,
84 | extensionAPI,
85 | sendRequest,
86 | customModels
87 | }: any) => {
88 | const { ["block-uid"]: blockUid, ["window-id"]: windowId } = useMemo(
89 | () => window.roamAlphaAPI.ui.getFocusedBlock(),
90 | []
91 | );
92 | const menuRef = useRef(null);
93 | const [modelIndex, setModelIndex] = useState(0);
94 | const [filter, setFilter] = useState("");
95 | const [activeIndex, setActiveIndex] = useState(0);
96 |
97 | const getModels = () => {
98 | const endpoint = OPTIONS[activeIndex]?.endpoint || 'none';
99 | return MODELS[endpoint].concat(customModels || []);
100 | }
101 |
102 | const getCurrentModel = useCallback(() => {
103 | return getModels()[modelIndex];
104 | }, [modelIndex, getModels]); // Ensure getModels is stable via useCallback as well
105 |
106 | const onSelect = useCallback(
107 | (option) => {
108 | onClose();
109 | sendRequest(option, getCurrentModel());
110 | },
111 | [activeIndex, onClose, sendRequest, getCurrentModel]
112 | );
113 |
114 | const keydownListener = useCallback(
115 |
116 | (e: KeyboardEvent) => {
117 | // switch mode
118 | if (e.ctrlKey || e.metaKey) {
119 | // select model
120 | const modelCount = getModels().length;
121 |
122 | if (e.key === "ArrowUp") {
123 | // setModelIndex(1)//(modelIndex - 1)
124 | setModelIndex((modelIndex - 1 + modelCount) % modelCount);
125 | e.stopPropagation();
126 | e.preventDefault();
127 | return;
128 | }
129 |
130 | if (e.key === "ArrowDown") {
131 | setModelIndex((modelIndex + 1) % modelCount);
132 | // setModelIndex(1) // (modelIndex +1)
133 |
134 | e.stopPropagation();
135 | e.preventDefault();
136 | }
137 |
138 | return;
139 | }
140 | else {
141 | setModelIndex(0);
142 |
143 | if (e.key === "ArrowDown") {
144 | const index = Number(menuRef.current.getAttribute("data-active-index"));
145 | const count = menuRef.current.childElementCount;
146 |
147 | setActiveIndex((index + 1) % count);
148 | e.stopPropagation();
149 | e.preventDefault();
150 | } else if (e.key === "ArrowUp") {
151 | const index = Number(menuRef.current.getAttribute("data-active-index"));
152 | const count = menuRef.current.childElementCount;
153 | setActiveIndex((index - 1 + count) % count);
154 | e.stopPropagation();
155 | e.preventDefault();
156 | } else if (e.key == "ArrowLeft" || e.key === "ArrowRight") {
157 | // e.stopPropagation();
158 | // e.preventDefault();
159 | } else if (e.key === "Enter") {
160 | const index = Number(menuRef.current.getAttribute("data-active-index"));
161 | onSelect(OPTIONS[index]);
162 | e.stopPropagation();
163 | e.preventDefault();
164 | } else if (e.key === "Escape") {
165 | onClose();
166 | } else {
167 | const value =
168 | triggerRegex.exec(
169 | textarea.value.substring(0, textarea.selectionStart)
170 | )?.[1] || "";
171 | if (value) {
172 | setFilter(value);
173 | } else {
174 | onClose();
175 | return;
176 | }
177 | }
178 | }
179 | },
180 | [menuRef, setActiveIndex, setFilter, onClose, triggerRegex, textarea, modelIndex, activeIndex]
181 | );
182 |
183 | useEffect(() => {
184 | const listeningEl = !!textarea.closest(".rm-reference-item")
185 | ? textarea.parentElement // Roam rerenders a new textarea in linked references on every keypress
186 | : textarea;
187 | listeningEl.addEventListener("keydown", keydownListener);
188 | return () => {
189 | listeningEl.removeEventListener("keydown", keydownListener);
190 | };
191 | }, [keydownListener]);
192 |
193 |
194 |
195 | return (
196 |
199 |
200 |
201 | { getCurrentModel()?.displayName }
202 |
203 |
204 | ⌘ + ↑/↓ to select model
205 |
206 |
207 |
208 |
213 | {
214 | OPTIONS.map((option, index) => {
215 | return(
216 |
onSelect(option)}
219 | >
220 | { option.name }
221 |
222 | )
223 | })
224 | }
225 |
226 |
227 | );
228 | };
229 |
230 | export const render = (props: any) => {
231 | const parent = document.createElement("span");
232 | const coords = getCoords(props.textarea);
233 | parent.style.position = "absolute";
234 | parent.style.zIndex = '10';
235 | parent.style.boxShadow = '0 0 0 1px rgb(16 22 26 / 10%), 0 2px 4px rgb(16 22 26 / 20%), 0 8px 24px rgb(16 22 26 / 20%)';
236 | parent.style.border = '1px solid rgb(213,218,222)';
237 | parent.style.width = '400px';
238 | parent.style.left = `${coords.left + 20}px`;
239 | parent.style.top = `${coords.top + 20}px`;
240 | props.textarea.parentElement.insertBefore(parent, props.textarea);
241 |
242 | ReactDOM.render(
243 | {
246 | props.onClose();
247 | ReactDOM.unmountComponentAtNode(parent);
248 | parent.remove();
249 | }}
250 | />,
251 | parent
252 | );
253 | };
254 |
255 | export default RoamAIMenu;
--------------------------------------------------------------------------------
/src/dom.ts:
--------------------------------------------------------------------------------
1 | // inspired by https://github.com/zurb/tribute/blob/master/src/TributeRange.js#L446-L556
2 | export const getCoords = (t: HTMLTextAreaElement) => {
3 | let properties = [
4 | "direction",
5 | "boxSizing",
6 | "width",
7 | "height",
8 | "overflowX",
9 | "overflowY",
10 | "borderTopWidth",
11 | "borderRightWidth",
12 | "borderBottomWidth",
13 | "borderLeftWidth",
14 | "paddingTop",
15 | "paddingRight",
16 | "paddingBottom",
17 | "paddingLeft",
18 | "fontStyle",
19 | "fontVariant",
20 | "fontWeight",
21 | "fontStretch",
22 | "fontSize",
23 | "fontSizeAdjust",
24 | "lineHeight",
25 | "fontFamily",
26 | "textAlign",
27 | "textTransform",
28 | "textIndent",
29 | "textDecoration",
30 | "letterSpacing",
31 | "wordSpacing",
32 | ] as const;
33 |
34 | const div = document.createElement("div");
35 | div.id = "input-textarea-caret-position-mirror-div";
36 | document.body.appendChild(div);
37 |
38 | const style = div.style;
39 | const computed = getComputedStyle(t);
40 |
41 | style.whiteSpace = "pre-wrap";
42 | style.wordWrap = "break-word";
43 |
44 | // position off-screen
45 | style.position = "absolute";
46 | style.visibility = "hidden";
47 | style.overflow = "hidden";
48 |
49 | // transfer the element's properties to the div
50 | properties.forEach((prop) => {
51 | style[prop] = computed[prop];
52 | });
53 |
54 | div.textContent = t.value.substring(0, t.selectionStart);
55 |
56 | const span = document.createElement("span");
57 | span.textContent = t.value.substring(t.selectionStart) || ".";
58 | div.appendChild(span);
59 |
60 | const doc = document.documentElement;
61 | const windowLeft =
62 | (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0);
63 | const windowTop =
64 | (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
65 |
66 | const coordinates = {
67 | top:
68 | windowTop +
69 | span.offsetTop +
70 | parseInt(computed.borderTopWidth) +
71 | parseInt(computed.fontSize) -
72 | t.scrollTop -
73 | 9,
74 | left: windowLeft + span.offsetLeft + parseInt(computed.borderLeftWidth) - 1,
75 | };
76 | document.body.removeChild(div);
77 | return coordinates;
78 | };
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LayBacc/roam-ai/032eb90727122a60186c6387249b3d52f2bda53b/src/index.css
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import toConfigPageName from 'roamjs-components/util/toConfigPageName';
2 | import runExtension from 'roamjs-components/util/runExtension';
3 | import addStyle from 'roamjs-components/dom/addStyle';
4 | import createBlock from 'roamjs-components/writes/createBlock';
5 | import updateBlock from 'roamjs-components/writes/updateBlock';
6 | import getCurrentPageUid from 'roamjs-components/dom/getCurrentPageUid';
7 |
8 | import getOrderByBlockUid from 'roamjs-components/queries/getOrderByBlockUid';
9 | import getParentUidByBlockUid from 'roamjs-components/queries/getParentUidByBlockUid';
10 | import getBasicTreeByParentUid from 'roamjs-components/queries/getBasicTreeByParentUid';
11 | import getTextByBlockUid from 'roamjs-components/queries/getTextByBlockUid';
12 | import getPageTitleByBlockUid from 'roamjs-components/queries/getPageTitleByBlockUid';
13 | import getFullTreeByParentUid from 'roamjs-components/queries/getFullTreeByParentUid';
14 | import getPageUidByPageTitle from 'roamjs-components/queries/getPageUidByPageTitle';
15 | import getShallowTreeByParentUid from 'roamjs-components/queries/getShallowTreeByParentUid';
16 |
17 | import Hashids from 'hashids';
18 |
19 | import { render as renderMenu } from './RoamAIMenu';
20 |
21 | const extensionId = 'roam-ai';
22 | const CONFIG = toConfigPageName(extensionId);
23 |
24 | let lastEditedBlockUid: string;
25 | let valueToCursor: string;
26 |
27 | let OPEN_AI_API_KEY = '';
28 | let MAX_TOKENS = 256;
29 | let MAX_WINDOW_SIZE = 4000;
30 | let CONTENT_TAG = '';
31 | let CUSTOM_MODELS: any = [];
32 |
33 | const hashids = new Hashids();
34 |
35 | const normalizePageTitle = (title: string): string =>
36 | title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
37 |
38 | async function extractString(blockText: string) {
39 | const match = blockText.match(/\(([^()]+)\)/);
40 | if (match) {
41 | const blockText = match[1];
42 |
43 | if (
44 | window.roamjs.extension.queryBuilder &&
45 | (window.roamjs.extension.queryBuilder as any)?.runQuery
46 | ) {
47 | try {
48 | const results = await (
49 | window.roamjs.extension.queryBuilder as any
50 | )?.runQuery(blockText);
51 | if (results && results.length > 0) {
52 | const res = JSON.stringify(results, null, 2);
53 | return res;
54 | }
55 | return null;
56 | } catch (err) {
57 | console.error('Error fetching query results:', err);
58 | return null;
59 | }
60 | } else {
61 | console.warn(
62 | 'QueryBuilder API is not available on window.roamjs.extension.queryBuilder',
63 | );
64 | }
65 | }
66 | return Promise.resolve(null);
67 | }
68 |
69 | const parseModulesFromString = async (rawString: string) => {
70 | const output: any = [];
71 |
72 | const matches = rawString.match(/\[\[(.*?)\]\]|\(\((.*?)\)\)/g);
73 |
74 | if (!matches) {
75 | return output;
76 | }
77 |
78 | for (const match of matches) {
79 | const isPageReference = match.startsWith('[[');
80 | const title = isPageReference ? match.slice(2, match.length - 2) : null;
81 | const id = isPageReference ? null : match.slice(2, match.length - 2);
82 | const isQueryBlock = await extractString(rawString);
83 |
84 | output.push({
85 | title,
86 | id,
87 | type: isPageReference ? 'page' : isQueryBlock ? 'qry-block' : 'block',
88 | qryRes: isQueryBlock,
89 | });
90 | }
91 |
92 | return output;
93 | };
94 |
95 | const parseRoamTree = (data: any, level: number = 0): string => {
96 | let result = '';
97 | if (!data || data?.length === 0) {
98 | return result;
99 | }
100 |
101 | for (const block of data) {
102 | if (block.text) {
103 | // add the current block to the result
104 | result += `${'\t'.repeat(level)}- ${block.text}\n`;
105 |
106 | // check for nested blocks
107 | if (block.children && block.children.length > 0) {
108 | result += parseRoamTree(block.children, level + 1);
109 | }
110 | }
111 | }
112 | return result;
113 | };
114 |
115 | const loadContext = async (tree: any): Promise => {
116 | const treeRaw = parseRoamTree(tree.children);
117 | const blocksToLoad = await parseModulesFromString(treeRaw);
118 |
119 | let contextPackets: any = [];
120 |
121 | blocksToLoad.map((block: any) => {
122 | let blockUid = block.id;
123 | let title = block.title;
124 | let blockContext: string = '';
125 |
126 | if (block.type === 'page') {
127 | blockUid = getPageUidByPageTitle(block.title);
128 | }
129 |
130 | if (block.type === 'block') {
131 | title = getTextByBlockUid(blockUid);
132 | }
133 |
134 | if (block.type === 'qry-block') {
135 | blockContext += block.qryRes;
136 | }
137 |
138 | const tree = getFullTreeByParentUid(blockUid);
139 | blockContext += `[[${title}]]:\n---\n`;
140 | if (block.type !== 'qry-block') {
141 | blockContext += parseRoamTree(tree.children, 0);
142 | }
143 |
144 | contextPackets.push({ role: 'user', content: blockContext });
145 | });
146 |
147 | return contextPackets;
148 | };
149 |
150 | const sendRequest = async (option: any, model: any) => {
151 | const targetBlockUid = lastEditedBlockUid;
152 | const parentBlockUid = getParentUidByBlockUid(lastEditedBlockUid);
153 | const siblings = getBasicTreeByParentUid(parentBlockUid);
154 |
155 | if (option?.id === 'open-chatroam') {
156 | // generate a new page
157 | const seed = Date.now();
158 | const roomId = hashids.encode(seed);
159 | const roomPage = `[[ChatRoam ${roomId}]]`;
160 |
161 | createBlock({
162 | node: { text: roomPage },
163 | parentUid: targetBlockUid,
164 | });
165 | return;
166 | }
167 |
168 | let prompt: any;
169 |
170 | if (option.scope === 'local') {
171 | prompt += valueToCursor.replace(new RegExp('qq$'), '');
172 | prompt += option.presetSuffix || '';
173 | } else if (!option?.fullPage) {
174 | prompt += getTextByBlockUid(parentBlockUid);
175 | prompt += '\n';
176 |
177 | // add sibling blocks BEFORE the current block
178 | siblings.find((b) => {
179 | prompt += getTextByBlockUid(b.uid).replace(new RegExp('qq$'), '');
180 | prompt += '\n';
181 | return b.uid === lastEditedBlockUid;
182 | });
183 | prompt += option.presetSuffix || '';
184 |
185 | if (siblings.length <= 1) {
186 | prompt += valueToCursor.replace(new RegExp('qq$'), '');
187 | }
188 | }
189 |
190 | // build the request payload
191 | let data;
192 | let url;
193 | if (option?.outputType === 'chat') {
194 | url = 'https://api.openai.com/v1/chat/completions';
195 |
196 | const pageTitle = getPageTitleByBlockUid(targetBlockUid);
197 | const currPageUid = getPageUidByPageTitle(pageTitle);
198 | const tree = getFullTreeByParentUid(currPageUid);
199 |
200 | let messages: any = [];
201 |
202 | // TODO: load all context
203 | if (option?.loadPages === true) {
204 | const contextPackets = await loadContext(tree);
205 |
206 | messages = messages.concat(contextPackets);
207 | }
208 |
209 | tree.children.map((block) => {
210 | const childrenText = parseRoamTree(block.children, 0);
211 |
212 | if (block.text.startsWith('[assistant]:')) {
213 | messages.push({
214 | role: 'assistant',
215 | content: block.text.replace('[assistant]:', ''),
216 | });
217 | messages.push({ role: 'assistant', content: childrenText });
218 | } else {
219 | messages.push({
220 | role: 'user',
221 | content: block.text.replace(new RegExp('qq$'), ''),
222 | });
223 | messages.push({ role: 'user', content: childrenText });
224 | }
225 | });
226 |
227 | data = {
228 | model: model.name,
229 | messages: messages,
230 | };
231 | } else if (option?.outputType === 'image') {
232 | url = 'https://api.openai.com/v1/images/generations';
233 | data = {
234 | prompt: prompt,
235 | model: model.name,
236 | n: 1,
237 | };
238 | }
239 |
240 | // replace the "qq" text
241 | updateBlock({
242 | text: getTextByBlockUid(lastEditedBlockUid).replace(
243 | new RegExp(' qq$'),
244 | ` ${CONTENT_TAG}`,
245 | ),
246 | uid: lastEditedBlockUid,
247 | });
248 |
249 | fetch(url, {
250 | method: 'POST',
251 | headers: {
252 | 'Content-Type': 'application/json',
253 | Authorization: `Bearer ${OPEN_AI_API_KEY}`,
254 | },
255 | body: JSON.stringify(data),
256 | })
257 | .then((res) => res.json())
258 | .then((data) => {
259 | if (data.error) {
260 | const pageTitle = getPageTitleByBlockUid(targetBlockUid);
261 | const currPageUid = getPageUidByPageTitle(pageTitle);
262 |
263 | createBlock({
264 | node: { text: `[assistant]: ${data.error.message}` },
265 | parentUid: currPageUid,
266 | order: getOrderByBlockUid(targetBlockUid) + 1,
267 | });
268 |
269 | return;
270 | }
271 |
272 | // insert chat response
273 | if (option?.outputType === 'chat') {
274 | const text = data.choices[0].message.content.trim();
275 | const lines = text.split('\n');
276 |
277 | const pageTitle = getPageTitleByBlockUid(targetBlockUid);
278 | const currPageUid = getPageUidByPageTitle(pageTitle);
279 |
280 | createBlock({
281 | node: { text: `[assistant]: ${text}` },
282 | parentUid: currPageUid,
283 | order: getOrderByBlockUid(targetBlockUid) + 1,
284 | });
285 |
286 | return;
287 | }
288 |
289 | // insert image
290 | if (option?.outputType === 'image') {
291 | const output = ``;
292 | createBlock({
293 | node: { text: output },
294 | parentUid: targetBlockUid,
295 | });
296 | return;
297 | }
298 |
299 | // insert text completion
300 | const text = data?.text ? data.text : data.choices[0].text.trim(); // depending on the endpoint
301 | const lines = text.split('\n');
302 | lines.reverse().map((line: any) => {
303 | line = line.trim().replace(/^- /, '');
304 |
305 | if (line.length === 0) return; // skip blank line
306 |
307 | if (option.operation === 'updateParent') {
308 | updateBlock({
309 | text: line,
310 | uid: parentBlockUid,
311 | });
312 | }
313 | // bullet point
314 | else if (line.startsWith('- ')) {
315 | // insert into the last child
316 | createBlock({
317 | node: { text: line },
318 | parentUid: targetBlockUid,
319 | });
320 | } else {
321 | createBlock({
322 | node: { text: line },
323 | parentUid: targetBlockUid,
324 | });
325 | }
326 | });
327 | })
328 | .catch((error) => {
329 | console.error('Error:', error);
330 | });
331 | };
332 |
333 | export default runExtension({
334 | extensionId,
335 | run: ({ extensionAPI }) => {
336 | const style = addStyle(`.roamjs-smartblocks-popover-target {
337 | display:inline-block;
338 | height:14px;
339 | width:17px;
340 | margin-right:7px;
341 | }
342 | .bp3-portal {
343 | z-index: 1000;
344 | }
345 | .roamjs-smartblocks-store-item {
346 | display: flex;
347 | flex-direction: column;
348 | width: 100%;
349 | box-sizing: border-box;
350 | padding: 4px 12px 0;
351 | cursor: pointer;
352 | font-size: 12px;
353 | border: 1px solid #10161a26;
354 | background: white;
355 | border-radius: 24px;
356 | }
357 | .roamjs-smartblocks-store-item:hover {
358 | box-shadow: 0px 3px 6px #00000040;
359 | transform: translate(0,-3px);
360 | }
361 | .roamjs-smartblocks-store-label .bp3-popover-wrapper {
362 | overflow: hidden;
363 | white-space: nowrap;
364 | text-overflow: ellipsis;
365 | }
366 | .roamjs-smartblocks-store-tabs .bp3-tab-list {
367 | justify-content: space-around;
368 | }
369 | .roamjs-smartblocks-store-tabs {
370 | height: 48px;
371 | }
372 | .roamjs-smartblock-menu {
373 | width: 300px;
374 | }`);
375 |
376 | const updateAPIKey = (value: string) => {
377 | if (!value) return;
378 | OPEN_AI_API_KEY = value.trim();
379 | };
380 |
381 | const updateMaxTokens = (value: string) => {
382 | if (!value) return;
383 | MAX_TOKENS = Number(value.trim());
384 | };
385 |
386 | const updateContentTag = (value: string) => {
387 | if (!value) return;
388 | CONTENT_TAG = value.trim();
389 | };
390 |
391 | const updateCustomModels = (value: string) => {
392 | if (!value) return;
393 |
394 | try {
395 | CUSTOM_MODELS = JSON.parse(value.trim());
396 | } catch (err) {}
397 | };
398 |
399 | updateAPIKey(extensionAPI.settings.get('api_key') as string);
400 | updateMaxTokens(extensionAPI.settings.get('max_tokens') as string);
401 | updateContentTag(extensionAPI.settings.get('content_tag') as string);
402 | updateCustomModels(extensionAPI.settings.get('custom_models') as any);
403 |
404 | extensionAPI.settings.panel.create({
405 | tabTitle: 'Roam AI',
406 | settings: [
407 | {
408 | action: {
409 | type: 'input',
410 | onChange: (e) => updateAPIKey(e.target.value),
411 | placeholder: 'sk-u80asgdf780ga3uipgrh1089y',
412 | },
413 | id: 'api_key',
414 | name: 'API key',
415 | description: 'Your Open AI API key',
416 | },
417 | {
418 | action: {
419 | type: 'input',
420 | onChange: (e) => updateMaxTokens(e.target.value),
421 | placeholder: '256',
422 | },
423 | id: 'max_tokens',
424 | name: 'Maximum length',
425 | description:
426 | 'The maximnum number of words to generate. Default is 256.',
427 | },
428 | {
429 | action: {
430 | type: 'input',
431 | onChange: (e) => updateContentTag(e.target.value),
432 | placeholder: '[[qq]]',
433 | },
434 | id: 'content_tag',
435 | name: 'Content tag',
436 | description: "A string to replace 'qq' with. Default is blank.",
437 | },
438 | {
439 | action: {
440 | type: 'input',
441 | onChange: (e) => updateCustomModels(e.target.value),
442 | placeholder:
443 | '[{ "name": "model A", "endpoint": "closedai.com/v1/completions", "displayName": "model A" }]',
444 | },
445 | id: 'custom_models',
446 | name: 'Custom models',
447 | description: 'Bring your own endpoints.',
448 | },
449 | ],
450 | });
451 |
452 | // detect keys
453 | const appRoot = document.querySelector('.roam-app');
454 | const appRootKeydownListener = async (e: KeyboardEvent) => {
455 | // resetting if the menu is stuck
456 | if (e.key === 'Escape') {
457 | menuLoaded = false;
458 | }
459 | };
460 | appRoot?.addEventListener('keydown', appRootKeydownListener);
461 |
462 | // read document input
463 | let menuLoaded = false;
464 | let trigger = 'qq';
465 | let triggerRegex = new RegExp(`${trigger}(.*)$`);
466 |
467 | const documentInputListener = async (e: InputEvent) => {
468 | const target = e.target as HTMLElement;
469 | if (
470 | !menuLoaded &&
471 | target.tagName === 'TEXTAREA' &&
472 | target.classList.contains('rm-block-input')
473 | ) {
474 | const textarea = target as HTMLTextAreaElement;
475 | const location = window.roamAlphaAPI.ui.getFocusedBlock();
476 | valueToCursor = textarea.value.substring(0, textarea.selectionStart);
477 |
478 | lastEditedBlockUid =
479 | window.roamAlphaAPI.ui.getFocusedBlock()?.['block-uid'];
480 |
481 | const match = triggerRegex.exec(valueToCursor);
482 | if (match) {
483 | menuLoaded = true;
484 |
485 | renderMenu({
486 | textarea,
487 | triggerRegex,
488 | triggerStart: match.index,
489 | sendRequest,
490 | extensionAPI,
491 | customModels: CUSTOM_MODELS,
492 | onClose: () => {
493 | menuLoaded = false;
494 | },
495 | });
496 | }
497 | }
498 | };
499 | document.addEventListener('input', documentInputListener);
500 |
501 | return {
502 | elements: [style],
503 | domListeners: [
504 | { type: 'input', listener: documentInputListener, el: document },
505 | { type: 'keydown', el: appRoot, listener: appRootKeydownListener },
506 | ],
507 | };
508 | },
509 | unload: () => {},
510 | });
511 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/**/*.{js,jsx,ts,tsx}",
5 | ],
6 | theme: {
7 | extend: {},
8 | },
9 | plugins: [],
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./node_modules/roamjs-scripts/dist/default.tsconfig",
3 | "include": [
4 | "src"
5 | ],
6 | "exclude": [
7 | "node_modules"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------