├── .eslintrc.json
├── src
├── app
│ ├── favicon.ico
│ ├── page.tsx
│ ├── layout.tsx
│ ├── globals.css
│ └── api
│ │ ├── completion
│ │ └── route.ts
│ │ ├── chat
│ │ └── route.ts
│ │ └── generate
│ │ └── route.ts
├── pages
│ ├── about.tsx
│ ├── _app.tsx
│ ├── chat.tsx
│ └── check.tsx
└── components
│ ├── pages-layout.tsx
│ └── editor
│ ├── data.ts
│ ├── utils.ts
│ └── editor.tsx
├── postcss.config.js
├── next.config.js
├── .env.example
├── .gitignore
├── tailwind.config.js
├── public
├── vercel.svg
└── next.svg
├── tsconfig.json
├── package.json
├── LICENSE
└── README.md
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LlamaGenAI/note-ai/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/pages/about.tsx:
--------------------------------------------------------------------------------
1 | export default function About() {
2 | return
About
;
3 | }
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = { reactStrictMode: true };
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from "next/dynamic";
2 | const Editor = dynamic(() => import("@/components/editor/editor"), {
3 | ssr: false,
4 | });
5 |
6 | export default function Home() {
7 | return (
8 |
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import Layout from "../components/pages-layout";
2 | import { AppProps } from "next/app";
3 | export default function MyApp({ Component, pageProps }: AppProps) {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/pages-layout.tsx:
--------------------------------------------------------------------------------
1 | import "./../app/globals.css";
2 | import { ReactNode } from "react";
3 | interface LayoutProps {
4 | children: ReactNode;
5 | }
6 | export default function Layout({ children }: LayoutProps) {
7 | return (
8 | <>
9 | {children}
10 | >
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Get your OpenAI API key here: https://platform.openai.com/account/api-keys
2 | OPENAI_API_KEY=
3 |
4 | # OPTIONAL: Vercel Blob (for uploading images)
5 | # Get your Vercel Blob credentials here: https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart
6 | BLOB_READ_WRITE_TOKEN=
7 |
8 | # OPTIONAL: Vercel KV (for ratelimiting)
9 | # Get your Vercel KV credentials here: https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart
10 | KV_REST_API_URL=
11 | KV_REST_API_TOKEN=
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 | import type { Metadata } from 'next'
3 | import { Inter } from 'next/font/google'
4 |
5 | const inter = Inter({ subsets: ['latin'] })
6 |
7 | export const metadata: Metadata = {
8 | title: 'Create Next App',
9 | description: 'Generated by create next app',
10 | }
11 |
12 | export default function RootLayout({
13 | children,
14 | }: {
15 | children: React.ReactNode
16 | }) {
17 | return (
18 |
19 | {children}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
12 | "gradient-conic":
13 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | };
19 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
7 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
8 | font-size: 16px;
9 | line-height: 1.5;
10 | color: #333;
11 | }
12 |
13 | body {
14 | margin: 0;
15 | padding: 0;
16 | }
17 |
18 | /* You can add global styles to this file, and also import other style files */
19 | .affine-default-page-block-container {
20 | margin: 0 !important;
21 | padding-bottom: 15px !important;
22 | }
23 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/editor/data.ts:
--------------------------------------------------------------------------------
1 | export const presetMarkdown = `This playground is designed to:
2 |
3 | * 📝 Test basic editing experience.
4 | * ⚙️ Serve as E2E test entry.
5 | * 🔗 Demonstrate how BlockSuite reconciles real-time collaboration with [local-first](https://martin.kleppmann.com/papers/local-first.pdf) data ownership.
6 |
7 | ## Controlling Playground Data Source
8 | You might initially enter this page with the \`?init\` URL param. This is the default (opt-in) setup that automatically loads this built-in article. Meanwhile, you'll connect to a random single-user room via a WebRTC provider by default. This is the "single-user mode" for local testing.;
9 |
10 | > Note that the second and subsequent users should not open the page with the \`?init\` param in this case. Also, due to the P2P nature of WebRTC, as long as there is at least one user connected to the room, the content inside the room will **always** exist.
11 | `;
12 |
--------------------------------------------------------------------------------
/src/pages/chat.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useChat } from "ai/react";
4 |
5 | export default function Chat() {
6 | const { messages, input, handleInputChange, handleSubmit } = useChat();
7 |
8 | return (
9 |
10 | {messages.map((m) => (
11 |
12 | {m.role === "user" ? "User: " : "AI: "}
13 | {m.content}
14 |
15 | ))}
16 |
17 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "note-ai",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@types/node": "20.4.1",
13 | "@types/react": "18.2.14",
14 | "@types/react-dom": "18.2.6",
15 | "autoprefixer": "10.4.14",
16 | "eslint": "8.44.0",
17 | "eslint-config-next": "13.4.9",
18 | "next": "13.4.9",
19 | "postcss": "8.4.25",
20 | "react": "18.2.0",
21 | "react-dom": "18.2.0",
22 | "tailwindcss": "3.3.2",
23 | "typescript": "5.1.6",
24 | "@blocksuite/blocks": "0.6.0",
25 | "@blocksuite/editor": "0.6.0",
26 | "@blocksuite/lit": "0.6.0",
27 | "@blocksuite/store": "0.6.0",
28 | "ahooks": "^3.7.7",
29 | "@upstash/ratelimit": "^0.4.3",
30 | "@vercel/kv": "^0.2.2",
31 | "ai": "^2.1.16",
32 | "openai-edge": "^1.2.0"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/api/completion/route.ts:
--------------------------------------------------------------------------------
1 | import { Configuration, OpenAIApi } from 'openai-edge'
2 | import { OpenAIStream, StreamingTextResponse } from 'ai'
3 |
4 | // Create an OpenAI API client (that's edge friendly!)
5 | const config = new Configuration({
6 | apiKey: process.env.OPENAI_API_KEY
7 | })
8 | const openai = new OpenAIApi(config)
9 |
10 | // IMPORTANT! Set the runtime to edge
11 | export const runtime = 'edge'
12 |
13 | export async function POST(req: Request) {
14 | // Extract the `prompt` from the body of the request
15 | const { prompt } = await req.json()
16 |
17 | // Ask OpenAI for a streaming completion given the prompt
18 | const response = await openai.createCompletion({
19 | model: 'text-davinci-003',
20 | stream: true,
21 | prompt
22 | })
23 |
24 | // Convert the response into a friendly text-stream
25 | const stream = OpenAIStream(response)
26 |
27 | // Respond with the stream
28 | return new StreamingTextResponse(stream)
29 | }
--------------------------------------------------------------------------------
/src/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { Configuration, OpenAIApi } from "openai-edge";
2 | import { OpenAIStream, StreamingTextResponse } from "ai";
3 |
4 | // Create an OpenAI API client (that's edge friendly!)
5 | const config = new Configuration({
6 | apiKey: process.env.OPENAI_API_KEY,
7 | });
8 | const openai = new OpenAIApi(config);
9 |
10 | // IMPORTANT! Set the runtime to edge
11 | export const runtime = "edge";
12 |
13 | export async function POST(req: Request) {
14 | // Extract the `messages` from the body of the request
15 | const { messages } = await req.json();
16 |
17 | // Ask OpenAI for a streaming chat completion given the prompt
18 | const response = await openai.createChatCompletion({
19 | model: "gpt-3.5-turbo",
20 | stream: true,
21 | messages,
22 | });
23 | // Convert the response into a friendly text-stream
24 | const stream = OpenAIStream(response);
25 | // Respond with the stream
26 | return new StreamingTextResponse(stream);
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 tzhangchi
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 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/check.tsx:
--------------------------------------------------------------------------------
1 | // app/page.tsx
2 | "use client";
3 |
4 | import { useCompletion } from "ai/react";
5 | import { useState, useCallback } from "react";
6 |
7 | export default function PostEditorPage() {
8 | // Locally store our blog posts content
9 | const [content, setContent] = useState("");
10 | const { complete } = useCompletion({
11 | api: "/api/generate",
12 | });
13 |
14 | const checkAndPublish = useCallback(
15 | async (c: string) => {
16 | const completion = await complete(c);
17 | if (!completion) throw new Error("Failed to check typos");
18 | const typos = JSON.parse(completion);
19 | // you should more validation here to make sure the response is valid
20 | if (typos?.length && !window.confirm("Typos found… continue?")) return;
21 | else alert("Post published");
22 | },
23 | [complete]
24 | );
25 |
26 | return (
27 |
28 |
Post Editor
29 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/api/generate/route.ts:
--------------------------------------------------------------------------------
1 | import { Configuration, OpenAIApi } from "openai-edge";
2 | import { OpenAIStream, StreamingTextResponse } from "ai";
3 | import { kv } from "@vercel/kv";
4 | import { Ratelimit } from "@upstash/ratelimit";
5 |
6 | const config = new Configuration({
7 | apiKey: process.env["OPENAI_API_KEY"],
8 | });
9 | console.log("OPENAI_API_KEY", process.env["OPENAI_API_KEY"]);
10 | console.log("KV_REST_API_TOKEN", process.env["KV_REST_API_TOKEN"]);
11 | const openai = new OpenAIApi(config);
12 |
13 | export const runtime = "edge";
14 |
15 | export async function POST(req: Request): Promise {
16 | if (
17 | process.env.NODE_ENV != "development" &&
18 | process.env.KV_REST_API_URL &&
19 | process.env.KV_REST_API_TOKEN
20 | ) {
21 | const ip = req.headers.get("x-forwarded-for");
22 | const ratelimit = new Ratelimit({
23 | redis: kv,
24 | limiter: Ratelimit.slidingWindow(50, "1 d"),
25 | });
26 |
27 | const { success, limit, reset, remaining } = await ratelimit.limit(
28 | `note-ai_ratelimit_${ip}`
29 | );
30 |
31 | if (!success) {
32 | return new Response("You have reached your request limit for the day.", {
33 | status: 429,
34 | headers: {
35 | "X-RateLimit-Limit": limit.toString(),
36 | "X-RateLimit-Remaining": remaining.toString(),
37 | "X-RateLimit-Reset": reset.toString(),
38 | },
39 | });
40 | }
41 | }
42 |
43 | let { prompt } = await req.json();
44 |
45 | const response = await openai.createChatCompletion({
46 | model: "gpt-3.5-turbo",
47 | messages: [
48 | {
49 | role: "system",
50 | content:
51 | "You are an AI writing assistant that continues existing text based on context from prior text. " +
52 | "Give more weight/priority to the later characters than the beginning ones. " +
53 | "Limit your response to no more than 200 characters, but make sure to construct complete sentences.",
54 | },
55 | {
56 | role: "user",
57 | content: prompt,
58 | },
59 | ],
60 | temperature: 0.7,
61 | top_p: 1,
62 | frequency_penalty: 0,
63 | presence_penalty: 0,
64 | stream: true,
65 | n: 1,
66 | });
67 |
68 | // Convert the response into a friendly text-stream
69 | const stream = OpenAIStream(response);
70 |
71 | // Respond with the stream
72 | return new StreamingTextResponse(stream);
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/editor/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | assertExists,
3 | createIndexeddbStorage,
4 | createMemoryStorage,
5 | createSimpleServerStorage,
6 | DebugDocProvider,
7 | type DocProviderConstructor,
8 | Generator,
9 | Utils,
10 | Workspace,
11 | type WorkspaceOptions,
12 | } from "@blocksuite/store";
13 | import type { BlobStorage, Page } from "@blocksuite/store";
14 | // import type { IShape } from "@blocksuite/phasor";
15 | import * as Y from "yjs";
16 | import { EditorContainer } from "@blocksuite/editor";
17 | /**
18 | * Provider configuration is specified by `?providers=webrtc` or `?providers=indexeddb,webrtc` in URL params.
19 | * We use webrtcDocProvider by default if the `providers` param is missing.
20 | */
21 | export function createWorkspaceOptions(): WorkspaceOptions {
22 | const providers: DocProviderConstructor[] = [];
23 | const blobStorages: ((id: string) => BlobStorage)[] = [];
24 | let idGenerator: Generator = Generator.AutoIncrement; // works only in single user mode
25 | blobStorages.push(createMemoryStorage);
26 | return {
27 | id: "step-article",
28 | providers,
29 | idGenerator,
30 | blobStorages,
31 | defaultFlags: {
32 | enable_toggle_block: true,
33 | enable_set_remote_flag: true,
34 | enable_drag_handle: true,
35 | enable_block_hub: true,
36 | enable_database: true,
37 | enable_edgeless_toolbar: true,
38 | enable_linked_page: true,
39 | enable_bookmark_operation: false,
40 | readonly: {
41 | "space:page0": false,
42 | },
43 | },
44 | };
45 | }
46 |
47 | // export function addShapeElement(
48 | // page: Page,
49 | // surfaceBlockId: string,
50 | // shape: IShape
51 | // ) {
52 | // const shapeYElement = new Y.Map();
53 | // for (const [key, value] of Object.entries(shape)) {
54 | // shapeYElement.set(key, value);
55 | // }
56 | // const yBlock = page.getYBlockById(surfaceBlockId);
57 | // assertExists(yBlock);
58 | // let yContainer = yBlock.get("elements") as InstanceType;
59 | // if (!yContainer) {
60 | // yContainer = new page.YMap();
61 | // yBlock.set("elements", yContainer);
62 | // }
63 | // yContainer.set(shape.id as string, shapeYElement);
64 | // }
65 |
66 | export const createEditor = (page: Page, element: HTMLElement) => {
67 | const editor = new EditorContainer();
68 | editor.page = page;
69 | editor.slots.pageLinkClicked.on(({ pageId }) => {
70 | const target = page.workspace.getPage(pageId);
71 | if (!target) {
72 | throw new Error(`Failed to jump to page ${pageId}`);
73 | }
74 | editor.page = target;
75 | });
76 |
77 | element.append(editor);
78 |
79 | editor.createBlockHub().then((blockHub) => {
80 | document.body.appendChild(blockHub);
81 | });
82 | return editor;
83 | };
84 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | 
4 |
5 | Note AI
6 |
7 |
8 |
9 | An open-source Notion-style WYSIWYG editor with AI-powered autocompletions.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Introduction ·
21 | Deploy Your Own ·
22 | Setting Up Locally ·
23 | Tech Stack ·
24 | Contributing ·
25 | License
26 |
27 |
28 |
29 | ## Introduction
30 |
31 | [Note AI](https://note-ai.vercel.app/) is a Notion-style WYSIWYG editor with AI-powered autocompletions.
32 |
33 | Here's a quick 20-second demo:
34 |
35 |
36 |
37 | https://github.com/tzhangchi/note-ai/assets/5910926/c5fa9b6d-4199-4a8c-9c90-98f7785a6281
38 |
39 |
40 |
41 |
42 |
43 | ## Deploy Your Own
44 |
45 | You can deploy your own version of Note AI to Vercel with one click:
46 |
47 | [](https://note-ai.vercel.app/)
48 |
49 | ## Setting Up Locally
50 |
51 | To set up Note AI locally, you'll need to clone the repository and set up the following environment variables:
52 |
53 | - `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys))
54 |
55 | If you've deployed this to Vercel, you can also use [`vc env pull`](https://vercel.com/docs/cli/env#exporting-development-environment-variables) to pull the environment variables from your Vercel project.
56 |
57 | ## Tech Stack
58 |
59 | Note AI is built on the following stack:
60 |
61 | - [Next.js](https://nextjs.org/) – framework
62 | - [blocksuite](https://blocksuite.affine.pro/) – text editor
63 | - [OpenAI](https://openai.com/) - AI completions
64 | - [Vercel AI SDK](https://sdk.vercel.ai/docs) – AI library
65 | - [Vercel](https://vercel.com) – deployments
66 | - [TailwindCSS](https://tailwindcss.com/) – styles
67 |
68 | ## Contributing
69 |
70 | Here's how you can contribute:
71 |
72 | - [Open an issue](https://github.com/tzhangchi/note-ai/issues) if you believe you've encountered a bug.
73 | - Make a [pull request](https://github.com/tzhangchi/note-ai/pull) to add new features/make quality-of-life improvements/fix bugs.
74 |
75 | ## Author
76 |
77 | - Chi Zhang ([@Terrychinaz](https://twitter.com/Terrychinaz))
78 |
79 | ## License
80 |
81 | Licensed under the [MIT license](https://github.com/tzhangchi/note-ai/blob/main/LICENSE.md).
82 |
--------------------------------------------------------------------------------
/src/components/editor/editor.tsx:
--------------------------------------------------------------------------------
1 | "use client"; // This is a client component 👈🏽
2 | import React, { useEffect, useRef, useState } from "react";
3 | import { createEditor, createWorkspaceOptions } from "./utils";
4 | import { __unstableSchemas, AffineSchemas } from "@blocksuite/blocks/models";
5 | import { useMount, useUpdate, useUpdateEffect } from "ahooks";
6 | import type { Page } from "@blocksuite/store";
7 | import { Text, Workspace } from "@blocksuite/store";
8 | import { ContentParser } from "@blocksuite/blocks/content-parser";
9 | import "@blocksuite/editor/themes/affine.css";
10 | import { presetMarkdown } from "./data";
11 | import { PageBlockModel, getDefaultPage } from "@blocksuite/blocks";
12 | import { useCompletion } from "ai/react";
13 |
14 | export interface IEditorProps {
15 | className?: string;
16 | }
17 |
18 | const options = createWorkspaceOptions();
19 | const pageId = "step-article-page";
20 | const Editor: React.FC = (props) => {
21 | const { className } = props;
22 |
23 | const [displayMarkdown, setDisplayMarkdown] = useState("");
24 | const [isMenuOpen, setIsMenuOpen] = useState(false);
25 | const [canEditor, setCanEditor] = useState(false);
26 | const exportPDF = () => {
27 | window.print();
28 | };
29 | const toggleMenu = () => {
30 | setIsMenuOpen(!isMenuOpen);
31 | };
32 | useEffect(() => {
33 | // 获取浏览器参数
34 | const url = new URL(window.location.href);
35 | const searchParams = url.searchParams;
36 | const init = searchParams.get("init");
37 |
38 | if (init === "streaming") {
39 | let i = 0;
40 | const interval = setInterval(() => {
41 | setDisplayMarkdown(presetMarkdown.substring(0, i));
42 | i++;
43 | if (i > presetMarkdown.length) {
44 | setCanEditor(true);
45 | clearInterval(interval);
46 | }
47 | }, 10);
48 | return () => clearInterval(interval);
49 | } else {
50 | setCanEditor(true);
51 |
52 | console.log("init ", presetMarkdown);
53 | setDisplayMarkdown(presetMarkdown);
54 | // complete(
55 | // "There can be more than Notion and Miro. AFFiNE is a next-gen knowledge base that brings planning, sorting and creating all together. Privacy first, open-source, customizable and ready to use."
56 | // );
57 | }
58 | }, []);
59 |
60 | const ref = useRef(null);
61 |
62 | const workspaceRef = useRef(null!);
63 | const pageRef = useRef(null!);
64 | const promptRef = useRef(null);
65 |
66 | const pageBlockIdRef = useRef("");
67 | const contentParserRef = useRef(null!);
68 | const [frameId, setFrameId] = useState("");
69 |
70 | // 初始化workspace、page
71 | useMount(() => {
72 | if (
73 | ref.current &&
74 | !workspaceRef.current &&
75 | !pageRef.current &&
76 | !pageBlockIdRef.current
77 | ) {
78 | const workspace = new Workspace(options)
79 | .register(AffineSchemas)
80 | .register(__unstableSchemas);
81 | const page = workspace.createPage({ id: pageId });
82 | const contentParser = new ContentParser(page);
83 | createEditor(page, ref.current);
84 | pageRef.current = page;
85 | workspaceRef.current = workspace;
86 |
87 | contentParserRef.current = contentParser;
88 | }
89 | });
90 |
91 | useEffect(() => {
92 | if (!pageRef.current) {
93 | return;
94 | }
95 | if (!pageBlockIdRef.current) {
96 | const _pageBlockId = pageRef.current.addBlock("affine:page", {
97 | title: new Text("Introduction Note AI"),
98 | });
99 | pageBlockIdRef.current = _pageBlockId;
100 | }
101 | }, []);
102 |
103 | useUpdateEffect(() => {
104 | const page = pageRef.current;
105 | if (!page) {
106 | return;
107 | }
108 | const root = page.root;
109 | if (root) {
110 | const blocks = root.children;
111 | console.log(blocks);
112 | if (blocks.length) {
113 | blocks.forEach((item) => {
114 | page.deleteBlock(item);
115 | });
116 | }
117 | }
118 | page.resetHistory();
119 |
120 | const frameId = pageRef.current.addBlock(
121 | "affine:frame",
122 | {},
123 | pageBlockIdRef.current
124 | );
125 | contentParserRef.current.importMarkdown(displayMarkdown, frameId);
126 | }, [displayMarkdown]);
127 |
128 | const onChangeTitle = () => {
129 | if (pageBlockIdRef.current) {
130 | const block = pageRef.current.getBlockById(
131 | pageBlockIdRef.current
132 | ) as PageBlockModel;
133 | if (block) {
134 | const pageComponent = getDefaultPage(pageRef.current);
135 |
136 | /* 重置title且失焦 */
137 | if (pageComponent) {
138 | pageComponent.titleVEditor.setText("new title123");
139 | setTimeout(() => {
140 | pageComponent.titleVEditor.rootElement.blur();
141 | }, 10);
142 | }
143 | }
144 | }
145 | };
146 |
147 | const onDelAllBlocks = () => {
148 | const page = pageRef.current;
149 | if (page) {
150 | const root = page.root;
151 | if (root) {
152 | const blocks = root.children;
153 |
154 | if (blocks.length) {
155 | blocks.forEach((item) => {
156 | page.deleteBlock(item);
157 | });
158 | }
159 | }
160 | }
161 | };
162 | const streamEffectInput = (str: string) => {
163 | let i = 0;
164 | if (promptRef && promptRef.current) {
165 | str = promptRef.current.value + str;
166 | }
167 | const interval = setInterval(() => {
168 | setDisplayMarkdown(str.substring(0, i));
169 | i++;
170 | if (i > str.length) {
171 | setCanEditor(true);
172 | clearInterval(interval);
173 | }
174 | }, 10);
175 | };
176 | const { complete, isLoading } = useCompletion({
177 | id: "note-ai",
178 | api: "/api/generate",
179 | onResponse: (response) => {
180 | if (response.status === 429) {
181 | alert("You have reached your request limit for the day.");
182 | console.log("Rate Limit Reached");
183 | return;
184 | }
185 | // editor.chain().focus().deleteRange(range).run();
186 | },
187 | onFinish: (_prompt, completion) => {
188 | console.log("_prompt", _prompt);
189 | console.log("completion", completion);
190 |
191 | // highlight the generated text
192 | // editor.commands.setTextSelection({
193 | // from: range.from,
194 | // to: range.from + completion.length,
195 | // });
196 | if (!completion) {
197 | streamEffectInput("completion is null");
198 | return;
199 | }
200 | streamEffectInput(completion);
201 | // if (promptRef && promptRef.current) {
202 | // promptRef.current.value = completion;
203 | // }
204 | },
205 | onError: () => {
206 | streamEffectInput("Note AI generate content..., something went wrong.");
207 | },
208 | });
209 | useEffect(() => {
210 | const handleClickOutside = (event: MouseEvent) => {
211 | const target = event.target as HTMLElement;
212 | if (!target.closest("#dropdownMenuIconButton")) {
213 | setIsMenuOpen(false);
214 | }
215 | };
216 | document.addEventListener("click", handleClickOutside);
217 | return () => {
218 | document.removeEventListener("click", handleClickOutside);
219 | };
220 | }, []);
221 | const contiuneWrite = () => {
222 | const prompt = promptRef.current?.value || "";
223 | complete(prompt);
224 | };
225 | const downloadMarkdown = () => {
226 | // contentParserRef.current;
227 | // debugger;
228 | // contentParserRef.current.exportMarkdown();
229 | // contentParserRef.current.exportHtml();
230 | };
231 |
232 | return (
233 |
234 |
239 |
248 |
249 |
250 |
266 |
267 |
322 |
323 |
328 |
334 |
Current state: {isLoading ? "Generating..." : "Idle"}
335 |
336 | {isLoading && (
337 |
340 | )}
341 |
342 |
345 |
346 | );
347 | };
348 |
349 | export default Editor;
350 |
--------------------------------------------------------------------------------