From 602f2059fde644d77062adf8070608c1ccf1afb5 Mon Sep 17 00:00:00 2001 From: songjunxi <3224266014@qq.com> Date: Fri, 10 Nov 2023 14:59:47 +0800 Subject: [PATCH] release 0.3.4 --- README.md | 103 +- apps/web/.env.example | 18 + apps/web/.npmrc | 1 + apps/web/app/api/auth/[...nextauth]/route.ts | 106 ++ apps/web/app/api/collaboration/count/route.ts | 38 + apps/web/app/api/collaboration/id/route.ts | 45 + .../app/api/collaboration/local-id/route.ts | 65 + apps/web/app/api/collaboration/room/route.ts | 107 ++ apps/web/app/api/collaboration/route.ts | 180 +++ apps/web/app/api/generate/bot/route.ts | 109 ++ apps/web/app/api/generate/continue/route.ts | 99 ++ apps/web/app/api/generate/edit/route.ts | 101 ++ apps/web/app/api/generate/route.ts | 81 - apps/web/app/api/generate/translate/route.ts | 96 ++ apps/web/app/api/share/all/route.ts | 51 + apps/web/app/api/share/route.ts | 168 +++ apps/web/app/api/share/update/keep/route.ts | 45 + apps/web/app/api/status/route.ts | 9 + apps/web/app/api/upload/route.ts | 74 +- apps/web/app/api/users/route.ts | 50 + apps/web/app/error.tsx | 53 + apps/web/app/features/card.tsx | 24 + apps/web/app/features/guide.tsx | 305 ++++ apps/web/app/feedback/layout.tsx | 27 + apps/web/app/feedback/page.tsx | 19 + apps/web/app/feedback/wrapper.tsx | 28 + apps/web/app/google-analytics.tsx | 28 + apps/web/app/invite/[id]/layout.tsx | 28 + apps/web/app/invite/[id]/page.tsx | 19 + apps/web/app/invite/[id]/wrapper.tsx | 233 +++ apps/web/app/layout.tsx | 33 +- apps/web/app/manifest.ts | 26 + apps/web/app/not-found.tsx | 9 + apps/web/app/opengraph-image.png | Bin 0 -> 196021 bytes apps/web/app/page.tsx | 27 +- apps/web/app/post/[id]/editor.tsx | 506 +++++++ apps/web/app/post/[id]/layout.tsx | 29 + apps/web/app/post/[id]/page.tsx | 13 + apps/web/app/post/[id]/request.ts | 182 +++ apps/web/app/post/[id]/sider.tsx | 655 +++++++++ apps/web/app/post/[id]/wrapper.tsx | 56 + apps/web/app/pricing/layout.tsx | 28 + apps/web/app/pricing/page.tsx | 19 + apps/web/app/pricing/wrapper.tsx | 254 ++++ apps/web/app/privacy/layout.tsx | 28 + apps/web/app/privacy/page.tsx | 19 + apps/web/app/privacy/wrapper.tsx | 37 + apps/web/app/providers.tsx | 2 - apps/web/app/publish/[id]/layout.tsx | 29 + apps/web/app/publish/[id]/page.tsx | 22 + apps/web/app/publish/[id]/wrapper.tsx | 138 ++ .../web/app/server-sitemap-index.xml/route.ts | 11 + apps/web/app/settings/layout.tsx | 28 + apps/web/app/settings/page.tsx | 19 + apps/web/app/settings/wrapper.tsx | 23 + apps/web/app/templates/layout.tsx | 28 + apps/web/app/templates/page.tsx | 19 + apps/web/app/templates/wrapper.tsx | 11 + apps/web/config/site.ts | 61 + apps/web/lib/consts.ts | 402 +++++ apps/web/lib/db/collaboration.ts | 140 ++ apps/web/lib/db/prisma.ts | 11 + apps/web/lib/db/share.ts | 103 ++ apps/web/lib/db/user.ts | 23 + apps/web/lib/hooks/use-scroll.ts | 16 + apps/web/lib/hooks/use-window-size.ts | 38 + apps/web/lib/types/active-code.ts | 8 + apps/web/lib/types/note.ts | 21 + apps/web/lib/types/response.ts | 5 + apps/web/lib/types/user.ts | 10 + apps/web/lib/utils.ts | 160 ++ apps/web/next-sitemap.config.js | 39 + apps/web/next.config.js | 34 +- apps/web/package.json | 41 +- apps/web/prisma/schema.prisma | 112 ++ apps/web/{app/favicon.ico => public/512.png} | Bin apps/web/public/android-chrome-96x96.png | Bin 0 -> 3014 bytes apps/web/public/apple-touch-icon.png | Bin 0 -> 2962 bytes apps/web/public/cat.png | Bin 0 -> 26661 bytes apps/web/public/desktop.png | Bin 0 -> 153918 bytes apps/web/public/e1.png | Bin 0 -> 85804 bytes apps/web/public/e2.png | Bin 0 -> 42013 bytes apps/web/public/e3.png | Bin 0 -> 77707 bytes apps/web/public/favicon-16x16.png | Bin 0 -> 643 bytes apps/web/public/favicon-32x32.png | Bin 0 -> 1050 bytes apps/web/public/favicon.ico | Bin 0 -> 15086 bytes apps/web/public/follow.svg | 7 + apps/web/public/logo-128.png | Bin 0 -> 18426 bytes apps/web/public/logo-256.png | Bin 0 -> 18426 bytes apps/web/public/logo.png | Bin 0 -> 63772 bytes apps/web/public/logo.svg | 3 + apps/web/public/mstile-150x150.png | Bin 0 -> 4732 bytes apps/web/public/opengraph-image.png | Bin 0 -> 196021 bytes apps/web/public/product.svg | 26 + apps/web/public/robots.txt | 26 + apps/web/public/safari-pinned-tab.svg | 27 + apps/web/public/sitemap.xml | 4 + apps/web/store/db.model.ts | 26 + apps/web/styles/globals.css | 26 +- apps/web/styles/search-btn.css | 66 + apps/web/tsconfig.json | 6 +- apps/web/ui/editor.tsx | 28 - apps/web/ui/icons/index.tsx | 4 - apps/web/ui/layout/active-licence-modal.tsx | 116 ++ apps/web/ui/layout/create-room-modal.tsx | 196 +++ apps/web/ui/layout/edit-nickname-modal.tsx | 115 ++ apps/web/ui/layout/email-login-button.tsx | 146 ++ apps/web/ui/layout/footer-publish.tsx | 27 + apps/web/ui/layout/footer.tsx | 114 ++ apps/web/ui/layout/nav.tsx | 8 + apps/web/ui/layout/navbar.tsx | 91 ++ apps/web/ui/layout/not-found.tsx | 50 + apps/web/ui/layout/sign-in-modal.tsx | 60 + apps/web/ui/layout/user-dropdown.tsx | 124 ++ apps/web/ui/menu.tsx | 115 +- apps/web/ui/new-post-button.tsx | 91 ++ apps/web/ui/search-input.tsx | 20 + apps/web/ui/shared/counting-numbers.tsx | 40 + apps/web/ui/shared/icons/box.tsx | 18 + apps/web/ui/shared/icons/checked.tsx | 18 + apps/web/ui/shared/icons/color.tsx | 20 + apps/web/ui/shared/icons/expanding-arrow.tsx | 36 + .../ui/{ => shared}/icons/font-default.tsx | 0 apps/web/ui/{ => shared}/icons/font-mono.tsx | 0 apps/web/ui/{ => shared}/icons/font-serif.tsx | 0 apps/web/ui/{ => shared}/icons/github.tsx | 0 apps/web/ui/shared/icons/google.tsx | 47 + apps/web/ui/shared/icons/image-down.tsx | 22 + apps/web/ui/shared/icons/index.tsx | 12 + apps/web/ui/shared/icons/loading-circle.tsx | 22 + .../ui/shared/icons/loading-dots.module.css | 40 + apps/web/ui/shared/icons/loading-dots.tsx | 13 + .../shared/icons/loading-spinner.module.css | 79 + apps/web/ui/shared/icons/loading-spinner.tsx | 20 + apps/web/ui/shared/icons/logo.tsx | 16 + apps/web/ui/shared/icons/product-hunt.tsx | 75 + apps/web/ui/shared/icons/twitter.tsx | 14 + apps/web/ui/shared/icons/widgets.tsx | 16 + apps/web/ui/shared/leaflet.tsx | 73 + apps/web/ui/shared/modal.tsx | 84 ++ apps/web/ui/shared/placeholder.tsx | 15 + apps/web/ui/shared/popover.tsx | 50 + apps/web/ui/shared/tooltip.tsx | 71 + package.json | 16 +- packages/core/package.json | 12 +- packages/core/src/ui/editor/bot/chat-bot.tsx | 237 +++ .../ai-selectors/edit/ai-edit-bubble.tsx | 15 +- .../ai-selectors/edit/ai-edit-selector.tsx | 105 +- .../translate/ai-translate-bubble.tsx | 15 +- .../translate/ai-translate-selector.tsx | 2 +- .../ui/editor/bubble-menu/color-selector.tsx | 70 +- .../ui/editor/bubble-menu/node-selector.tsx | 22 +- .../ui/editor/extensions/collaboration.tsx | 113 ++ .../ui/editor/extensions/color-highlighter.ts | 56 + .../core/src/ui/editor/extensions/index.tsx | 24 +- .../ui/editor/extensions/slash-command.tsx | 7 +- packages/core/src/ui/editor/index.tsx | 71 +- packages/core/src/ui/editor/styles.css | 71 + packages/core/src/ui/icons/magic-1.tsx | 18 + packages/core/src/ui/icons/magic.tsx | 3 +- pnpm-lock.yaml | 1303 ++++++++++++++++- 161 files changed, 9921 insertions(+), 347 deletions(-) create mode 100644 apps/web/.npmrc create mode 100644 apps/web/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/app/api/collaboration/count/route.ts create mode 100644 apps/web/app/api/collaboration/id/route.ts create mode 100644 apps/web/app/api/collaboration/local-id/route.ts create mode 100644 apps/web/app/api/collaboration/room/route.ts create mode 100644 apps/web/app/api/collaboration/route.ts create mode 100644 apps/web/app/api/generate/bot/route.ts create mode 100644 apps/web/app/api/generate/continue/route.ts create mode 100644 apps/web/app/api/generate/edit/route.ts delete mode 100644 apps/web/app/api/generate/route.ts create mode 100644 apps/web/app/api/generate/translate/route.ts create mode 100644 apps/web/app/api/share/all/route.ts create mode 100644 apps/web/app/api/share/route.ts create mode 100644 apps/web/app/api/share/update/keep/route.ts create mode 100644 apps/web/app/api/status/route.ts create mode 100644 apps/web/app/api/users/route.ts create mode 100644 apps/web/app/error.tsx create mode 100644 apps/web/app/features/card.tsx create mode 100644 apps/web/app/features/guide.tsx create mode 100644 apps/web/app/feedback/layout.tsx create mode 100644 apps/web/app/feedback/page.tsx create mode 100644 apps/web/app/feedback/wrapper.tsx create mode 100644 apps/web/app/google-analytics.tsx create mode 100644 apps/web/app/invite/[id]/layout.tsx create mode 100644 apps/web/app/invite/[id]/page.tsx create mode 100644 apps/web/app/invite/[id]/wrapper.tsx create mode 100644 apps/web/app/manifest.ts create mode 100644 apps/web/app/not-found.tsx create mode 100644 apps/web/app/opengraph-image.png create mode 100644 apps/web/app/post/[id]/editor.tsx create mode 100644 apps/web/app/post/[id]/layout.tsx create mode 100644 apps/web/app/post/[id]/page.tsx create mode 100644 apps/web/app/post/[id]/request.ts create mode 100644 apps/web/app/post/[id]/sider.tsx create mode 100644 apps/web/app/post/[id]/wrapper.tsx create mode 100644 apps/web/app/pricing/layout.tsx create mode 100644 apps/web/app/pricing/page.tsx create mode 100644 apps/web/app/pricing/wrapper.tsx create mode 100644 apps/web/app/privacy/layout.tsx create mode 100644 apps/web/app/privacy/page.tsx create mode 100644 apps/web/app/privacy/wrapper.tsx create mode 100644 apps/web/app/publish/[id]/layout.tsx create mode 100644 apps/web/app/publish/[id]/page.tsx create mode 100644 apps/web/app/publish/[id]/wrapper.tsx create mode 100644 apps/web/app/server-sitemap-index.xml/route.ts create mode 100644 apps/web/app/settings/layout.tsx create mode 100644 apps/web/app/settings/page.tsx create mode 100644 apps/web/app/settings/wrapper.tsx create mode 100644 apps/web/app/templates/layout.tsx create mode 100644 apps/web/app/templates/page.tsx create mode 100644 apps/web/app/templates/wrapper.tsx create mode 100644 apps/web/config/site.ts create mode 100644 apps/web/lib/consts.ts create mode 100644 apps/web/lib/db/collaboration.ts create mode 100644 apps/web/lib/db/prisma.ts create mode 100644 apps/web/lib/db/share.ts create mode 100644 apps/web/lib/db/user.ts create mode 100644 apps/web/lib/hooks/use-scroll.ts create mode 100644 apps/web/lib/hooks/use-window-size.ts create mode 100644 apps/web/lib/types/active-code.ts create mode 100644 apps/web/lib/types/note.ts create mode 100644 apps/web/lib/types/response.ts create mode 100644 apps/web/lib/types/user.ts create mode 100644 apps/web/next-sitemap.config.js create mode 100644 apps/web/prisma/schema.prisma rename apps/web/{app/favicon.ico => public/512.png} (100%) create mode 100644 apps/web/public/android-chrome-96x96.png create mode 100644 apps/web/public/apple-touch-icon.png create mode 100644 apps/web/public/cat.png create mode 100644 apps/web/public/desktop.png create mode 100644 apps/web/public/e1.png create mode 100644 apps/web/public/e2.png create mode 100644 apps/web/public/e3.png create mode 100644 apps/web/public/favicon-16x16.png create mode 100644 apps/web/public/favicon-32x32.png create mode 100644 apps/web/public/favicon.ico create mode 100644 apps/web/public/follow.svg create mode 100644 apps/web/public/logo-128.png create mode 100644 apps/web/public/logo-256.png create mode 100644 apps/web/public/logo.png create mode 100644 apps/web/public/logo.svg create mode 100644 apps/web/public/mstile-150x150.png create mode 100644 apps/web/public/opengraph-image.png create mode 100644 apps/web/public/product.svg create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/public/safari-pinned-tab.svg create mode 100644 apps/web/public/sitemap.xml create mode 100644 apps/web/store/db.model.ts create mode 100644 apps/web/styles/search-btn.css delete mode 100644 apps/web/ui/editor.tsx delete mode 100644 apps/web/ui/icons/index.tsx create mode 100644 apps/web/ui/layout/active-licence-modal.tsx create mode 100644 apps/web/ui/layout/create-room-modal.tsx create mode 100644 apps/web/ui/layout/edit-nickname-modal.tsx create mode 100644 apps/web/ui/layout/email-login-button.tsx create mode 100644 apps/web/ui/layout/footer-publish.tsx create mode 100644 apps/web/ui/layout/footer.tsx create mode 100644 apps/web/ui/layout/nav.tsx create mode 100644 apps/web/ui/layout/navbar.tsx create mode 100644 apps/web/ui/layout/not-found.tsx create mode 100644 apps/web/ui/layout/sign-in-modal.tsx create mode 100644 apps/web/ui/layout/user-dropdown.tsx create mode 100644 apps/web/ui/new-post-button.tsx create mode 100644 apps/web/ui/search-input.tsx create mode 100644 apps/web/ui/shared/counting-numbers.tsx create mode 100644 apps/web/ui/shared/icons/box.tsx create mode 100644 apps/web/ui/shared/icons/checked.tsx create mode 100644 apps/web/ui/shared/icons/color.tsx create mode 100644 apps/web/ui/shared/icons/expanding-arrow.tsx rename apps/web/ui/{ => shared}/icons/font-default.tsx (100%) rename apps/web/ui/{ => shared}/icons/font-mono.tsx (100%) rename apps/web/ui/{ => shared}/icons/font-serif.tsx (100%) rename apps/web/ui/{ => shared}/icons/github.tsx (100%) create mode 100644 apps/web/ui/shared/icons/google.tsx create mode 100644 apps/web/ui/shared/icons/image-down.tsx create mode 100644 apps/web/ui/shared/icons/index.tsx create mode 100644 apps/web/ui/shared/icons/loading-circle.tsx create mode 100644 apps/web/ui/shared/icons/loading-dots.module.css create mode 100644 apps/web/ui/shared/icons/loading-dots.tsx create mode 100644 apps/web/ui/shared/icons/loading-spinner.module.css create mode 100644 apps/web/ui/shared/icons/loading-spinner.tsx create mode 100644 apps/web/ui/shared/icons/logo.tsx create mode 100644 apps/web/ui/shared/icons/product-hunt.tsx create mode 100644 apps/web/ui/shared/icons/twitter.tsx create mode 100644 apps/web/ui/shared/icons/widgets.tsx create mode 100644 apps/web/ui/shared/leaflet.tsx create mode 100644 apps/web/ui/shared/modal.tsx create mode 100644 apps/web/ui/shared/placeholder.tsx create mode 100644 apps/web/ui/shared/popover.tsx create mode 100644 apps/web/ui/shared/tooltip.tsx create mode 100644 packages/core/src/ui/editor/bot/chat-bot.tsx create mode 100644 packages/core/src/ui/editor/extensions/collaboration.tsx create mode 100644 packages/core/src/ui/editor/extensions/color-highlighter.ts create mode 100644 packages/core/src/ui/icons/magic-1.tsx diff --git a/README.md b/README.md index 68e707f..9b43c0c 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,42 @@ -# Inke +

