├── .eslintrc.json
├── .gitignore
├── README.md
├── app
├── api
│ ├── llm
│ │ ├── ragBotService.js
│ │ ├── ragChain.js
│ │ └── saveNoteToVectorStore.js
│ └── note
│ │ └── actions.js
├── components
│ ├── EditButton.js
│ ├── Note.js
│ ├── NoteEditor.js
│ ├── NoteListSkeleton.js
│ ├── NotePreview.js
│ ├── Sidebar.js
│ ├── SidebarFilterNoteItems.js
│ ├── SidebarNoteItemContent.js
│ ├── SidebarNoteItemHeader.js
│ ├── SidebarNoteList.js
│ ├── SidebarSearchField.js
│ ├── Spinner.js
│ └── Uploader.js
├── globals.css
├── layout.js
├── lib
│ ├── redis.js
│ └── utils.js
├── note
│ ├── [id]
│ │ ├── loading.js
│ │ └── page.js
│ ├── chat
│ │ ├── page.js
│ │ └── styles.module.css
│ └── edit
│ │ ├── [id]
│ │ └── page.js
│ │ ├── loading.js
│ │ └── page.js
├── page.js
└── style.css
├── asset
├── demo.gif
└── explain.png
├── jsconfig.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── checkmark.svg
├── chevron-down.svg
├── chevron-up.svg
├── cross.svg
├── db
│ └── placeholder
│ │ ├── docstore.json
│ │ └── faiss.index
├── favicon.ico
├── gemma2Icon.png
├── llmEntryIcon.png
├── logo.svg
└── next.svg
└── tailwind.config.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.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.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 |
32 | # env files (can opt-in for commiting if needed)
33 | .env*
34 |
35 | # vercel
36 | .vercel
37 |
38 | # typescript
39 | *.tsbuildinfo
40 | next-env.d.ts
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 简介
2 |
3 | Hi👋🏻~,本项目基于 [Next.js](https://nextjs.org) 实现了一个简单的笔记管理系统,能够上传、编辑与预览、删除笔记🗒️。
4 |
5 | 同时,项目还基于 [LangChain.js](https://js.langchain.com/docs/introduction/) 实现了一个 RAG(检索增强生成)问答机器人🤖,能够基于现有的笔记文档,如公司的内部文档、需求文档等,进行聊天对话。
6 |
7 | ## 演示
8 | ### 功能概览
9 | 
10 |
11 | ### 演示分解
12 | 
13 |
14 | 如果提问针对的是一些专业性较强的内容甚至是公司的内部文档,由于大语言模型的训练数据集中这方面的知识占比少,模型较大概率会生成一些低质量的回复,这也是 [RAG](https://js.langchain.com/v0.2/docs/tutorials/rag/) 提出的主要原因。例如演示中:
15 | - step1 当允许大模型不回答置信度不高的问题时,提问`什么是孔乙己?`,模型并没有给出明确的答复;
16 | - step2 新建并上传了与`孔乙己`相关的文档信息;
17 | - step3 在拥有额外的知识库后,对于同样的问题,模型给出了正确且关联度高的高质量回复。
18 |
19 | ## 本地运行
20 | ### 环境配置
21 | - 安装项目依赖,如在根目录下执行`npm i`。
22 | - 启动本地大模型,项目使用 [Ollama](https://ollama.com/) 运行谷歌的开源本地大模型 [gemma2](https://blog.google/technology/developers/google-gemma-2/)。安装`Ollama`后,命令行运行`ollama run gemma2`即可。
23 | - ps:`gemma2`经过测试,正常运行会占用显卡运存`9GB`左右。可以参考 [这里](https://github.com/ollama/ollama) 更换其他更轻量的模型,或直接使用OpenAI提供的接口。
24 | - 启动本地 Redis。
25 |
26 | ### 启动项目
27 | 根目录下执行`npm run dev`,在本地开发环境下启动项目。项目运行在`http://localhost:3000`。
--------------------------------------------------------------------------------
/app/api/llm/ragBotService.js:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { getRagChain } from "@/api/llm/ragChain"
4 | import { createStreamableValue } from "ai/rsc"
5 | import redis from "@/lib/redis"
6 |
7 | const ragChainMap = new Map() // 模拟线程池 - 单例模式
8 |
9 | export async function initRagChatBot(sessionId) {
10 | if (!ragChainMap.has(sessionId)) {
11 | const ragChain = await getRagChain({ sessionId })
12 | ragChainMap.set(sessionId, ragChain)
13 | redis.set(sessionId, "")
14 | }
15 | }
16 |
17 | export async function executeRagBotTool(sessionId, query) {
18 | const ragChain = ragChainMap.get(sessionId)
19 | if (!ragChain) throw new Error("RagBot is not initialized for this session.")
20 |
21 | const stream = createStreamableValue()
22 |
23 | const run = async () => {
24 | const output = await ragChain.stream({ question: query })
25 | for await (const chunk of output) {
26 | stream.update(chunk)
27 | }
28 | stream.done()
29 | }
30 | run()
31 |
32 | return { streamData: stream.value }
33 | }
34 |
35 | export async function removeRagBot(sessionId) {
36 | ragChainMap.delete(sessionId)
37 | redis.del(sessionId)
38 | }
--------------------------------------------------------------------------------
/app/api/llm/ragChain.js:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import "dotenv/config"
4 | import 'faiss-node'
5 | import { FaissStore } from "@langchain/community/vectorstores/faiss"
6 | import { Ollama, OllamaEmbeddings } from "@langchain/ollama"
7 | import { ChatPromptTemplate } from "@langchain/core/prompts"
8 | import { RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables"
9 | import { StringOutputParser } from "@langchain/core/output_parsers"
10 | import { getBufferString } from "@langchain/core/messages"
11 | import { ChatMessageHistory } from "langchain/stores/message/in_memory"
12 | import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression"
13 | import { LLMChainExtractor } from "langchain/retrievers/document_compressors/chain_extract"
14 | import { ScoreThresholdRetriever } from "langchain/retrievers/score_threshold"
15 | import { join } from "path"
16 | import { promises as fs } from 'fs'
17 | import redis from "@/lib/redis"
18 |
19 | async function loadVectorStore() {
20 | const embeddings = new OllamaEmbeddings({
21 | model: "gemma2",
22 | baseUrl: "http://localhost:11434"
23 | })
24 | const rootDirPath = join(process.cwd(), 'public', 'db')
25 | const dirEntries = await fs.readdir(rootDirPath, { withFileTypes: true })
26 | const subDirPaths = dirEntries
27 | .filter(item => item.isDirectory())
28 | .map(item => join(rootDirPath, item.name))
29 | const vectorStores = await Promise.all(
30 | subDirPaths.map(async (directory) => {
31 | return await FaissStore.load(directory, embeddings)
32 | })
33 | )
34 | // if (!vectorStores || !vectorStores.length) return null // todo: 处理空向量库 | update: db中存入了一个占位符
35 | const res = vectorStores[0]
36 | for (let i = 1; i < vectorStores.length; i++)
37 | res.mergeFrom(vectorStores[i])
38 | return res
39 | }
40 |
41 | async function getSummaryChain() {
42 | const summaryPrompt = ChatPromptTemplate.fromTemplate(`
43 | 总结提供的新对话,并结合先前的摘要,总结出一个新的摘要。
44 | 注意摘要的简洁性,总结时可以只保留关键信息。
45 |
46 | 先前的摘要:
47 | {summary}
48 |
49 | 新对话:
50 | {new_lines}
51 |
52 | 新的摘要:
53 | `)
54 |
55 | const summaryChain = RunnableSequence.from([
56 | summaryPrompt,
57 | new Ollama({
58 | baseUrl: "http://localhost:11434",
59 | model: "gemma2",
60 | }),
61 | new StringOutputParser(),
62 | ])
63 | return summaryChain
64 | }
65 |
66 | async function getRephraseChain() {
67 | const rephraseChainPrompt = ChatPromptTemplate.fromTemplate(`
68 | 你会收到一段历史对话总结和一个后续问题,你的任务是根据历史对话将后续问题转述成一个描述更加具体和清晰的新提问。
69 | 注意:如果没有收到对话历史或者你认为后续问题的描述已经足够清晰,直接使用后续问题。
70 |
71 | 例子
72 | 对话历史:human表示他叫小明,他今年18岁。AI热情地表示很高兴认识他,并询问有什么可以帮助他的?
73 | 后续问题:我今年多少岁了?
74 | 重述后的问题:小明今年多少岁?
75 | 例子结束
76 |
77 | 对话历史:{history_summary}
78 | 后续问题:{question}
79 | 重述后的问题:
80 | `)
81 |
82 | const rephraseChain = RunnableSequence.from([
83 | rephraseChainPrompt,
84 | new Ollama({
85 | baseUrl: "http://localhost:11434",
86 | model: "gemma2",
87 | temperature: 0.4,
88 | }),
89 | new StringOutputParser(),
90 | ])
91 |
92 | return rephraseChain
93 | }
94 |
95 | export async function getRagChain({ sessionId = '' }) {
96 | // 读取向量数据并设置检索策略
97 | const vectorStore = await loadVectorStore()
98 | // const retriever = vectorStore.asRetriever(2)
99 | const retriever = new ContextualCompressionRetriever({
100 | baseCompressor: LLMChainExtractor.fromLLM(new Ollama({
101 | baseUrl: "http://localhost:11434",
102 | model: "gemma2",
103 | })),
104 | baseRetriever: ScoreThresholdRetriever.fromVectorStore(vectorStore, {
105 | minSimilarityScore: 0.15, // todo
106 | maxK: 5,
107 | kIncrement: 1
108 | })
109 | })
110 |
111 | const convertDocsToString = (documents) => {
112 | return documents.map(document => document.pageContent).join("\n")
113 | }
114 | const contextRetrieverChain = RunnableSequence.from([
115 | (input) => input.new_question,
116 | retriever,
117 | convertDocsToString,
118 | ])
119 |
120 | // 配置 RAG 链
121 | const SYSTEM_TEMPLATE = `
122 | 作为一个专业的知识问答助手,你需要尽可能回答用户问题。在回答时可以参考以下信息,综合考虑后做出回答。
123 | 当然,如果你对当前提问感到疑惑,也可以回答“我不确定”,并直接给出自己的建议。
124 |
125 | 以下是与提问相关的文档内容:
126 | {context}
127 |
128 | 以下是聊天摘要:
129 | {history_summary}
130 |
131 | `
132 |
133 | const prompt = ChatPromptTemplate.fromMessages([
134 | ["system", SYSTEM_TEMPLATE],
135 | ["human", "现在,你需要参考以上信息,回答以下问题:\n{new_question}`"],
136 | ])
137 |
138 | const history = new ChatMessageHistory()
139 | const summaryChain = await getSummaryChain()
140 | const rephraseChain = await getRephraseChain()
141 | const model = new Ollama({
142 | baseUrl: "http://localhost:11434",
143 | model: "gemma2",
144 | // verbose: true
145 | })
146 |
147 | const ragChain = RunnableSequence.from([
148 | {
149 | input: new RunnablePassthrough({
150 | func: async (input) => {
151 | history.addUserMessage(input.question)
152 | }
153 | }),
154 | question: (input) => input.question,
155 | history_summary: () => redis.get(sessionId),
156 | },
157 | RunnablePassthrough.assign({
158 | new_question: rephraseChain,
159 | }),
160 | RunnablePassthrough.assign({
161 | context: contextRetrieverChain,
162 | }),
163 | prompt,
164 | model,
165 | new StringOutputParser(),
166 | new RunnablePassthrough({
167 | func: async (input) => {
168 | history.addAIMessage(input)
169 | const messages = await history.getMessages()
170 | const new_lines = getBufferString(messages)
171 | const newSummary = await summaryChain.invoke({
172 | summary: redis.get(sessionId),
173 | new_lines
174 | })
175 | redis.set(sessionId, newSummary)
176 | history.clear()
177 | }
178 | })
179 | ])
180 |
181 | return ragChain
182 | }
183 |
184 | // current
185 | // let executeRagBotTool = async () => null
186 |
187 | // export async function initRagChatBot() {
188 | // const ragChain = await getRagChain() // todo: 单例模式
189 |
190 | // executeRagBotTool = async (query) => {
191 | // const stream = createStreamableValue()
192 |
193 | // const run = async () => {
194 | // const output = await ragChain.stream({ question: query }) // todo: sessionID - redis - 存聊天记录
195 | // for await (const chunk of output) {
196 | // stream.update(chunk)
197 | // }
198 | // stream.done()
199 | // }
200 | // run()
201 |
202 | // return { streamData: stream.value }
203 | // }
204 | // }
205 |
206 | // export { executeRagBotTool }
--------------------------------------------------------------------------------
/app/api/llm/saveNoteToVectorStore.js:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import "dotenv/config"
4 | import 'faiss-node'
5 | import { Document } from 'langchain/document'
6 | import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"
7 | import { FaissStore } from "@langchain/community/vectorstores/faiss"
8 | import { OllamaEmbeddings } from "@langchain/ollama"
9 | import { join } from "path"
10 | import dayjs from 'dayjs'
11 |
12 | export const saveNoteToVectorStore = async (noteInfo) => {
13 | if (!noteInfo.content) return
14 | const docs = [
15 | new Document({
16 | pageContent: noteInfo.content,
17 | metadata: {
18 | title: noteInfo.title,
19 | uid: noteInfo.uid,
20 | updateTime: dayjs(noteInfo.updateTime).format('YYYY-MM-DD HH:mm:ss')
21 | }
22 | })
23 | ]
24 |
25 | const splitter = new RecursiveCharacterTextSplitter({
26 | chunkSize: 100, // todo: 动态调整
27 | chunkOverlap: 10
28 | })
29 |
30 | const splitDocs = await splitter.splitDocuments(docs)
31 |
32 | const embeddings = new OllamaEmbeddings({
33 | model: "gemma2",
34 | baseUrl: "http://localhost:11434"
35 | })
36 | const vectorStore = await FaissStore.fromDocuments(splitDocs, embeddings)
37 |
38 | await vectorStore.save(join(process.cwd(), 'public', 'db', `${noteInfo.uid}`))
39 | }
--------------------------------------------------------------------------------
/app/api/note/actions.js:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { saveNoteToVectorStore } from "@/api/llm/saveNoteToVectorStore"
4 | import { redirect } from 'next/navigation'
5 | import { addNote, updateNote, delNote } from '@/lib/redis'
6 | import { z } from "zod"
7 | import { rm } from 'fs/promises'
8 | import { join } from "path"
9 |
10 | const schema = z.object({
11 | title: z.string().max(200, 'The title should be less than 200 characters.'),
12 | content: z.string()
13 | })
14 |
15 | export async function saveNote(formData) {
16 | const data = {
17 | title: formData.get('title'),
18 | content: formData.get('body'),
19 | updateTime: new Date()
20 | }
21 | // 校验数据
22 | const validated = schema.safeParse(data)
23 | if (!validated.success) {
24 | return {
25 | success: false,
26 | message: validated.error.issues[0].message
27 | }
28 | }
29 |
30 | let noteId = formData.get('noteId')
31 | const sData = JSON.stringify(data)
32 | if (noteId) {
33 | await updateNote(noteId, sData)
34 | } else {
35 | noteId = await addNote(sData)
36 | }
37 | // 保存到向量数据库
38 | await saveNoteToVectorStore({
39 | ...data,
40 | uid: noteId
41 | })
42 | redirect(`/note/${noteId}`)
43 | }
44 |
45 | export async function deleteNote(formData) {
46 | const noteId = formData.get('noteId')
47 | const flag = await delNote(noteId)
48 | if (flag) {
49 | try {
50 | // 删除向量数据库记录的内容
51 | await rm(join(process.cwd(), 'public', 'db', noteId), { recursive: true, force: true })
52 | } catch (error) {
53 | console.error(`Error deleting vector store with ID ${noteId}:`, error)
54 | }
55 | redirect('/')
56 | } else {
57 | console.error("Delete failed.")
58 | }
59 | }
60 |
61 | export async function importNote(file) {
62 | if (!file) return new Error("File is required.")
63 |
64 | try {
65 | const buffer = Buffer.from(await file.arrayBuffer())
66 | const filename = file.name.replace(/\.[^/.]+$/, "")
67 | // 写入数据库
68 | const data = {
69 | title: filename,
70 | content: buffer.toString('utf-8'),
71 | updateTime: new Date()
72 | }
73 | const res = await addNote(JSON.stringify(data))
74 | await saveNoteToVectorStore({
75 | ...data,
76 | uid: res
77 | })
78 | return { uid: res }
79 | } catch (e) {
80 | console.error(e)
81 | return new Error("Upload failed.")
82 | }
83 | }
--------------------------------------------------------------------------------
/app/components/EditButton.js:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 |
3 | export default function EditButton({ noteId, children }) {
4 | const isDraft = !noteId
5 | return (
6 |
7 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/Note.js:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import NotePreview from '@/components/NotePreview'
3 | import EditButton from '@/components/EditButton'
4 | export default function Note({ noteId, note }) {
5 | const { title, content, updateTime } = note
6 |
7 | return (
8 |
9 |
10 |
{title}
11 |
12 |
13 | Last updated on {dayjs(updateTime).format('YYYY-MM-DD hh:mm:ss')}
14 |
15 | Edit
16 |
17 |
18 |
{content}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/app/components/NoteEditor.js:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { useFormStatus } from 'react-dom'
5 | import { deleteNote, saveNote } from '@/api/note/actions'
6 | import NotePreview from '@/components/NotePreview'
7 |
8 | export default function NoteEditor({
9 | noteId,
10 | initialTitle,
11 | initialBody
12 | }) {
13 | const { pending } = useFormStatus()
14 | const [title, setTitle] = useState(initialTitle)
15 | const [body, setBody] = useState(initialBody)
16 |
17 | const isEdit = !!noteId
18 |
19 | const save = async (formData) => {
20 | const res = await saveNote(formData)
21 | if (!res.success) alert(res.message)
22 | }
23 |
24 | return (
25 |
26 |
89 |
90 |
91 | Preview
92 |
93 |
{title}
94 |
{body}
95 |
96 |
97 | )
98 | }
99 |
100 |
--------------------------------------------------------------------------------
/app/components/NoteListSkeleton.js:
--------------------------------------------------------------------------------
1 | export default function NoteListSkeleton() {
2 | return (
3 |
4 |
5 | -
6 |
10 |
11 | -
12 |
16 |
17 | -
18 |
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/app/components/NotePreview.js:
--------------------------------------------------------------------------------
1 | import { marked } from 'marked' // markdown 转换为 HTML
2 | import sanitizeHtml from 'sanitize-html' // 清理 HTML, 只保留白名单的标签及其对应属性, 防止潜在的威胁, 如 XSS 攻击
3 |
4 | const allowedTags = sanitizeHtml.defaults.allowedTags.concat([
5 | 'img',
6 | 'h1',
7 | 'h2',
8 | 'h3'
9 | ])
10 | const allowedAttributes = Object.assign(
11 | {},
12 | sanitizeHtml.defaults.allowedAttributes,
13 | {
14 | img: ['alt', 'src']
15 | }
16 | )
17 |
18 | export default function NotePreview({ children }) {
19 | return (
20 |
31 | )
32 | }
33 |
34 |
--------------------------------------------------------------------------------
/app/components/Sidebar.js:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react'
2 | import Link from 'next/link'
3 | import SidebarNoteList from '@/components/SidebarNoteList'
4 | import EditButton from '@/components/EditButton'
5 | import NoteListSkeleton from '@/components/NoteListSkeleton'
6 | import SidebarSearchField from '@/components/SidebarSearchField'
7 | import Uploader from '@/components/Uploader'
8 |
9 | export default async function Sidebar() {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
23 | Next Notes
24 |
25 |
26 |
30 |
31 |
32 |
39 |
40 |
41 |
46 |
49 |
50 | >
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/app/components/SidebarFilterNoteItems.js:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useSearchParams } from 'next/navigation'
4 | import SidebarNoteItemContent from '@/components/SidebarNoteItemContent'
5 |
6 | export default function SidebarFilterNoteItems({ notes }) {
7 | const searchParams = useSearchParams()
8 | const searchText = searchParams.get('q')
9 | return (
10 |
11 | {notes.map(noteItem => {
12 | const { noteId, note, header } = noteItem
13 | const { title = '', content = '' } = note
14 | if (!searchText || (searchText && title.toLowerCase().includes(searchText.toLowerCase()))) {
15 | return (
16 | -
17 |
23 | {content.substring(0, 20) || No content}
24 |
25 | }>
26 | {header}
27 |
28 |
29 | )
30 | }
31 | return null
32 | })}
33 |
34 | )
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/app/components/SidebarNoteItemContent.js:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useRef, useEffect, useTransition } from 'react'
4 | import { useRouter, usePathname, useSearchParams } from 'next/navigation'
5 |
6 | export default function SidebarNoteItemContent({
7 | id,
8 | title,
9 | children,
10 | expandedChildren,
11 | }) {
12 | const router = useRouter()
13 | const pathname = usePathname()
14 | const searchParams = useSearchParams()
15 | const selectedId = pathname?.split('/')[2] || null
16 |
17 | const [isPending] = useTransition()
18 | const [isExpanded, setIsExpanded] = useState(false)
19 | const isActive = id === selectedId
20 |
21 | // Animate after title is edited.
22 | const itemRef = useRef(null)
23 | const prevTitleRef = useRef(title)
24 |
25 | useEffect(() => {
26 | if (title !== prevTitleRef.current) {
27 | prevTitleRef.current = title
28 | itemRef.current.classList.add('flash')
29 | }
30 | }, [title])
31 |
32 | return (
33 | {
36 | itemRef.current.classList.remove('flash')
37 | }}
38 | className={[
39 | 'sidebar-note-list-item',
40 | isExpanded ? 'note-expanded' : '',
41 | ].join(' ')}>
42 | {children}
43 |
64 |
76 | {isExpanded && expandedChildren}
77 |
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/app/components/SidebarNoteItemHeader.js:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs' // [tip] 服务端渲染,这意味着 day.js 的代码并不会被打包到客户端的 bundle 中
2 |
3 | export default function SidebarNoteItemHeader({ title, updateTime }) {
4 | return (
5 |
6 | {title}
7 | {dayjs(updateTime).format('YYYY-MM-DD hh:mm:ss')}
8 |
9 | )
10 | }
--------------------------------------------------------------------------------
/app/components/SidebarNoteList.js:
--------------------------------------------------------------------------------
1 | import SidebarFilterNoteItems from '@/components/SidebarFilterNoteItems'
2 | import SidebarNoteItemHeader from '@/components/SidebarNoteItemHeader'
3 | import { getAllNotes } from '@/lib/redis'
4 |
5 | export default async function SidebarNoteList() {
6 | const noteArr = Object.entries(await getAllNotes())
7 |
8 | return !!noteArr.length && (
9 | {
11 | const noteData = JSON.parse(note)
12 | return {
13 | noteId,
14 | note: noteData,
15 | header:
16 | }
17 | })
18 | } />
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/app/components/SidebarSearchField.js:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { usePathname, useRouter } from 'next/navigation'
4 | import { useState, useEffect, useTransition } from 'react'
5 | import Spinner from '@/components/Spinner'
6 |
7 | export default function SidebarSearchField() {
8 | const { replace } = useRouter()
9 | const pathname = usePathname()
10 | const [searchText, setSearchText] = useState('')
11 | const [isPending, startTransition] = useTransition()
12 |
13 | useEffect(() => {
14 | if (pathname === '/' || pathname.includes('edit')) setSearchText('')
15 | }, [pathname])
16 |
17 |
18 | function handleSearch(term) {
19 | const params = new URLSearchParams(window.location.search)
20 | if (term) params.set('q', term)
21 | else params.delete('q')
22 |
23 | setSearchText(term)
24 | startTransition(() => {
25 | replace(`${pathname}?${params.toString()}`)
26 | })
27 | }
28 |
29 | return (
30 |
31 |
34 | handleSearch(e.target.value)}
40 | />
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/app/components/Spinner.js:
--------------------------------------------------------------------------------
1 | export default function Spinner({ active = true }) {
2 | return (
3 |
8 | )
9 | }
--------------------------------------------------------------------------------
/app/components/Uploader.js:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRef, useState } from 'react'
4 | import { useRouter } from 'next/navigation'
5 | import { importNote } from '@/api/note/actions'
6 | import Spinner from '@/components/Spinner'
7 |
8 | export default function SidebarImport() {
9 | const router = useRouter()
10 | const formRef = useRef(null)
11 | const [loading, setLoading] = useState(false)
12 |
13 | const upload = async (e) => {
14 | const fileInput = e.target
15 | if (!fileInput.files || fileInput.files.length === 0) {
16 | console.warn("files list is empty")
17 | return
18 | }
19 |
20 | const file = fileInput.files[0]
21 | setLoading(true)
22 | try {
23 | const data = await importNote(file)
24 | router.push(`/note/${data.uid}`)
25 | } catch (error) {
26 | alert('Failed to upload file.')
27 | console.error(error || "upload error")
28 | } finally {
29 | setLoading(false)
30 | }
31 |
32 | // 重置 file input
33 | formRef.current?.reset()
34 | }
35 |
36 | return (
37 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/app/layout.js:
--------------------------------------------------------------------------------
1 | import './style.css'
2 | import Sidebar from '@/components/Sidebar'
3 |
4 | export const metadata = {
5 | title: "next notes",
6 | description: "A practice based on nextjs",
7 | }
8 |
9 | export default async function RootLayout({
10 | children
11 | }) {
12 |
13 | return (
14 |
15 |
16 |
22 |
23 |
24 | )
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/app/lib/redis.js:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis'
2 |
3 | const redis = new Redis()
4 |
5 | export async function getAllNotes() {
6 | const data = await redis.hgetall("notes")
7 | if (Object.keys(data).length == 0) return {}
8 | return await redis.hgetall("notes")
9 | }
10 |
11 | export async function addNote(data) {
12 | const uuid = Date.now().toString()
13 | await redis.hset("notes", [uuid], data)
14 | return uuid
15 | }
16 |
17 | export async function updateNote(uuid, data) {
18 | await redis.hset("notes", [uuid], data)
19 | }
20 |
21 | export async function getNote(uuid) {
22 | return await redis.hget("notes", uuid)
23 | }
24 |
25 | export async function delNote(uuid) {
26 | return redis.hdel("notes", uuid)
27 | }
28 |
29 | export default redis
30 |
--------------------------------------------------------------------------------
/app/lib/utils.js:
--------------------------------------------------------------------------------
1 | export const sleep = ms => new Promise(r => setTimeout(r, ms))
--------------------------------------------------------------------------------
/app/note/[id]/loading.js:
--------------------------------------------------------------------------------
1 | export default function NoteSkeleton() {
2 | return (
3 |
8 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/app/note/[id]/page.js:
--------------------------------------------------------------------------------
1 | import DefaultPage from '@/page'
2 | import Note from '@/components/Note'
3 | import { getNote } from '@/lib/redis'
4 | import { sleep } from '@/lib/utils'
5 |
6 | export default async function Page({ params }) {
7 | // 动态路由 获取笔记 id
8 | const { id: noteId } = await params
9 | const note = await getNote(noteId)
10 |
11 | // 防止加载速度过快,骨架屏导致的页面闪烁
12 | // await sleep(200)
13 |
14 | return note ? :
15 | }
--------------------------------------------------------------------------------
/app/note/chat/page.js:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useEffect, useRef, useState } from "react"
4 | import ReactMarkdown from "react-markdown"
5 | import { readStreamableValue } from "ai/rsc"
6 | import { initRagChatBot, executeRagBotTool, removeRagBot } from "@/api/llm/ragBotService"
7 | import { v4 as uuidv4 } from 'uuid'
8 | import styles from "./styles.module.css"
9 | import Spinner from '@/components/Spinner'
10 |
11 | export default function Page() {
12 | const [emptyText, setEmptyText] = useState("")
13 | const [input, setInput] = useState("")
14 | const [chatHistory, setChatHistory] = useState([])
15 | const [curMsg, setCurMsg] = useState("")
16 | const [isLoading, setIsLoading] = useState(false)
17 | const [isReady, setIsReady] = useState(false)
18 | const scrollRef = useRef(null)
19 | const sessionId = useRef(uuidv4())
20 |
21 | useEffect(() => {
22 | let cleanup
23 | (async () => {
24 | setEmptyText("😊")
25 | cleanup = typeEmptyText()
26 | await initRagChatBot(sessionId.current)
27 | setIsReady(true)
28 | })()
29 | return () => {
30 | removeRagBot(sessionId.current)
31 | cleanup()
32 | }
33 | }, [])
34 |
35 | useEffect(() => {
36 | if (scrollRef.current) {
37 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight
38 | }
39 | }, [chatHistory])
40 |
41 | const typeEmptyText = () => {
42 | let index = -1, timeoutId
43 | const typingSpeed = 100
44 | const emptyMsg = "有什么可以帮忙的?".split('')
45 |
46 | const type = () => {
47 | ++index
48 | setEmptyText((prev) => prev + emptyMsg[index])
49 | if (index < emptyMsg.length - 1) {
50 | timeoutId = setTimeout(type, typingSpeed)
51 | }
52 | }
53 | type()
54 |
55 | return () => clearTimeout(timeoutId)
56 | }
57 |
58 | const handleSubmit = async (e) => {
59 | e.preventDefault()
60 | if (!input || !isReady || isLoading) return
61 |
62 | let msg = ''
63 | setIsLoading(true)
64 | setChatHistory(prev => [...prev, input])
65 | const { streamData } = await executeRagBotTool(sessionId.current, input)
66 | setInput("")
67 | for await (const chunk of readStreamableValue(streamData)) {
68 | msg += chunk
69 | setCurMsg(msg)
70 | }
71 | setChatHistory(prev => [...prev, msg])
72 | setIsLoading(false)
73 | setCurMsg("")
74 | }
75 |
76 | return (
77 |
78 |
79 |
80 | {chatHistory.map((item, idx) => {
81 | return (
82 |
83 | {idx % 2 ? (
84 | <>
85 |

86 |
{item}
87 | >
88 | ) : (
89 |
{item}
90 | )}
91 |
92 | )
93 | })}
94 | {!!curMsg.length &&
95 |

96 |
{curMsg}
97 |
}
98 | {!chatHistory.length &&
{emptyText}}
99 |
100 |
115 |
116 |
117 | )
118 | }
--------------------------------------------------------------------------------
/app/note/chat/styles.module.css:
--------------------------------------------------------------------------------
1 | .outerContainer {
2 | justify-content: center;
3 | align-items: center;
4 | display: flex;
5 | background: var(--white);
6 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.1);
7 | border-radius: 8px;
8 | height: 95%;
9 | width: 95%;
10 | min-width: 400px;
11 | max-width: 80vw;
12 | padding: 8%;
13 | }
14 |
15 | .container {
16 | margin: 0 auto;
17 | width: 100%;
18 | max-width: 66rem;
19 | display: flex;
20 | flex-direction: column;
21 | gap: 2rem;
22 | }
23 |
24 | .aiIcon {
25 | float: left;
26 | position: absolute;
27 | top: -1rem;
28 | left: -3.5rem;
29 | width: 3rem;
30 | height: 3rem;
31 | border-radius: 50%;
32 | }
33 |
34 | .chatContainer {
35 | display: flex;
36 | flex-direction: column;
37 | gap: 2rem;
38 | padding: 3rem 3.5rem;
39 | height: 80vh;
40 | overflow-y: auto;
41 | }
42 |
43 | .chatContainer div {
44 | position: relative;
45 | display: flex;
46 | flex-direction: column;
47 | align-items: flex-start;
48 | word-wrap: break-word;
49 | background-color: var(--white);
50 | }
51 |
52 | .chatContainer div:nth-child(odd) {
53 | align-items: flex-end;
54 | text-align: left;
55 | }
56 | .chatContainer div .userMsg {
57 | max-width: 80%;
58 | padding: 16px;
59 | border-radius: 16px;
60 | background-color: var(--gray-100);
61 | }
62 |
63 | .chatContainer .emptyMsg {
64 | background-color: var(--gray-100);
65 | font-size: 3rem;
66 | font-weight: bolder;
67 | margin: auto;
68 | border-radius: 2rem;
69 | padding: 1rem 2rem;
70 | }
71 |
72 | .search {
73 | display: flex;
74 | flex-direction: column;
75 | gap: 0.5rem;
76 | position: relative;
77 | }
78 |
79 | .inputField {
80 | background-color: #f9fafb;
81 | border: 1px solid #d1d5db;
82 | color: #111827;
83 | border-radius: 0.5rem;
84 | width: 100%;
85 | height: 100px;
86 | padding: 0.5rem;
87 | resize: none;
88 | overflow-y: auto;
89 | }
90 |
91 | .search textarea {
92 | border: 1px solid var(--gray-90);
93 | outline-style: none;
94 | }
95 |
96 | .search textarea:focus {
97 | box-shadow: var(--outline-box-shadow);
98 | }
99 |
100 | .search .uploadBnt {
101 | margin: 0;
102 | padding: 0;
103 | width: 32px;
104 | height: 32px;
105 | border-radius: 50%;
106 | position: absolute;
107 | bottom: 0;
108 | right: 0.5rem;
109 | bottom: 0.3rem;
110 | border: 1px solid #d1d5db;
111 | background-color: var(--white);
112 | }
113 |
114 | .search .uploadBnt:hover {
115 | background: var(--secondary-blue);
116 | }
117 |
118 | .search .active {
119 | margin-top: 5px;
120 | }
--------------------------------------------------------------------------------
/app/note/edit/[id]/page.js:
--------------------------------------------------------------------------------
1 | import DefaultPage from '@/page'
2 | import NoteEditor from '@/components/NoteEditor'
3 | import { getNote } from '@/lib/redis'
4 | import { sleep } from '@/lib/utils'
5 |
6 | export default async function EditPage({ params }) {
7 |
8 | const { id: noteId } = await params
9 | const note = await getNote(noteId)
10 | const { title, content } = JSON.parse(note)
11 |
12 | // 让 Suspense 的效果更明显
13 | // await sleep(200)
14 |
15 | return note ? :
16 | }
--------------------------------------------------------------------------------
/app/note/edit/loading.js:
--------------------------------------------------------------------------------
1 | export default function EditSkeleton() {
2 | return (
3 |
8 |
12 |
13 |
23 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | )
37 | }
38 |
39 |
--------------------------------------------------------------------------------
/app/note/edit/page.js:
--------------------------------------------------------------------------------
1 | import NoteEditor from '@/components/NoteEditor'
2 |
3 | export default async function EditPage() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/app/page.js:
--------------------------------------------------------------------------------
1 | export default async function Page() {
2 | return (
3 |
4 |
5 |
6 | Click a note on the left to find something! 🥺
7 |
8 |
9 |
10 |
11 | Or simply chat with the note-enhanced AI~ 🤖
12 |
13 |
14 |
15 | )
16 | }
--------------------------------------------------------------------------------
/app/style.css:
--------------------------------------------------------------------------------
1 | /* -------------------------------- CSSRESET --------------------------------*/
2 | /* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */
3 | /* Box sizing rules */
4 | *,
5 | *::before,
6 | *::after {
7 | box-sizing: border-box;
8 | }
9 |
10 | /* Remove default padding */
11 | ul[class],
12 | ol[class] {
13 | padding: 0;
14 | }
15 |
16 | /* Remove default margin */
17 | body,
18 | h1,
19 | h2,
20 | h3,
21 | h4,
22 | p,
23 | ul[class],
24 | ol[class],
25 | li,
26 | figure,
27 | figcaption,
28 | blockquote,
29 | dl,
30 | dd {
31 | margin: 0;
32 | }
33 |
34 | /* Set core body defaults */
35 | body {
36 | min-height: 100vh;
37 | scroll-behavior: smooth;
38 | text-rendering: optimizeSpeed;
39 | line-height: 1.5;
40 | }
41 |
42 | /* Remove list styles on ul, ol elements with a class attribute */
43 | ul[class],
44 | ol[class] {
45 | list-style: none;
46 | }
47 |
48 | /* A elements that don't have a class get default styles */
49 | a:not([class]) {
50 | text-decoration-skip-ink: auto;
51 | }
52 |
53 | /* Make images easier to work with */
54 | img {
55 | max-width: 100%;
56 | display: block;
57 | }
58 |
59 | /* Natural flow and rhythm in articles by default */
60 | article>*+* {
61 | margin-block-start: 1em;
62 | }
63 |
64 | /* Inherit fonts for inputs and buttons */
65 | input,
66 | button,
67 | textarea,
68 | select {
69 | font: inherit;
70 | }
71 |
72 | /* Remove all animations and transitions for people that prefer not to see them */
73 | @media (prefers-reduced-motion: reduce) {
74 | * {
75 | animation-duration: 0.01ms !important;
76 | animation-iteration-count: 1 !important;
77 | transition-duration: 0.01ms !important;
78 | scroll-behavior: auto !important;
79 | }
80 | }
81 |
82 | /* -------------------------------- /CSSRESET --------------------------------*/
83 |
84 | :root {
85 | /* Colors */
86 | --main-border-color: #ddd;
87 | --primary-border: #037dba;
88 | --gray-20: #404346;
89 | --gray-60: #8a8d91;
90 | --gray-70: #bcc0c4;
91 | --gray-80: #c9ccd1;
92 | --gray-90: #e4e6eb;
93 | --gray-95: #f0f2f5;
94 | --gray-100: #f5f7fa;
95 | --primary-blue: #037dba;
96 | --secondary-blue: #0396df;
97 | --tertiary-blue: #c6efff;
98 | --flash-blue: #4cf7ff;
99 | --outline-blue: rgba(4, 164, 244, 0.6);
100 | --navy-blue: #035e8c;
101 | --red-25: #bd0d2a;
102 | --secondary-text: #65676b;
103 | --white: #fff;
104 | --yellow: #fffae1;
105 |
106 | --outline-box-shadow: 0 0 0 2px var(--outline-blue);
107 | --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue);
108 |
109 | /* Fonts */
110 | --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto,
111 | Ubuntu, Helvetica, sans-serif;
112 | --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,
113 | monospace;
114 | }
115 |
116 | html {
117 | font-size: 100%;
118 | }
119 |
120 | body {
121 | font-family: var(--sans-serif);
122 | background: var(--gray-100);
123 | font-weight: 400;
124 | line-height: 1.75;
125 | }
126 |
127 | h1,
128 | h2,
129 | h3,
130 | h4,
131 | h5 {
132 | margin: 0;
133 | font-weight: 700;
134 | line-height: 1.3;
135 | }
136 |
137 | h1 {
138 | font-size: 3.052rem;
139 | }
140 |
141 | h2 {
142 | font-size: 2.441rem;
143 | }
144 |
145 | h3 {
146 | font-size: 1.953rem;
147 | }
148 |
149 | h4 {
150 | font-size: 1.563rem;
151 | }
152 |
153 | h5 {
154 | font-size: 1.25rem;
155 | }
156 |
157 | small,
158 | .text_small {
159 | font-size: 0.8rem;
160 | }
161 |
162 | pre,
163 | code {
164 | font-family: var(--monospace);
165 | border-radius: 6px;
166 | }
167 |
168 | pre {
169 | background: var(--gray-95);
170 | padding: 12px;
171 | line-height: 1.5;
172 | }
173 |
174 | code {
175 | background: var(--yellow);
176 | padding: 0 3px;
177 | font-size: 0.94rem;
178 | word-break: break-word;
179 | }
180 |
181 | pre code {
182 | background: none;
183 | }
184 |
185 | a {
186 | color: var(--primary-blue);
187 | }
188 |
189 | .text-with-markdown h1,
190 | .text-with-markdown h2,
191 | .text-with-markdown h3,
192 | .text-with-markdown h4,
193 | .text-with-markdown h5 {
194 | margin-block: 2rem 0.7rem;
195 | margin-inline: 0;
196 | }
197 |
198 | .text-with-markdown blockquote {
199 | font-style: italic;
200 | color: var(--gray-20);
201 | border-left: 3px solid var(--gray-80);
202 | padding-left: 10px;
203 | }
204 |
205 | hr {
206 | border: 0;
207 | height: 0;
208 | border-top: 1px solid rgba(0, 0, 0, 0.1);
209 | border-bottom: 1px solid rgba(255, 255, 255, 0.3);
210 | }
211 |
212 | /* ---------------------------------------------------------------------------*/
213 | .main {
214 | display: flex;
215 | height: 100vh;
216 | width: 100%;
217 | overflow: hidden;
218 | }
219 |
220 | .col {
221 | height: 100%;
222 | }
223 |
224 | .col:last-child {
225 | flex-grow: 1;
226 | }
227 |
228 | .logo {
229 | height: 20px;
230 | width: 22px;
231 | margin-inline-end: 10px;
232 | }
233 |
234 | .edit-button {
235 | border-radius: 100px;
236 | letter-spacing: 0.12em;
237 | text-transform: uppercase;
238 | padding: 6px 20px 8px;
239 | cursor: pointer;
240 | font-weight: 700;
241 | outline-style: none;
242 | }
243 |
244 | .edit-button--solid {
245 | background: var(--primary-blue);
246 | color: var(--white);
247 | border: none;
248 | margin-inline-start: 6px;
249 | transition: all 0.2s ease-in-out;
250 | }
251 |
252 | .edit-button--solid:hover {
253 | background: var(--secondary-blue);
254 | }
255 |
256 | .edit-button--solid:focus {
257 | box-shadow: var(--outline-box-shadow-contrast);
258 | }
259 |
260 | .edit-button--outline {
261 | background: var(--white);
262 | color: var(--primary-blue);
263 | border: 1px solid var(--primary-blue);
264 | margin-inline-start: 12px;
265 | transition: all 0.1s ease-in-out;
266 | }
267 |
268 | .edit-button--outline:disabled {
269 | opacity: 0.5;
270 | }
271 |
272 | .edit-button--outline:hover:not([disabled]) {
273 | background: var(--primary-blue);
274 | color: var(--white);
275 | }
276 |
277 | .edit-button--outline:focus {
278 | box-shadow: var(--outline-box-shadow);
279 | }
280 |
281 | ul.notes-list {
282 | padding: 16px 0;
283 | }
284 |
285 | .notes-list>li {
286 | padding: 0 16px;
287 | }
288 |
289 | .notes-empty {
290 | padding: 16px;
291 | }
292 |
293 | .sidebar {
294 | background: var(--white);
295 | box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.1), 0px 2px 2px rgba(0, 0, 0, 0.1);
296 | overflow-y: scroll;
297 | z-index: 1000;
298 | flex-shrink: 0;
299 | max-width: 350px;
300 | min-width: 250px;
301 | width: 30%;
302 | }
303 |
304 | .sidebar-header {
305 | letter-spacing: 0.15em;
306 | text-transform: uppercase;
307 | padding: 36px 16px 16px;
308 | display: flex;
309 | align-items: center;
310 | }
311 |
312 | .sidebar-menu {
313 | padding: 0 16px 16px;
314 | display: flex;
315 | justify-content: space-between;
316 | }
317 |
318 | .sidebar-menu>.search {
319 | position: relative;
320 | flex-grow: 1;
321 | }
322 |
323 | .sidebar-note-list-item {
324 | position: relative;
325 | margin-bottom: 12px;
326 | padding: 16px;
327 | width: 100%;
328 | display: flex;
329 | justify-content: space-between;
330 | align-items: flex-start;
331 | flex-wrap: wrap;
332 | max-height: 100px;
333 | transition: max-height 250ms ease-out;
334 | transform: scale(1);
335 | }
336 |
337 | .sidebar-note-list-item.note-expanded {
338 | max-height: 300px;
339 | transition: max-height 0.5s ease;
340 | }
341 |
342 | .sidebar-note-list-item.flash {
343 | animation-name: flash;
344 | animation-duration: 0.6s;
345 | }
346 |
347 | .sidebar-note-open {
348 | position: absolute;
349 | top: 0;
350 | left: 0;
351 | right: 0;
352 | bottom: 0;
353 | width: 100%;
354 | z-index: 0;
355 | border: none;
356 | border-radius: 6px;
357 | text-align: start;
358 | background: var(--gray-95);
359 | cursor: pointer;
360 | outline-style: none;
361 | color: transparent;
362 | font-size: 0px;
363 | }
364 |
365 | .sidebar-note-open:focus {
366 | box-shadow: var(--outline-box-shadow);
367 | }
368 |
369 | .sidebar-note-open:hover {
370 | background: var(--gray-90);
371 | }
372 |
373 | .sidebar-note-header {
374 | z-index: 1;
375 | max-width: 85%;
376 | pointer-events: none;
377 | }
378 |
379 | .sidebar-note-header>strong {
380 | display: block;
381 | font-size: 1.25rem;
382 | line-height: 1.2;
383 | white-space: nowrap;
384 | overflow: hidden;
385 | text-overflow: ellipsis;
386 | }
387 |
388 | .sidebar-note-toggle-expand {
389 | z-index: 2;
390 | border-radius: 50%;
391 | height: 24px;
392 | border: 1px solid var(--gray-60);
393 | cursor: pointer;
394 | flex-shrink: 0;
395 | visibility: hidden;
396 | opacity: 0;
397 | cursor: default;
398 | transition: visibility 0s linear 20ms, opacity 300ms;
399 | outline-style: none;
400 | }
401 |
402 | .sidebar-note-toggle-expand:focus {
403 | box-shadow: var(--outline-box-shadow);
404 | }
405 |
406 | .sidebar-note-open:hover+.sidebar-note-toggle-expand,
407 | .sidebar-note-open:focus+.sidebar-note-toggle-expand,
408 | .sidebar-note-toggle-expand:hover,
409 | .sidebar-note-toggle-expand:focus {
410 | visibility: visible;
411 | opacity: 1;
412 | transition: visibility 0s linear 0s, opacity 300ms;
413 | }
414 |
415 | .sidebar-note-toggle-expand img {
416 | width: 10px;
417 | height: 10px;
418 | }
419 |
420 | .sidebar-note-excerpt {
421 | pointer-events: none;
422 | z-index: 2;
423 | flex: 1 1 250px;
424 | color: var(--secondary-text);
425 | position: relative;
426 | animation: slideIn 100ms;
427 | }
428 |
429 | .search input {
430 | padding: 0 16px;
431 | border-radius: 100px;
432 | border: 1px solid var(--gray-90);
433 | width: 100%;
434 | height: 100%;
435 | outline-style: none;
436 | }
437 |
438 | .search input:focus {
439 | box-shadow: var(--outline-box-shadow);
440 | }
441 |
442 | .search .spinner {
443 | position: absolute;
444 | right: 10px;
445 | top: 10px;
446 | }
447 |
448 | .note-viewer {
449 | display: flex;
450 | align-items: center;
451 | justify-content: center;
452 | }
453 |
454 | .note {
455 | background: var(--white);
456 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.1);
457 | border-radius: 8px;
458 | height: 95%;
459 | width: 95%;
460 | min-width: 400px;
461 | max-width: 80vw;
462 | padding: 8%;
463 | overflow-y: auto;
464 | overflow-wrap: anywhere;
465 | }
466 |
467 | .note--empty-state {
468 | margin-inline: 20px 20px;
469 | }
470 |
471 | .note-text--empty-state {
472 | font-size: 1.5rem;
473 | }
474 |
475 | .note-header {
476 | display: flex;
477 | justify-content: space-between;
478 | align-items: center;
479 | flex-wrap: wrap-reverse;
480 | margin-inline-start: -12px;
481 | }
482 |
483 | .note-menu {
484 | display: flex;
485 | justify-content: space-between;
486 | align-items: center;
487 | flex-grow: 1;
488 | }
489 |
490 | .note-title {
491 | line-height: 1.3;
492 | flex-grow: 1;
493 | overflow-wrap: break-word;
494 | margin-inline-start: 12px;
495 | overflow: auto;
496 | }
497 |
498 | .note-updated-at {
499 | color: var(--secondary-text);
500 | white-space: nowrap;
501 | margin-inline-start: 12px;
502 | }
503 |
504 | .note-preview {
505 | margin-block-start: 50px;
506 | }
507 |
508 | .note-editor {
509 | background: var(--white);
510 | display: flex;
511 | height: 100%;
512 | width: 100%;
513 | padding: 58px;
514 | overflow-y: auto;
515 | }
516 |
517 | .note-editor .label {
518 | margin-bottom: 20px;
519 | }
520 |
521 | .note-editor-form {
522 | display: flex;
523 | flex-direction: column;
524 | width: 400px;
525 | flex-shrink: 0;
526 | position: sticky;
527 | top: 0;
528 | }
529 |
530 | .note-editor-form input,
531 | .note-editor-form textarea {
532 | background: none;
533 | border: 1px solid var(--gray-70);
534 | border-radius: 2px;
535 | font-family: var(--monospace);
536 | font-size: 0.8rem;
537 | padding: 12px;
538 | outline-style: none;
539 | }
540 |
541 | .note-editor-form input:focus,
542 | .note-editor-form textarea:focus {
543 | box-shadow: var(--outline-box-shadow);
544 | }
545 |
546 | .note-editor-form input {
547 | height: 44px;
548 | margin-bottom: 16px;
549 | }
550 |
551 | .note-editor-form textarea {
552 | height: 100%;
553 | max-width: 400px;
554 | }
555 |
556 | .note-editor-menu {
557 | display: flex;
558 | justify-content: flex-end;
559 | align-items: center;
560 | margin-bottom: 12px;
561 | }
562 |
563 | .note-editor-preview {
564 | margin-inline-start: 40px;
565 | overflow-wrap: anywhere;
566 | width: 55vw;
567 | }
568 |
569 | .note-editor-done,
570 | .note-editor-delete {
571 | display: flex;
572 | justify-content: space-between;
573 | align-items: center;
574 | border-radius: 100px;
575 | letter-spacing: 0.12em;
576 | text-transform: uppercase;
577 | padding: 6px 20px 8px;
578 | cursor: pointer;
579 | font-weight: 700;
580 | margin-inline-start: 12px;
581 | outline-style: none;
582 | transition: all 0.2s ease-in-out;
583 | }
584 |
585 | .note-editor-done:disabled,
586 | .note-editor-delete:disabled {
587 | opacity: 0.5;
588 | }
589 |
590 | .note-editor-done {
591 | border: none;
592 | background: var(--primary-blue);
593 | color: var(--white);
594 | }
595 |
596 | .note-editor-done:focus {
597 | box-shadow: var(--outline-box-shadow-contrast);
598 | }
599 |
600 | .note-editor-done:hover:not([disabled]) {
601 | background: var(--secondary-blue);
602 | }
603 |
604 | .note-editor-delete {
605 | border: 1px solid var(--red-25);
606 | background: var(--white);
607 | color: var(--red-25);
608 | }
609 |
610 | .note-editor-delete:focus {
611 | box-shadow: var(--outline-box-shadow);
612 | }
613 |
614 | .note-editor-delete:hover:not([disabled]) {
615 | background: var(--red-25);
616 | color: var(--white);
617 | }
618 |
619 | /* Hack to color our svg */
620 | .note-editor-delete:hover:not([disabled]) img {
621 | filter: grayscale(1) invert(1) brightness(2);
622 | }
623 |
624 | .note-editor-done>img {
625 | width: 14px;
626 | }
627 |
628 | .note-editor-delete>img {
629 | width: 10px;
630 | }
631 |
632 | .note-editor-done>img,
633 | .note-editor-delete>img {
634 | margin-inline-end: 12px;
635 | }
636 |
637 | .note-editor-done[disabled],
638 | .note-editor-delete[disabled] {
639 | opacity: 0.5;
640 | }
641 |
642 | .label {
643 | display: inline-block;
644 | border-radius: 100px;
645 | letter-spacing: 0.05em;
646 | text-transform: uppercase;
647 | font-weight: 700;
648 | padding: 4px 14px;
649 | }
650 |
651 | .label--preview {
652 | background: rgba(38, 183, 255, 0.15);
653 | color: var(--primary-blue);
654 | }
655 |
656 | .text-with-markdown p {
657 | margin-bottom: 16px;
658 | }
659 |
660 | .text-with-markdown img {
661 | width: 100%;
662 | }
663 |
664 | /* https://codepen.io/mandelid/pen/vwKoe */
665 | .spinner {
666 | display: inline-block;
667 | transition: opacity linear 0.1s 0.2s;
668 | width: 20px;
669 | height: 20px;
670 | border: 3px solid rgba(80, 80, 80, 0.5);
671 | border-radius: 50%;
672 | border-top-color: #fff;
673 | animation: spin 1s ease-in-out infinite;
674 | opacity: 0;
675 | }
676 |
677 | .spinner--active {
678 | opacity: 1;
679 | }
680 |
681 | .skeleton::after {
682 | content: 'Loading...';
683 | }
684 |
685 | .skeleton {
686 | height: 100%;
687 | background-color: #eee;
688 | background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee);
689 | background-size: 200px 100%;
690 | background-repeat: no-repeat;
691 | border-radius: 4px;
692 | display: block;
693 | line-height: 1;
694 | width: 100%;
695 | animation: shimmer 1.2s ease-in-out infinite;
696 | color: transparent;
697 | }
698 |
699 | .skeleton:first-of-type {
700 | margin: 0;
701 | }
702 |
703 | .skeleton--button {
704 | border-radius: 100px;
705 | padding: 6px 20px 8px;
706 | width: auto;
707 | }
708 |
709 | .v-stack+.v-stack {
710 | margin-block-start: 0.8em;
711 | }
712 |
713 | .offscreen {
714 | border: 0;
715 | clip: rect(0, 0, 0, 0);
716 | height: 1px;
717 | margin: -1px;
718 | overflow: hidden;
719 | padding: 0;
720 | width: 1px;
721 | position: absolute;
722 | }
723 |
724 | /* ---------------------------------------------------------------------------*/
725 | @keyframes spin {
726 | to {
727 | transform: rotate(360deg);
728 | }
729 | }
730 |
731 | @keyframes spin {
732 | to {
733 | transform: rotate(360deg);
734 | }
735 | }
736 |
737 | @keyframes shimmer {
738 | 0% {
739 | background-position: -200px 0;
740 | }
741 |
742 | 100% {
743 | background-position: calc(200px + 100%) 0;
744 | }
745 | }
746 |
747 | @keyframes slideIn {
748 | 0% {
749 | top: -10px;
750 | opacity: 0;
751 | }
752 |
753 | 100% {
754 | top: 0;
755 | opacity: 1;
756 | }
757 | }
758 |
759 | @keyframes flash {
760 | 0% {
761 | transform: scale(1);
762 | opacity: 1;
763 | }
764 |
765 | 50% {
766 | transform: scale(1.05);
767 | opacity: 0.9;
768 | }
769 |
770 | 100% {
771 | transform: scale(1);
772 | opacity: 1;
773 | }
774 | }
--------------------------------------------------------------------------------
/asset/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tpxbps/nextnotes/855c6f12b182cb0a47cc3c0367e85e4689f1e315/asset/demo.gif
--------------------------------------------------------------------------------
/asset/explain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tpxbps/nextnotes/855c6f12b182cb0a47cc3c0367e85e4689f1e315/asset/explain.png
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./app/*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | logging: {
4 | fetches: {
5 | fullUrl: true
6 | }
7 | },
8 | webpack: (config, { isServer }) => {
9 | if (isServer) {
10 | config.externals.push('faiss-node')
11 | }
12 | return config
13 | },
14 | };
15 |
16 | export default nextConfig
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-app",
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 | "@langchain/community": "^0.3.15",
13 | "@langchain/core": "^0.3.18",
14 | "@langchain/ollama": "^0.1.2",
15 | "@langchain/openai": "^0.3.14",
16 | "ai": "^4.0.0",
17 | "dayjs": "^1.11.13",
18 | "dotenv": "^16.4.5",
19 | "faiss": "^1.0.0",
20 | "faiss-node": "^0.5.1",
21 | "ioredis": "^5.4.1",
22 | "langchain": "^0.3.6",
23 | "marked": "^15.0.0",
24 | "mime": "^4.0.4",
25 | "next": "15.0.2",
26 | "react": "^18.3.1",
27 | "react-dom": "^18.3.1",
28 | "react-markdown": "^9.0.1",
29 | "sanitize-html": "^2.13.1",
30 | "uuid": "^11.0.3",
31 | "zod": "^3.23.8"
32 | },
33 | "devDependencies": {
34 | "eslint": "^8",
35 | "eslint-config-next": "15.0.2",
36 | "postcss": "^8",
37 | "tailwindcss": "^3.4.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
--------------------------------------------------------------------------------
/public/checkmark.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/chevron-up.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/cross.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/db/placeholder/docstore.json:
--------------------------------------------------------------------------------
1 | [[["a74d1a90-f791-493f-9fe3-24ddfc7c4c1f",{"pageContent":"暂无内容","metadata":{"title":"","uid":"1732443582923","updateTime":"2024-11-24 18:19:42","loc":{"lines":{"from":1,"to":1}}}}]],{"0":"a74d1a90-f791-493f-9fe3-24ddfc7c4c1f"}]
--------------------------------------------------------------------------------
/public/db/placeholder/faiss.index:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tpxbps/nextnotes/855c6f12b182cb0a47cc3c0367e85e4689f1e315/public/db/placeholder/faiss.index
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tpxbps/nextnotes/855c6f12b182cb0a47cc3c0367e85e4689f1e315/public/favicon.ico
--------------------------------------------------------------------------------
/public/gemma2Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tpxbps/nextnotes/855c6f12b182cb0a47cc3c0367e85e4689f1e315/public/gemma2Icon.png
--------------------------------------------------------------------------------
/public/llmEntryIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tpxbps/nextnotes/855c6f12b182cb0a47cc3c0367e85e4689f1e315/public/llmEntryIcon.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | theme: {
9 | extend: {
10 | colors: {
11 | background: "var(--background)",
12 | foreground: "var(--foreground)",
13 | },
14 | },
15 | },
16 | plugins: [],
17 | };
18 |
--------------------------------------------------------------------------------