Add canvas interface (#461)

This commit is contained in:
Jeremy 2024-10-30 16:01:24 +05:30 committed by GitHub
parent 1a74a5ca9a
commit b3cb0ea755
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 7454 additions and 4691 deletions

View file

@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS "Suggestion" (
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"documentId" uuid NOT NULL,
"documentCreatedAt" timestamp NOT NULL,
"originalText" text NOT NULL,
"suggestedText" text NOT NULL,
"description" text,
"isResolved" boolean DEFAULT false NOT NULL,
"userId" uuid NOT NULL,
"createdAt" timestamp NOT NULL,
CONSTRAINT "Suggestion_id_pk" PRIMARY KEY("id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "Document" (
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
"createdAt" timestamp NOT NULL,
"title" text NOT NULL,
"content" text,
"userId" uuid NOT NULL,
CONSTRAINT "Document_id_createdAt_pk" PRIMARY KEY("id","createdAt")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk" FOREIGN KEY ("documentId","documentCreatedAt") REFERENCES "public"."Document"("id","createdAt") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,259 @@
{
"id": "f3d3437c-4735-4c91-80af-1014048a904e",
"prevId": "715ec9ec-6715-4d0f-9f6c-9b5c7f09827c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Suggestion": {
"name": "Suggestion",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"documentId": {
"name": "documentId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"documentCreatedAt": {
"name": "documentCreatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"originalText": {
"name": "originalText",
"type": "text",
"primaryKey": false,
"notNull": true
},
"suggestedText": {
"name": "suggestedText",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isResolved": {
"name": "isResolved",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Suggestion_userId_User_id_fk": {
"name": "Suggestion_userId_User_id_fk",
"tableFrom": "Suggestion",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": {
"name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk",
"tableFrom": "Suggestion",
"tableTo": "Document",
"columnsFrom": [
"documentId",
"documentCreatedAt"
],
"columnsTo": [
"id",
"createdAt"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Suggestion_id_pk": {
"name": "Suggestion_id_pk",
"columns": [
"id"
]
}
},
"uniqueConstraints": {}
},
"public.Chat": {
"name": "Chat",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"messages": {
"name": "messages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Chat_userId_User_id_fk": {
"name": "Chat_userId_User_id_fk",
"tableFrom": "Chat",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.Document": {
"name": "Document",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"userId": {
"name": "userId",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Document_userId_User_id_fk": {
"name": "Document_userId_User_id_fk",
"tableFrom": "Document",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"Document_id_createdAt_pk": {
"name": "Document_id_createdAt_pk",
"columns": [
"id",
"createdAt"
]
}
},
"uniqueConstraints": {}
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1728598022383,
"tag": "0000_keen_devos",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1730207363999,
"tag": "0001_sparkling_blue_marvel",
"breakpoints": true
}
]
}

7
lib/editor/DiffType.js Normal file
View file

@ -0,0 +1,7 @@
// Taken from https://github.com/hamflx/prosemirror-diff/blob/master/src/DiffType.js
export const DiffType = {
Unchanged: 0,
Deleted: -1,
Inserted: 1,
};

511
lib/editor/diff.js Normal file
View file

@ -0,0 +1,511 @@
// Taken from https://github.com/hamflx/prosemirror-diff/blob/master/src/diff.js
import { diff_match_patch } from 'diff-match-patch';
import { Fragment, Node } from 'prosemirror-model';
import { DiffType } from './DiffType.js';
export const patchDocumentNode = (schema, oldNode, newNode) => {
assertNodeTypeEqual(oldNode, newNode);
const finalLeftChildren = [];
const finalRightChildren = [];
const oldChildren = normalizeNodeContent(oldNode);
const newChildren = normalizeNodeContent(newNode);
const oldChildLen = oldChildren.length;
const newChildLen = newChildren.length;
const minChildLen = Math.min(oldChildLen, newChildLen);
let left = 0;
let right = 0;
// console.log("==> searching same left");
for (; left < minChildLen; left++) {
const oldChild = oldChildren[left];
const newChild = newChildren[left];
if (!isNodeEqual(oldChild, newChild)) {
break;
}
finalLeftChildren.push(...ensureArray(oldChild));
}
// console.log("==> searching same right");
for (; right + left + 1 < minChildLen; right++) {
const oldChild = oldChildren[oldChildLen - right - 1];
const newChild = newChildren[newChildLen - right - 1];
if (!isNodeEqual(oldChild, newChild)) {
break;
}
finalRightChildren.unshift(...ensureArray(oldChild));
}
// console.log(
// `==> eq left:${left}, right:${right}`,
// [...finalLeftChildren],
// [...finalRightChildren],
// );
const diffOldChildren = oldChildren.slice(left, oldChildLen - right);
const diffNewChildren = newChildren.slice(left, newChildLen - right);
// console.log(
// "==> diff children",
// diffOldChildren.length,
// diffNewChildren.length,
// );
if (diffOldChildren.length && diffNewChildren.length) {
const matchedNodes = matchNodes(
schema,
diffOldChildren,
diffNewChildren
).sort((a, b) => b.count - a.count);
const bestMatch = matchedNodes[0];
if (bestMatch) {
// console.log("==> bestMatch", bestMatch);
const { oldStartIndex, newStartIndex, oldEndIndex, newEndIndex } =
bestMatch;
const oldBeforeMatchChildren = diffOldChildren.slice(0, oldStartIndex);
const newBeforeMatchChildren = diffNewChildren.slice(0, newStartIndex);
// console.log(
// "==> before match",
// oldBeforeMatchChildren.length,
// newBeforeMatchChildren.length,
// oldBeforeMatchChildren,
// newBeforeMatchChildren,
// );
finalLeftChildren.push(
...patchRemainNodes(
schema,
oldBeforeMatchChildren,
newBeforeMatchChildren
)
);
finalLeftChildren.push(
...diffOldChildren.slice(oldStartIndex, oldEndIndex)
);
// console.log("==> match", oldEndIndex - oldStartIndex);
const oldAfterMatchChildren = diffOldChildren.slice(oldEndIndex);
const newAfterMatchChildren = diffNewChildren.slice(newEndIndex);
// console.log(
// "==> after match",
// oldAfterMatchChildren.length,
// newAfterMatchChildren.length,
// );
finalRightChildren.unshift(
...patchRemainNodes(
schema,
oldAfterMatchChildren,
newAfterMatchChildren
)
);
} else {
// console.log("==> no best match found");
finalLeftChildren.push(
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
);
}
// console.log("==> matchedNodes", matchedNodes);
} else {
finalLeftChildren.push(
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
);
}
return createNewNode(oldNode, [...finalLeftChildren, ...finalRightChildren]);
};
const matchNodes = (schema, oldChildren, newChildren) => {
// console.log("==> matchNodes", oldChildren, newChildren);
const matches = [];
for (
let oldStartIndex = 0;
oldStartIndex < oldChildren.length;
oldStartIndex++
) {
const oldStartNode = oldChildren[oldStartIndex];
const newStartIndex = findMatchNode(newChildren, oldStartNode);
if (newStartIndex !== -1) {
let oldEndIndex = oldStartIndex + 1;
let newEndIndex = newStartIndex + 1;
for (
;
oldEndIndex < oldChildren.length && newEndIndex < newChildren.length;
oldEndIndex++, newEndIndex++
) {
const oldEndNode = oldChildren[oldEndIndex];
if (!isNodeEqual(newChildren[newEndIndex], oldEndNode)) {
break;
}
}
// console.log(
// "==> match",
// oldStartIndex,
// oldEndIndex,
// newStartIndex,
// newEndIndex,
// );
matches.push({
oldStartIndex,
newStartIndex,
oldEndIndex,
newEndIndex,
count: newEndIndex - newStartIndex,
});
}
}
return matches;
};
const findMatchNode = (children, node, startIndex = 0) => {
for (let i = startIndex; i < children.length; i++) {
if (isNodeEqual(children[i], node)) {
return i;
}
}
return -1;
};
const patchRemainNodes = (schema, oldChildren, newChildren) => {
const finalLeftChildren = [];
const finalRightChildren = [];
const oldChildLen = oldChildren.length;
const newChildLen = newChildren.length;
let left = 0;
let right = 0;
while (oldChildLen - left - right > 0 && newChildLen - left - right > 0) {
const leftOldNode = oldChildren[left];
const leftNewNode = newChildren[left];
const rightOldNode = oldChildren[oldChildLen - right - 1];
const rightNewNode = newChildren[newChildLen - right - 1];
let updateLeft =
!isTextNode(leftOldNode) && matchNodeType(leftOldNode, leftNewNode);
let updateRight =
!isTextNode(rightOldNode) && matchNodeType(rightOldNode, rightNewNode);
if (Array.isArray(leftOldNode) && Array.isArray(leftNewNode)) {
finalLeftChildren.push(
...patchTextNodes(schema, leftOldNode, leftNewNode)
);
left += 1;
continue;
}
if (updateLeft && updateRight) {
const equalityLeft = computeChildEqualityFactor(leftOldNode, leftNewNode);
const equalityRight = computeChildEqualityFactor(
rightOldNode,
rightNewNode
);
if (equalityLeft < equalityRight) {
updateLeft = false;
} else {
updateRight = false;
}
}
if (updateLeft) {
finalLeftChildren.push(
patchDocumentNode(schema, leftOldNode, leftNewNode)
);
left += 1;
} else if (updateRight) {
finalRightChildren.unshift(
patchDocumentNode(schema, rightOldNode, rightNewNode)
);
right += 1;
} else {
// todo
finalLeftChildren.push(
createDiffNode(schema, leftOldNode, DiffType.Deleted)
);
finalLeftChildren.push(
createDiffNode(schema, leftNewNode, DiffType.Inserted)
);
left += 1;
// delete and insert
}
}
const deleteNodeLen = oldChildLen - left - right;
const insertNodeLen = newChildLen - left - right;
if (deleteNodeLen) {
finalLeftChildren.push(
...oldChildren
.slice(left, left + deleteNodeLen)
.flat()
.map((node) => createDiffNode(schema, node, DiffType.Deleted))
);
}
if (insertNodeLen) {
finalRightChildren.unshift(
...newChildren
.slice(left, left + insertNodeLen)
.flat()
.map((node) => createDiffNode(schema, node, DiffType.Inserted))
);
}
return [...finalLeftChildren, ...finalRightChildren];
};
export const patchTextNodes = (schema, oldNode, newNode) => {
const dmp = new diff_match_patch();
const oldText = oldNode.map((n) => getNodeText(n)).join('');
const newText = newNode.map((n) => getNodeText(n)).join('');
const diff = dmp.diff_main(oldText, newText);
let oldLen = 0;
let newLen = 0;
const res = diff
.map((d) => {
const [type, content] = [d[0], d[1]];
const node = createTextNode(
schema,
content,
type !== DiffType.Unchanged ? createDiffMark(schema, type) : []
);
const oldFrom = oldLen;
const oldTo = oldFrom + (type === DiffType.Inserted ? 0 : content.length);
const newFrom = newLen;
const newTo = newFrom + (type === DiffType.Deleted ? 0 : content.length);
oldLen = oldTo;
newLen = newTo;
return { node, type, oldFrom, oldTo, newFrom, newTo };
})
.map(({ node, type, oldFrom, oldTo, newFrom, newTo }) => {
if (type === DiffType.Deleted) {
const textItems = findTextNodes(oldNode, oldFrom, oldTo).filter(
(n) => Object.keys(n.node.attrs ?? {}).length || n.node.marks?.length
);
return applyTextNodeAttrsMarks(schema, node, oldFrom, textItems);
} else {
const textItems = findTextNodes(newNode, newFrom, newTo).filter(
(n) => Object.keys(n.node.attrs ?? {}).length || n.node.marks?.length
);
return applyTextNodeAttrsMarks(schema, node, newFrom, textItems);
}
});
return res.flat(Infinity);
};
const findTextNodes = (textNodes, from, to) => {
const result = [];
let start = 0;
for (let i = 0; i < textNodes.length && start < to; i++) {
const node = textNodes[i];
const text = getNodeText(node);
const end = start + text.length;
const intersect =
(start >= from && start < to) ||
(end > from && end <= to) ||
(start <= from && end >= to);
if (intersect) {
result.push({ node, from: start, to: end });
}
start += text.length;
}
return result;
};
const applyTextNodeAttrsMarks = (schema, node, base, textItems) => {
if (!textItems.length) {
return node;
}
const baseMarks = node.marks ?? [];
const firstItem = textItems[0];
const nodeText = getNodeText(node);
const nodeEnd = base + nodeText.length;
const result = [];
if (firstItem.from - base > 0) {
result.push(
createTextNode(
schema,
nodeText.slice(0, firstItem.from - base),
baseMarks
)
);
}
for (let i = 0; i < textItems.length; i++) {
const { from, node: textNode, to } = textItems[i];
result.push(
createTextNode(
schema,
nodeText.slice(Math.max(from, base) - base, to - base),
[...baseMarks, ...(textNode.marks ?? [])]
)
);
const nextFrom = i + 1 < textItems.length ? textItems[i + 1].from : nodeEnd;
if (nextFrom > to) {
result.push(
createTextNode(
schema,
nodeText.slice(to - base, nextFrom - base),
baseMarks
)
);
}
}
return result;
};
export const computeChildEqualityFactor = (node1, node2) => {
return 0;
};
export const assertNodeTypeEqual = (node1, node2) => {
if (getNodeProperty(node1, 'type') !== getNodeProperty(node2, 'type')) {
throw new Error(`node type not equal: ${node1.type} !== ${node2.type}`);
}
};
export const ensureArray = (value) => {
return Array.isArray(value) ? value : [value];
};
export const isNodeEqual = (node1, node2) => {
const isNode1Array = Array.isArray(node1);
const isNode2Array = Array.isArray(node2);
if (isNode1Array !== isNode2Array) {
return false;
}
if (isNode1Array) {
return (
node1.length === node2.length &&
node1.every((node, index) => isNodeEqual(node, node2[index]))
);
}
const type1 = getNodeProperty(node1, 'type');
const type2 = getNodeProperty(node2, 'type');
if (type1 !== type2) {
return false;
}
if (isTextNode(node1)) {
const text1 = getNodeProperty(node1, 'text');
const text2 = getNodeProperty(node2, 'text');
if (text1 !== text2) {
return false;
}
}
const attrs1 = getNodeAttributes(node1);
const attrs2 = getNodeAttributes(node2);
const attrs = [...new Set([...Object.keys(attrs1), ...Object.keys(attrs2)])];
for (const attr of attrs) {
if (attrs1[attr] !== attrs2[attr]) {
return false;
}
}
const marks1 = getNodeMarks(node1);
const marks2 = getNodeMarks(node2);
if (marks1.length !== marks2.length) {
return false;
}
for (let i = 0; i < marks1.length; i++) {
if (!isNodeEqual(marks1[i], marks2[i])) {
return false;
}
}
const children1 = getNodeChildren(node1);
const children2 = getNodeChildren(node2);
if (children1.length !== children2.length) {
return false;
}
for (let i = 0; i < children1.length; i++) {
if (!isNodeEqual(children1[i], children2[i])) {
return false;
}
}
return true;
};
export const normalizeNodeContent = (node) => {
const content = getNodeChildren(node) ?? [];
const res = [];
for (let i = 0; i < content.length; i++) {
const child = content[i];
if (isTextNode(child)) {
const textNodes = [];
for (
let textNode = content[i];
i < content.length && isTextNode(textNode);
textNode = content[++i]
) {
textNodes.push(textNode);
}
i--;
res.push(textNodes);
} else {
res.push(child);
}
}
return res;
};
export const getNodeProperty = (node, property) => {
if (property === 'type') {
return node.type?.name;
}
return node[property];
};
export const getNodeAttribute = (node, attribute) =>
node.attrs ? node.attrs[attribute] : undefined;
export const getNodeAttributes = (node) =>
node.attrs ? node.attrs : undefined;
export const getNodeMarks = (node) => node.marks ?? [];
export const getNodeChildren = (node) => node.content?.content ?? [];
export const getNodeText = (node) => node.text;
export const isTextNode = (node) => node.type?.name === 'text';
export const matchNodeType = (node1, node2) =>
node1.type?.name === node2.type?.name ||
(Array.isArray(node1) && Array.isArray(node2));
export const createNewNode = (oldNode, children) => {
if (!oldNode.type) {
throw new Error('oldNode.type is undefined');
}
return new Node(
oldNode.type,
oldNode.attrs,
Fragment.fromArray(children),
oldNode.marks
);
};
export const createDiffNode = (schema, node, type) => {
return mapDocumentNode(node, (node) => {
if (isTextNode(node)) {
return createTextNode(schema, getNodeText(node), [
...(node.marks || []),
createDiffMark(schema, type),
]);
}
return node;
});
};
function mapDocumentNode(node, mapper) {
const copy = node.copy(
Fragment.from(
node.content.content
.map((node) => mapDocumentNode(node, mapper))
.filter((n) => n)
)
);
return mapper(copy) || copy;
}
export const createDiffMark = (schema, type) => {
if (type === DiffType.Inserted) {
return schema.mark('diffMark', { type });
}
if (type === DiffType.Deleted) {
return schema.mark('diffMark', { type });
}
throw new Error('type is not valid');
};
export const createTextNode = (schema, content, marks = []) => {
return schema.text(content, marks);
};
export const diffEditor = (schema, oldDoc, newDoc) => {
const oldNode = Node.fromJSON(schema, oldDoc);
const newNode = Node.fromJSON(schema, newDoc);
return patchDocumentNode(schema, oldNode, newNode);
};

2
lib/editor/index.js Normal file
View file

@ -0,0 +1,2 @@
export * from "./diff.js";
export * from "./DiffType";

View file

@ -0,0 +1,11 @@
import { createRoot } from "react-dom/client";
export class ReactRenderer {
static render(component: React.ReactElement, dom: HTMLElement) {
const root = createRoot(dom);
root.render(component);
return {
destroy: () => root.unmount(),
};
}
}

118
lib/editor/suggestions.tsx Normal file
View file

@ -0,0 +1,118 @@
import { Node } from 'prosemirror-model';
import { PluginKey, Plugin } from 'prosemirror-state';
import { DecorationSet, EditorView } from 'prosemirror-view';
import { createRoot } from 'react-dom/client';
import { Suggestion as PreviewSuggestion } from '@/components/custom/suggestion';
import { Suggestion } from '@/db/schema';
export interface UISuggestion extends Suggestion {
selectionStart: number;
selectionEnd: number;
}
interface Position {
start: number;
end: number;
}
function findPositionsInDoc(doc: Node, searchText: string): Position | null {
let positions: { start: number; end: number } | null = null;
doc.nodesBetween(0, doc.content.size, (node, pos) => {
if (node.isText && node.text) {
const index = node.text.indexOf(searchText);
if (index !== -1) {
positions = {
start: pos + index,
end: pos + index + searchText.length,
};
return false;
}
}
return true;
});
return positions;
}
export function projectWithHighlights(
doc: Node,
suggestions: Array<Suggestion>
): Array<UISuggestion> {
return suggestions.map((suggestion) => {
const positions = findPositionsInDoc(doc, suggestion.originalText);
if (!positions) {
return {
...suggestion,
selectionStart: 0,
selectionEnd: 0,
};
}
return {
...suggestion,
selectionStart: positions.start,
selectionEnd: positions.end,
};
});
}
export function createSuggestionWidget(
suggestion: UISuggestion,
view: EditorView
): { dom: HTMLElement; destroy: () => void } {
const dom = document.createElement('span');
const root = createRoot(dom);
const onApply = () => {
const { state, dispatch } = view;
const tr = state.tr.replaceWith(
suggestion.selectionStart,
suggestion.selectionEnd,
state.schema.text(suggestion.suggestedText)
);
dispatch(tr);
};
root.render(<PreviewSuggestion suggestion={suggestion} onApply={onApply} />);
return {
dom,
destroy: () => {
// Wrapping unmount in setTimeout to avoid synchronous unmounting during render
setTimeout(() => {
root.unmount();
}, 0);
},
};
}
export const suggestionsPluginKey = new PluginKey('suggestions');
export const suggestionsPlugin = new Plugin({
key: suggestionsPluginKey,
state: {
init() {
return { decorations: DecorationSet.empty, selected: null };
},
apply(tr, state) {
const newDecorations = tr.getMeta(suggestionsPluginKey);
if (newDecorations) return newDecorations;
return {
decorations: state.decorations.map(tr.mapping, tr.doc),
selected: state.selected,
};
},
},
props: {
decorations(state) {
return this.getState(state)?.decorations ?? DecorationSet.empty;
},
},
});

View file

@ -1,17 +0,0 @@
// Define your models here.
export const models = [
{
label: 'GPT 4o mini',
name: 'gpt-4o-mini',
description: 'Small model for fast, lightweight tasks',
},
{
label: 'GPT 4o',
name: 'gpt-4o',
description: 'For complex, multi-step tasks',
},
] as const;
export const DEFAULT_MODEL_NAME: Model['name'] = 'gpt-4o-mini';
export type Model = (typeof models)[number];

View file

@ -1,14 +1,15 @@
import {
CoreAssistantMessage,
CoreMessage,
CoreToolMessage,
generateId,
Message,
ToolInvocation,
} from "ai";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
} from 'ai';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { Chat } from "@/db/schema";
import { Chat } from '@/db/schema';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@ -24,7 +25,7 @@ export const fetcher = async (url: string) => {
if (!res.ok) {
const error = new Error(
"An error occurred while fetching the data.",
'An error occurred while fetching the data.'
) as ApplicationError;
error.info = await res.json();
@ -37,16 +38,16 @@ export const fetcher = async (url: string) => {
};
export function getLocalStorage(key: string) {
if (typeof window !== "undefined") {
return JSON.parse(localStorage.getItem(key) || "[]");
if (typeof window !== 'undefined') {
return JSON.parse(localStorage.getItem(key) || '[]');
}
return [];
}
export function generateUUID(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
@ -64,13 +65,13 @@ function addToolMessageToChat({
...message,
toolInvocations: message.toolInvocations.map((toolInvocation) => {
const toolResult = toolMessage.content.find(
(tool) => tool.toolCallId === toolInvocation.toolCallId,
(tool) => tool.toolCallId === toolInvocation.toolCallId
);
if (toolResult) {
return {
...toolInvocation,
state: "result",
state: 'result',
result: toolResult.result,
};
}
@ -85,28 +86,28 @@ function addToolMessageToChat({
}
export function convertToUIMessages(
messages: Array<CoreMessage>,
messages: Array<CoreMessage>
): Array<Message> {
return messages.reduce((chatMessages: Array<Message>, message) => {
if (message.role === "tool") {
if (message.role === 'tool') {
return addToolMessageToChat({
toolMessage: message as CoreToolMessage,
messages: chatMessages,
});
}
let textContent = "";
let textContent = '';
let toolInvocations: Array<ToolInvocation> = [];
if (typeof message.content === "string") {
if (typeof message.content === 'string') {
textContent = message.content;
} else if (Array.isArray(message.content)) {
for (const content of message.content) {
if (content.type === "text") {
if (content.type === 'text') {
textContent += content.text;
} else if (content.type === "tool-call") {
} else if (content.type === 'tool-call') {
toolInvocations.push({
state: "call",
state: 'call',
toolCallId: content.toolCallId,
toolName: content.toolName,
args: content.args,
@ -131,8 +132,92 @@ export function getTitleFromChat(chat: Chat) {
const firstMessage = messages[0];
if (!firstMessage) {
return "Untitled";
return 'Untitled';
}
return firstMessage.content;
}
const emptyAssistantMessage = [
{
role: 'assistant',
content: [
{
type: 'text',
text: '',
},
],
},
];
export function sanitizeResponseMessages(
messages: Array<CoreToolMessage | CoreAssistantMessage>
): Array<CoreToolMessage | CoreAssistantMessage> {
let toolResultIds: Array<string> = [];
for (const message of messages) {
if (message.role === 'tool') {
for (const content of message.content) {
if (content.type === 'tool-result') {
toolResultIds.push(content.toolCallId);
}
}
}
}
const messagesBySanitizedContent = messages.map((message) => {
if (message.role !== 'assistant') return message;
if (typeof message.content === 'string') return message;
const sanitizedContent = message.content.filter((content) =>
content.type === 'tool-call'
? toolResultIds.includes(content.toolCallId)
: content.type === 'text'
? content.text.length > 0
: true
);
return {
...message,
content: sanitizedContent,
};
});
return messagesBySanitizedContent.filter(
(message) => message.content.length > 0
);
}
export function sanitizeUIMessages(messages: Array<Message>): Array<Message> {
const messagesBySanitizedToolInvocations = messages.map((message) => {
if (message.role !== 'assistant') return message;
if (!message.toolInvocations) return message;
let toolResultIds: Array<string> = [];
for (const toolInvocation of message.toolInvocations) {
if (toolInvocation.state === 'result') {
toolResultIds.push(toolInvocation.toolCallId);
}
}
const sanitizedToolInvocations = message.toolInvocations.filter(
(toolInvocation) =>
toolInvocation.state === 'result' ||
toolResultIds.includes(toolInvocation.toolCallId)
);
return {
...message,
toolInvocations: sanitizedToolInvocations,
};
});
return messagesBySanitizedToolInvocations.filter(
(message) =>
message.content.length > 0 ||
(message.toolInvocations && message.toolInvocations.length > 0)
);
}