+ +

- - - - - - - - License - - - status - +

Inke - Small is beautiful

-Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. +

+ + status + + + License + + inke.app's GitHub repo +

-See live demo: [inke-web](https://inke.app) +# About Inke + +[Inke](https://inke.app/) is a notebook with AI assisted writing and real-time collaboration. Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. -# Install Inke +## Features -```bash -npm install inkejs -``` +- 😗 WYSIWYG Editing like markdown +- 😄 Efficient Shortcut Inputs +- 😍 AI-powered Text Autocomplete +- 🥰 Local Data Storage +- 🥳 Image uploads(use command or drag) +- 😍 Cloud storage notes +- 😄 Export as json/image/markdown +- 🥰 Install as PWA App to your desktop + + +## Self Hosting + +You can deploy your own version of Inke to Vercel with one click: + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-link=https%3A%2F%2Fgithub.com%2Fyesmore%2Finke&env=OPENAI_API_KEY&envDescription=Find%20your%20OpenAI%20API%20Key%20by%20click%20the%20right%20Learn%20More%20button.%20%20&envLink=https%3A%2F%2Fplatform.openai.com%2Faccount%2Fapi-keys&project-name=inke&repository-name=inke) ## Setting Up Locally @@ -42,39 +55,29 @@ pnpm build pnpm dev ``` -Then, you can use it in your code like this: +## Environment Variable -```jsx -import { Editor } from "inkejs"; +| Prop | Type | Description | Example | +| ----------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `OPENAI_API_KEY` | `string` | The API Key to use for the OpenAI completion API. | `sk-xxx` | +| `BLOB_READ_WRITE_TOKEN` | `string` | OPTIONAL: Vercel Blob (for uploading images). Get your Vercel Blob credentials [here](https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart) | `vercel_blob_xxxx` | +| `KV_REST_API_URL` | `string` | OPTIONAL: Vercel KV (for ratelimiting). Get your Vercel KV credentials [here](https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart) | [`"https//xxx.com"`](https://github.com/steven-tey/novel/blob/main/packages/core/src/ui/editor/default-content.tsx) | +| `KV_REST_API_TOKEN` | `string` | OPTIONAL: Vercel KV (for ratelimiting). Get your Vercel KV credentials [here](https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart). | `abcdefg` | +| `NEXTAUTH_SECRET` | `string` | Only for production – generate one here: [generate-secret](https://generate-secret.vercel.app/32). | `fasgagahhjerherg` | +| `DATABASE_URL` | `string` | Database url, recommend using [MongoDB Atlas](https://account.mongodb.com/account/login?signedOut=true) | `mongodb+srv://xxxx` | +| `EMAIL_FROM` | `string` | Next Auth Provider: [Email](https://next-auth.js.org/providers/email) | `Inke ` | +| `EMAIL_SERVER` | `string` | Next Auth Provider: [Email](https://next-auth.js.org/providers/email) | `smtps://xxxx` | +| `GITHUB_ID` | `string` | Next Auth Provider: [Github](https://next-auth.js.org/providers/github) | `aaaaaaaa` | +| `GITHUB_SECRET` | `string` | Next Auth Provider: [Github](https://next-auth.js.org/providers/github) | `aaaaaaaa` | +| `GOOGLE_CLIENT_ID` | `string` | Next Auth Provider: [Google](https://next-auth.js.org/providers/google) | `aaaaaaaa` | +| `GOOGLE_CLIENT_SECRET` | `string` | Next Auth Provider: [Google](https://next-auth.js.org/providers/google) | `aaaaaaaa` | -export default function App() { - return ; -} +# Install Inke + +```bash +npm install inkejs ``` -The `Editor` is a React component that takes in the following props: - -| Prop | Type | Description | Default | -| --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` | -| `className` | `string` | Editor container classname. | `"relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"` | -| `defaultValue` | `JSONContent` or `string` | The default value to use for the editor. | [`defaultEditorContent`](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/default-content.tsx) | -| `extensions` | `Extension[]` | A list of extensions to use for the editor, in addition to the [default Novel extensions](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/extensions/index.tsx). | `[]` | -| `editorProps` | `EditorProps` | Props to pass to the underlying Tiptap editor, in addition to the [default Novel editor props](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/props.ts). | `{}` | -| `onUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated. | `() => {}` | -| `onDebouncedUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated, but only after the defined debounce duration. | `() => {}` | -| `debounceDuration` | `number` | The duration (in milliseconds) to debounce the `onDebouncedUpdate` callback. | `750` | -| `storageKey` | `string` | The key to use for storing the editor's value in local storage. | `novel__content` | -| `disableLocalStorage` | `boolean` | Enabling this option will prevent read/write content from/to local storage. | `false` | - -> **Note**: Make sure to define an API endpoint that matches the `completionApi` prop (default is `/api/generate`). This is needed for the AI autocompletions to work. Here's an example: https://github.com/yesmore/inke/blob/main/apps/web/app/api/generate/route.ts - -## Deploy Your Own - -You can deploy your own version of Novel to Vercel with one click: - -[![Deploy with Vercel](https://vercel.com/button)]() - ## Tech Stack Inke is built on the following stack: @@ -90,6 +93,10 @@ Inke is built on the following stack: [![Star History Chart](https://api.star-history.com/svg?repos=yesmore/inke&type=Date)](https://star-history.com/#yesmore/inke&Date) + + Product Hunt + + ## License [Apache-2.0](./LICENSE) © [yesmore](https://github.com/yesmore) diff --git a/apps/web/.env.example b/apps/web/.env.example index a0a4414..65fdf8e 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -4,6 +4,7 @@ # Get your OpenAI API key here: https://platform.openai.com/account/api-keys OPENAI_API_KEY= +OPENAI_API_KEYs= # OPTIONAL: Vercel Blob (for uploading images) # Get your Vercel Blob credentials here: https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart @@ -13,3 +14,20 @@ BLOB_READ_WRITE_TOKEN= # Get your Vercel KV credentials here: https://vercel.com/docs/storage/vercel-kv/quickstart#quickstart KV_REST_API_URL= KV_REST_API_TOKEN= + +# Only for production – generate one here: https://generate-secret.vercel.app/32 +NEXTAUTH_SECRET= + +## Only required for localhost +NEXTAUTH_URL=http://localhost:3000 + +DATABASE_URL= + +EMAIL_FROM= +EMAIL_SERVER= + +GITHUB_ID= +GITHUB_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 0000000..b7425b9 --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true \ No newline at end of file diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b97545a --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,106 @@ +import NextAuth, { NextAuthOptions, Theme } from "next-auth"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import prisma from "@/lib/db/prisma"; +import EmailProvider from "next-auth/providers/email"; +import GithubProvider from "next-auth/providers/github"; +import GoogleProvider from "next-auth/providers/google"; +import { createTransport } from "nodemailer"; + +export const authOptions: NextAuthOptions = { + adapter: PrismaAdapter(prisma), + providers: [ + GithubProvider({ + clientId: process.env.GITHUB_ID as string, + clientSecret: process.env.GITHUB_SECRET as string, + }), + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + }), + EmailProvider({ + server: process.env.EMAIL_SERVER, + from: process.env.EMAIL_FROM, + maxAge: 10 * 60, // 10min, 邮箱链接失效时间,默认24小时 + async sendVerificationRequest({ + identifier: email, + url, + provider, + theme, + }) { + const { host } = new URL(url); + const transport = createTransport(provider.server); + const result = await transport.sendMail({ + to: email, + from: provider.from, + subject: `You are logging in to Inke`, + text: text({ url, host }), + html: html({ url, host, theme }), + }); + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length) { + throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`); + } + }, + }), + ], +}; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; + +/** + *使用HTML body 代替正文内容 + */ +function html(params: { url: string; host: string; theme: Theme }) { + const { url, host, theme } = params; + + const escapedHost = host.replace(/\./g, "​."); + + const brandColor = theme.brandColor || "#346df1"; + const color = { + background: "#f9f9f9", + text: "#444", + mainBackground: "#fff", + buttonBackground: brandColor, + buttonBorder: brandColor, + buttonText: theme.buttonText || "#fff", + }; + + return ` + + + + + + + + + + + +
+ Welcome to Inke 🎉 +
+ + + + +
Sign + in now
+
+ Button click without response? Try open this link in your browser. If you did not request this email you can safely ignore it. +
+ + `; +} + +/** 不支持HTML 的邮件客户端会显示下面的文本信息 */ +function text({ url, host }: { url: string; host: string }) { + return `Welcome to Inke! This is a magic link, click on it to log in ${url}\n`; +} diff --git a/apps/web/app/api/collaboration/count/route.ts b/apps/web/app/api/collaboration/count/route.ts new file mode 100644 index 0000000..acdee9f --- /dev/null +++ b/apps/web/app/api/collaboration/count/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { findCollaborationInviteCount } from "@/lib/db/collaboration"; + +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id || id === "undefined") { + return NextResponse.json({ + code: 403, + msg: "Empty roomId", + data: null, + }); + } + + const res = await findCollaborationInviteCount(id); + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Not joined the collaboration space", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/collaboration/id/route.ts b/apps/web/app/api/collaboration/id/route.ts new file mode 100644 index 0000000..f50363b --- /dev/null +++ b/apps/web/app/api/collaboration/id/route.ts @@ -0,0 +1,45 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../../auth/[...nextauth]/route"; +import { getUserByEmail } from "@/lib/db/user"; +import { + findCollaborationByDBId, + findCollaborationInviteCount, +} from "@/lib/db/collaboration"; + +// /invite/:id 邀请页调用,查询此邀请详细信息,不需要登录,点击“加入协作”后才需要鉴权 +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) { + return NextResponse.json({ + code: 403, + msg: "Empty roomId", + data: null, + }); + } + + const res = await findCollaborationByDBId(id); + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Not joined the collaboration space", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/collaboration/local-id/route.ts b/apps/web/app/api/collaboration/local-id/route.ts new file mode 100644 index 0000000..68fac3d --- /dev/null +++ b/apps/web/app/api/collaboration/local-id/route.ts @@ -0,0 +1,65 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../../auth/[...nextauth]/route"; +import { getUserByEmail } from "@/lib/db/user"; +import { + findCollaborationByDBId, + findCollaborationBylocalId, +} from "@/lib/db/collaboration"; + +// /invite/:id 邀请页调用,查询此邀请详细信息,不需要登录,点击“加入协作”后才需要鉴权 +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "Unauthorized! Please login", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Something wrong", + data: null, + }); + } + + const { searchParams } = new URL(req.url); + const id = searchParams.get("localId"); + if (!id) { + return NextResponse.json({ + code: 403, + msg: "Empty id", + data: null, + }); + } + + const res = await findCollaborationBylocalId(id, user.id); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Not joined collaboration", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/collaboration/room/route.ts b/apps/web/app/api/collaboration/room/route.ts new file mode 100644 index 0000000..397c89b --- /dev/null +++ b/apps/web/app/api/collaboration/room/route.ts @@ -0,0 +1,107 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../../auth/[...nextauth]/route"; +import { getUserByEmail } from "@/lib/db/user"; +import { findCollaborationByRoomId } from "@/lib/db/collaboration"; + +// post页面获取详情时调用,需要用户id查询是否已经加入,加入才开启协作模式 +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "Unauthorized! Please login first", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Not found user", + data: null, + }); + } + + const { searchParams } = new URL(req.url); + const id = searchParams.get("roomId"); + if (!id) { + return NextResponse.json({ + code: 403, + msg: "Empty roomId", + data: null, + }); + } + + // 查询用户是否已经加入了这个协作(已经创建了这个room记录) + const res = await findCollaborationByRoomId(id, user.id); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "You haven't joined this collaboration yet", + data: null, + }); + } catch (error) { + return NextResponse.json({ + code: 500, + msg: error, + data: null, + }); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { roomId } = await req.json(); + + if (!roomId) { + return NextResponse.json({ + code: 403, + msg: "Empty roomId", + data: null, + }); + } + + const res = await findCollaborationByRoomId(roomId); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Not found", + data: null, + }); + } catch (error) { + return NextResponse.json({ + code: 500, + msg: error, + data: null, + }); + } +} + +// fix error: "DYNAMIC_SERVER_USAGE" +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/collaboration/route.ts b/apps/web/app/api/collaboration/route.ts new file mode 100644 index 0000000..cf7b0e6 --- /dev/null +++ b/apps/web/app/api/collaboration/route.ts @@ -0,0 +1,180 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../auth/[...nextauth]/route"; +import { getUserByEmail } from "@/lib/db/user"; +import { + createCollaboration, + deleteCollaborationNote, + findCollaborationByRoomId, + findUserCollaborations, +} from "@/lib/db/collaboration"; + +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "Unauthorized! Please login", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Something wrong", + data: null, + }); + } + + const res = await findUserCollaborations(user.id); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Not joined collaboration", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "Unauthorized! Please login", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Something wrong", + data: null, + }); + } + + const { localId, roomId, title } = await req.json(); + if (!localId || !roomId) { + return NextResponse.json({ + code: 405, + msg: "Empty params", + data: null, + }); + } + + // 判断用户是否已经加入此协作 + const find_res = await findCollaborationByRoomId(roomId, user.id); + + if (find_res) { + return NextResponse.json({ + code: 301, + msg: "Joined! Redirecting...", + data: find_res, + }); + } + + // TODO: 限制新建个数 + // 新建 + const res = await createCollaboration(user.id, localId, roomId, title); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successfully!Redirecting...", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json({ + code: 500, + msg: error, + data: null, + }); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "Unauthorized! Please login", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Something wrong", + data: null, + }); + } + + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) { + return NextResponse.json({ + code: 403, + msg: "Empty id", + data: null, + }); + } + + const res = await deleteCollaborationNote(id); + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +// fix error: "DYNAMIC_SERVER_USAGE" +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/generate/bot/route.ts b/apps/web/app/api/generate/bot/route.ts new file mode 100644 index 0000000..b1366b0 --- /dev/null +++ b/apps/web/app/api/generate/bot/route.ts @@ -0,0 +1,109 @@ +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { kv } from "@vercel/kv"; +import { Ratelimit } from "@upstash/ratelimit"; +import { getRandomElement } from "@/lib/utils"; +import { Account_Plans } from "../../../../lib/consts"; + +const api_key = process.env.OPENAI_API_KEY || ""; +const api_keys = process.env.OPENAI_API_KEYs || ""; + +const openai = new OpenAI({ + baseURL: process.env.OPENAI_API_PROXY || "https://api.openai.com", +}); + +// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime +export const runtime = "edge"; + +export async function POST(req: Request): Promise { + try { + // Check if the OPENAI_API_KEY is set, if not return 400 + if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { + return new Response( + "Missing OPENAI_API_KEY – make sure to add it to your .env file.", + { + status: 400, + }, + ); + } + + const { prompt, plan, messages, system } = await req.json(); + + const planN = Number(plan || "5"); + + if ( + messages && + messages.length > Account_Plans[planN].ai_bot_history_length + ) { + return new Response("You have reached the history message limit.", { + status: 429, + }); + } + + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ip = req.headers.get("x-forwarded-for"); + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow( + Account_Plans[planN].ai_generate_day, + "1 d", + ), + }); + + // console.log("plan", planN, Account_Plans[planN], ip); + + const { success, limit, reset, remaining } = await ratelimit.limit( + `novel_ratelimit_${ip}`, + ); + + if (!success) { + return new Response( + "You have reached your request limit for the day.", + { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }, + ); + } + } + + openai.apiKey = getRandomElement(api_keys.split(",")) || api_key; + + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo-16k", + messages: [ + { + role: "system", + content: + "As a note assistant, communicate with users based on the input note content.", + // "Do not reply to questions unrelated to the notes. If there are questions unrelated to the notes, please reply 'Please ask questions related to the notes'", + }, + { + role: "system", + content: `Note content: \n${system}`, + }, + ...messages, + ], + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + stream: true, + n: 1, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); + } catch (error) { + return new Response(`Server error\n${error}`, { + status: 500, + }); + } +} diff --git a/apps/web/app/api/generate/continue/route.ts b/apps/web/app/api/generate/continue/route.ts new file mode 100644 index 0000000..af8740e --- /dev/null +++ b/apps/web/app/api/generate/continue/route.ts @@ -0,0 +1,99 @@ +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { kv } from "@vercel/kv"; +import { Ratelimit } from "@upstash/ratelimit"; +import { getRandomElement } from "@/lib/utils"; +import { Account_Plans } from "../../../../lib/consts"; + +const api_key = process.env.OPENAI_API_KEY || ""; +const api_keys = process.env.OPENAI_API_KEYs || ""; + +const openai = new OpenAI(); + +// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime +export const runtime = "edge"; + +export async function POST(req: Request): Promise { + try { + // Check if the OPENAI_API_KEY is set, if not return 400 + if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { + return new Response( + "Missing OPENAI_API_KEY – make sure to add it to your .env file.", + { + status: 400, + }, + ); + } + + const { prompt, plan } = await req.json(); + + const planN = Number(plan || "5"); + + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ip = req.headers.get("x-forwarded-for"); + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow( + Account_Plans[planN].ai_generate_day, + "1 d", + ), + }); + + // console.log("plan", planN, Account_Plans[planN], ip); + + const { success, limit, reset, remaining } = await ratelimit.limit( + `novel_ratelimit_${ip}`, + ); + + if (!success) { + return new Response( + "You have reached your request limit for the day.", + { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }, + ); + } + } + + openai.apiKey = getRandomElement(api_keys.split(",")) || api_key; + + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo-16k", + messages: [ + { + role: "system", + content: + "You are an AI writing assistant that continues existing text based on context from prior text." + + "Give more weight/priority to the later characters than the beginning ones. " + + `Limit your response to no more than ${Account_Plans[planN].ai_generate_chars} characters, but make sure to construct complete sentences.`, + // "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + stream: true, + n: 1, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); + } catch (error) { + return new Response("Server error", { + status: 500, + }); + } +} diff --git a/apps/web/app/api/generate/edit/route.ts b/apps/web/app/api/generate/edit/route.ts new file mode 100644 index 0000000..af7979e --- /dev/null +++ b/apps/web/app/api/generate/edit/route.ts @@ -0,0 +1,101 @@ +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { kv } from "@vercel/kv"; +import { Ratelimit } from "@upstash/ratelimit"; +import { getRandomElement } from "@/lib/utils"; +import { Account_Plans } from "../../../../lib/consts"; + +const api_key = process.env.OPENAI_API_KEY || ""; +const api_keys = process.env.OPENAI_API_KEYs || ""; + +const openai = new OpenAI(); + +// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime +export const runtime = "edge"; + +export async function POST(req: Request): Promise { + try { + // Check if the OPENAI_API_KEY is set, if not return 400 + if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { + return new Response( + "Missing OPENAI_API_KEY – make sure to add it to your .env file.", + { + status: 400, + }, + ); + } + + const { prompt, plan } = await req.json(); + + const planN = Number(plan || "5"); + + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ip = req.headers.get("x-forwarded-for"); + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow( + Account_Plans[planN].ai_generate_day, + "1 d", + ), + }); + + // console.log("plan", planN, Account_Plans[planN], ip); + + const { success, limit, reset, remaining } = await ratelimit.limit( + `novel_ratelimit_${ip}`, + ); + + if (!success) { + return new Response( + "You have reached your request limit for the day.", + { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }, + ); + } + } + + openai.apiKey = getRandomElement(api_keys.split(",")) || api_key; + + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo-16k", + messages: [ + { + role: "system", + content: `I hope you can take on roles such as spell proofreading and rhetorical improvement, + or other roles related to text editing, optimization, and abbreviation. I will + communicate with you in any language, and you will recognize the language. Please only answer the corrected and improved parts, and + do not write explanations. + Limit your response to no more than ${Account_Plans[planN].ai_generate_chars} characters, + but make sure to construct complete sentences.`, + // "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + stream: true, + n: 1, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); + } catch (error) { + return new Response("Server error", { + status: 500, + }); + } +} diff --git a/apps/web/app/api/generate/route.ts b/apps/web/app/api/generate/route.ts deleted file mode 100644 index 400ca3b..0000000 --- a/apps/web/app/api/generate/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import OpenAI from "openai"; -import { OpenAIStream, StreamingTextResponse } from "ai"; -import { kv } from "@vercel/kv"; -import { Ratelimit } from "@upstash/ratelimit"; - -// Create an OpenAI API client (that's edge friendly!) -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY || "", -}); - -// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime -export const runtime = "edge"; - -export async function POST(req: Request): Promise { - // Check if the OPENAI_API_KEY is set, if not return 400 - if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { - return new Response( - "Missing OPENAI_API_KEY – make sure to add it to your .env file.", - { - status: 400, - }, - ); - } - if ( - process.env.NODE_ENV != "development" && - process.env.KV_REST_API_URL && - process.env.KV_REST_API_TOKEN - ) { - const ip = req.headers.get("x-forwarded-for"); - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(50, "1 d"), - }); - - const { success, limit, reset, remaining } = await ratelimit.limit( - `novel_ratelimit_${ip}`, - ); - - if (!success) { - return new Response("You have reached your request limit for the day.", { - status: 429, - headers: { - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), - }, - }); - } - } - - let { prompt } = await req.json(); - - const response = await openai.chat.completions.create({ - model: "gpt-3.5-turbo", - messages: [ - { - role: "system", - content: - "You are an AI writing assistant that continues existing text based on context from prior text. " + - "Give more weight/priority to the later characters than the beginning ones. " + - "Limit your response to no more than 200 characters, but make sure to construct complete sentences.", - }, - { - role: "user", - content: prompt, - }, - ], - temperature: 0.7, - top_p: 1, - frequency_penalty: 0, - presence_penalty: 0, - stream: true, - n: 1, - }); - - // Convert the response into a friendly text-stream - const stream = OpenAIStream(response); - - // Respond with the stream - return new StreamingTextResponse(stream); -} diff --git a/apps/web/app/api/generate/translate/route.ts b/apps/web/app/api/generate/translate/route.ts new file mode 100644 index 0000000..f3a054b --- /dev/null +++ b/apps/web/app/api/generate/translate/route.ts @@ -0,0 +1,96 @@ +import OpenAI from "openai"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import { kv } from "@vercel/kv"; +import { Ratelimit } from "@upstash/ratelimit"; +import { getRandomElement } from "@/lib/utils"; +import { Account_Plans } from "../../../../lib/consts"; + +const api_key = process.env.OPENAI_API_KEY || ""; +const api_keys = process.env.OPENAI_API_KEYs || ""; + +const openai = new OpenAI(); + +// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime +export const runtime = "edge"; + +export async function POST(req: Request): Promise { + try { + // Check if the OPENAI_API_KEY is set, if not return 400 + if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { + return new Response( + "Missing OPENAI_API_KEY – make sure to add it to your .env file.", + { + status: 400, + }, + ); + } + + const { prompt, plan } = await req.json(); + + const planN = Number(plan || "5"); + + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ip = req.headers.get("x-forwarded-for"); + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow( + Account_Plans[planN].ai_generate_day, + "1 d", + ), + }); + + // console.log("plan", planN, Account_Plans[planN], ip); + + const { success, limit, reset, remaining } = await ratelimit.limit( + `novel_ratelimit_${ip}`, + ); + + if (!success) { + return new Response( + "You have reached your request limit for the day.", + { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }, + ); + } + } + + openai.apiKey = getRandomElement(api_keys.split(",")) || api_key; + + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo-16k", + messages: [ + { + role: "system", + content: + "I hope you can play the role of translator and spell proofreader. I will communicate with you in any language, and you will recognize the language and translate it to answer me.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + stream: true, + n: 1, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); + } catch (error) { + return new Response("Server error", { + status: 500, + }); + } +} diff --git a/apps/web/app/api/share/all/route.ts b/apps/web/app/api/share/all/route.ts new file mode 100644 index 0000000..314858d --- /dev/null +++ b/apps/web/app/api/share/all/route.ts @@ -0,0 +1,51 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../../auth/[...nextauth]/route"; +import { getUserByEmail } from "@/lib/db/user"; +import { findUserShares } from "@/lib/db/share"; + +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "UnAuth", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Something wrong", + data: null, + }); + } + + const res = await findUserShares(user.id); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts new file mode 100644 index 0000000..8db1cf9 --- /dev/null +++ b/apps/web/app/api/share/route.ts @@ -0,0 +1,168 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "../auth/[...nextauth]/route"; +import { getUserByEmail } from "@/lib/db/user"; +import { + createShareNote, + deleteShareNote, + findShareByLocalId, + findUserSharesCount, + updateShareClick, + updateShareKeeps, + updateShareNote, +} from "@/lib/db/share"; +import { Account_Plans } from "@/lib/consts"; + +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) { + return NextResponse.json({ + code: 403, + msg: "Empty id", + data: null, + }); + } + + const res = await findShareByLocalId(id); + + if (res) { + await updateShareClick(res.id, res.click); // 数据库id + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ + code: 401, + msg: "Unauthorized! Please login", + data: null, + }); + } + + const user = await getUserByEmail(session.user.email); + + if (!user) { + return NextResponse.json({ + code: 403, + msg: "Something wrong", + data: null, + }); + } + + const { data } = await req.json(); + if (!data) { + return NextResponse.json({ + code: 405, + msg: "Empty data", + data: null, + }); + } + + const find_share_count = await findUserSharesCount(user.id); + + if ( + find_share_count >= Account_Plans[Number(user.plan)].note_upload_count + ) { + return NextResponse.json({ + code: 429, + msg: "You have exceeded the maximum number of uploads, please upgrade your plan.", + data: null, + }); + } + + // 必需要用户ID + const find_res = await findShareByLocalId(data.id, user.id); + + if (find_res) { + const update_res = await updateShareNote(data, find_res.id); + return NextResponse.json({ + code: 200, + msg: "Updated!", + data: update_res, + }); + } + + const res = await createShareNote(data, user.id); + + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json({ + code: 500, + msg: error, + data: null, + }); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + if (!id) { + return NextResponse.json({ + code: 403, + msg: "Empty id", + data: null, + }); + } + + const res = await deleteShareNote(id); + if (res) { + return NextResponse.json({ + code: 200, + msg: "Successed!", + data: res, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json(error); + } +} + +// fix error: "DYNAMIC_SERVER_USAGE" +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/share/update/keep/route.ts b/apps/web/app/api/share/update/keep/route.ts new file mode 100644 index 0000000..1d60136 --- /dev/null +++ b/apps/web/app/api/share/update/keep/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { findShareByDBId, updateShareKeeps } from "@/lib/db/share"; + +export async function POST( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { id } = await req.json(); + if (!id) { + return NextResponse.json({ + code: 405, + msg: "Empty id", + data: null, + }); + } + + const find_res = await findShareByDBId(id); + + if (find_res) { + const res = await updateShareKeeps(id, find_res.keeps); + + return NextResponse.json({ + code: 200, + msg: "success", + data: null, + }); + } + + return NextResponse.json({ + code: 404, + msg: "Something wrong", + data: null, + }); + } catch (error) { + return NextResponse.json({ + code: 500, + msg: error, + data: null, + }); + } +} + +// fix error: "DYNAMIC_SERVER_USAGE" +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/api/status/route.ts b/apps/web/app/api/status/route.ts new file mode 100644 index 0000000..0a3da87 --- /dev/null +++ b/apps/web/app/api/status/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + subject: "website", + status: "live", + color: "green", + }); +} diff --git a/apps/web/app/api/upload/route.ts b/apps/web/app/api/upload/route.ts index 4926b53..60b81c4 100644 --- a/apps/web/app/api/upload/route.ts +++ b/apps/web/app/api/upload/route.ts @@ -1,17 +1,46 @@ -import { put } from "@vercel/blob"; +import { Account_Plans } from "@/lib/consts"; import { NextResponse } from "next/server"; +// import { getServerSession } from "next-auth"; +// import { authOptions } from "../auth/[...nextauth]/route"; +// import { getUserByEmail } from "@/lib/db/user"; +import COS from "cos-nodejs-sdk-v5"; +import { Readable } from "stream"; -export const runtime = "edge"; +const uploadFile = (stream: Readable, filename: string): Promise => { + return new Promise((resolve, reject) => { + const params = { + Bucket: "gcloud-1303456836", + Region: "ap-chengdu", + Key: "inke/" + filename, + Body: stream, + }; + + cos.putObject(params, (err: any, data: any) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); +}; + +export const runtime = "nodejs"; + +var cos = new COS({ + SecretId: process.env.TencentSecretID || "", + SecretKey: process.env.TencentSecretKey || "", +}); export async function POST(req: Request) { - if (!process.env.BLOB_READ_WRITE_TOKEN) { - return new Response( - "Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", - { - status: 401, - }, - ); - } + // if (!process.env.BLOB_READ_WRITE_TOKEN) { + // return new Response( + // "Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", + // { + // status: 401, + // }, + // ); + // } const file = req.body || ""; const filename = req.headers.get("x-vercel-filename") || "file.txt"; @@ -22,10 +51,25 @@ export async function POST(req: Request) { const finalName = filename.includes(fileType) ? filename : `${filename}${fileType}`; - const blob = await put(finalName, file, { - contentType, - access: "public", - }); - return NextResponse.json(blob); + const fileStream = Readable.from(file as any); + + const res = await uploadFile(fileStream, finalName); + // console.log("上传结果", res, res.Location); + + if (res && res.statusCode === 200) { + return NextResponse.json({ url: "https://" + res.Location }); + } + + // if ( + // blob && + // Number(blob.size) > Account_Plans[plan].image_upload_size * 1024 * 1024 + // ) { + // return new Response( + // "You have exceeded the maximum size of uploads, please upgrade your plan.", + // { + // status: 429, + // }, + // ); + // } } diff --git a/apps/web/app/api/users/route.ts b/apps/web/app/api/users/route.ts new file mode 100644 index 0000000..1f87db6 --- /dev/null +++ b/apps/web/app/api/users/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUserByEmail, getUserById, updateUserName } from "@/lib/db/user"; + +export async function GET( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { searchParams } = new URL(req.url); + const email = searchParams.get("email"); + const id = searchParams.get("id"); + + if (email) { + const user = await getUserByEmail(email); + return NextResponse.json(user); + } else if (id && id !== "undefined") { + const user = await getUserById(id); + return NextResponse.json(user); + } + + return NextResponse.json("empty params"); + } catch (error) { + return NextResponse.json(error); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Record }, +) { + try { + const { userId, userName } = await req.json(); + if (!userId || !userName) { + return NextResponse.json("empty content"); + } + + const res = await updateUserName(userId, userName); + + if (res) { + return NextResponse.json(res); + } + + return NextResponse.json("something wrong"); + } catch { + return NextResponse.json("error"); + } +} + +// fix error: "DYNAMIC_SERVER_USAGE" +export const dynamic = "force-dynamic"; diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..30d4440 --- /dev/null +++ b/apps/web/app/error.tsx @@ -0,0 +1,53 @@ +"use client"; // Error components must be Client Components + +// import { Note_Storage_Key } from "@/lib/consts"; +import { useEffect } from "react"; +import Image from "next/image"; +import Link from "next/link"; + +export default function Error({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+ 404 +

Oops, Something went wrong!

+ + + + + + + + + + {/* */} +
+ ); +} diff --git a/apps/web/app/features/card.tsx b/apps/web/app/features/card.tsx new file mode 100644 index 0000000..2e4ea18 --- /dev/null +++ b/apps/web/app/features/card.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from "react"; + +export function CardItem({ + bgColor = "bg-yellow-400", + rotate = "rotate-12", + icon, +}: { + bgColor?: string; + rotate?: string; + icon: ReactNode; +}) { + return ( + <> +
+ {icon} +
+ + ); +} diff --git a/apps/web/app/features/guide.tsx b/apps/web/app/features/guide.tsx new file mode 100644 index 0000000..71ef90e --- /dev/null +++ b/apps/web/app/features/guide.tsx @@ -0,0 +1,305 @@ +"use client"; + +import NewPostButton from "@/ui/new-post-button"; +import Image from "next/image"; +import Link from "next/link"; +import { Session } from "next-auth"; +import { TypeAnimation } from "react-type-animation"; +import Checked from "@/ui/shared/icons/checked"; +import { Account_Plans } from "@/lib/consts"; +import { CardItem } from "./card"; +import { motion } from "framer-motion"; + +export function Welcome() { + return ( +
+

+ + Lightweight + {" "} + .
+ AI Powered .
+ + Markdown + +

+ +

+ +

+ + +
+ ); +} + +export function Landing({ session }: { session: Session | null }) { + return ( + <> +
+
+ {"example"} +
+

+ Rich editing components +

+

+ 📖 Integrate rich text, Markdown, and final render with JSON. +

+
+
+ +
+ {"example"} +
+

+ AI empowering writing +

+

+ 🎉 Continue writing, editing, translation, chat with AI, all in + one. +

+
+
+ +
+ {"example"} +
+

+ Online Collaboration +

+

+ 👨‍👩‍👦 One click to start real-time online collaboration among + multiple people. +

+
+
+ +
+ {"example"} +
+

+ Export & Theme +

+

+ 🍥 One click simple export of PDF, images, Markdown, Json files +

+
+
+ +

PLAN

+ +
+
+
+

Free

+
+ + ${Account_Plans[0].pay} + +
+
    +
  • + + Unlimited number of local notes +
  • +
  • + + {Account_Plans[0].note_upload_count} notes upload to Cloud +
  • +
  • + + AI generates {Account_Plans[0].ai_generate_day} times per day +
  • +
  • + + AI generates up to {Account_Plans[0].ai_generate_chars}{" "} + characters per time +
  • +
  • + + Less than {Account_Plans[0].image_upload_size}MB for upload + image per time +
  • +
+
+
+ +
+
+ +
+
+ Beta for free +
+
+

Basic

+
+

${Account_Plans[1].pay}

+
+
    +
  • + + Unlimited number of local notes +
  • +
  • + + Unlimited number of Cloud notes +
  • +
  • + + AI generates {Account_Plans[1].ai_generate_day} times per day +
  • +
  • + + AI generates up to {Account_Plans[1].ai_generate_chars}{" "} + characters per time +
  • +
  • + + Less than {Account_Plans[1].image_upload_size}MB for upload + image per time +
  • +
  • + + All subsequent features will be used for free +
  • +
+
+
+ + + +
+
+ +
+
+

Pro

+
+

${Account_Plans[2].pay}

+
+
    +
  • + + Unlimited number of local notes +
  • +
  • + + Unlimited number of Cloud notes +
  • +
  • + + AI generates {Account_Plans[2].ai_generate_day} times per day +
  • +
  • + + AI generates up to {Account_Plans[2].ai_generate_chars}{" "} + characters per time +
  • +
  • + + Less than {Account_Plans[2].image_upload_size}MB for upload + image per time +
  • +
  • + + All subsequent features will be used for free +
  • +
+
+
+ +
+
+
+
+ +
+ + + + +
+ + + + ); +} diff --git a/apps/web/app/feedback/layout.tsx b/apps/web/app/feedback/layout.tsx new file mode 100644 index 0000000..872f493 --- /dev/null +++ b/apps/web/app/feedback/layout.tsx @@ -0,0 +1,27 @@ +import Providers from "@/app/providers"; +import { siteConfig } from "@/config/site"; +import "@/styles/globals.css"; +import { Metadata } from "next"; +import { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Feedback | Inke", + description: siteConfig.description, + keywords: siteConfig.keywords, + authors: siteConfig.authors, + creator: siteConfig.creator, + themeColor: siteConfig.themeColor, + icons: siteConfig.icons, + metadataBase: siteConfig.metadataBase, + openGraph: siteConfig.openGraph, + twitter: siteConfig.twitter, + manifest: siteConfig.manifest, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + <> + {children} + + ); +} diff --git a/apps/web/app/feedback/page.tsx b/apps/web/app/feedback/page.tsx new file mode 100644 index 0000000..3e16420 --- /dev/null +++ b/apps/web/app/feedback/page.tsx @@ -0,0 +1,19 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import Nav from "@/ui/layout/nav"; +import Wrapper from "./wrapper"; +import Footer from "@/ui/layout/footer"; + +export default async function Page() { + const session = await getServerSession(authOptions); + return ( + <> +
+ {/* @ts-expect-error Server Component */} +
+ + ); +} diff --git a/apps/web/app/feedback/wrapper.tsx b/apps/web/app/feedback/wrapper.tsx new file mode 100644 index 0000000..d82c8bc --- /dev/null +++ b/apps/web/app/feedback/wrapper.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Session } from "next-auth"; +import Giscus from "@giscus/react"; + +export default function Wrapper({ session }: { session: Session | null }) { + return ( + <> +
+ +
+ + ); +} diff --git a/apps/web/app/google-analytics.tsx b/apps/web/app/google-analytics.tsx new file mode 100644 index 0000000..fbf1719 --- /dev/null +++ b/apps/web/app/google-analytics.tsx @@ -0,0 +1,28 @@ +"use client"; +import Script from "next/script"; +const GoogleAnalytics = () => { + return ( + <> +