diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..ce5cb65 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index 8f62d6c..d9a126b 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,17 @@ "antd": "^5.13.3", "axios": "^1.6.7", "cheerio": "^1.0.0-rc.12", + "d3-dsv": "2", "dayjs": "^1.11.10", "html-to-text": "^9.0.5", "i18next": "^23.10.1", "i18next-browser-languagedetector": "^7.2.0", "langchain": "^0.1.28", "lucide-react": "^0.350.0", + "ml-distance": "^4.0.1", "pdfjs-dist": "^4.0.379", "property-information": "^6.4.1", + "pubsub-js": "^1.9.4", "react": "18.2.0", "react-dom": "18.2.0", "react-i18next": "^14.1.0", @@ -47,17 +50,21 @@ "rehype-mathjax": "4.0.3", "remark-gfm": "3.0.1", "remark-math": "5.1.1", + "turndown": "^7.1.3", "yt-transcript": "^0.0.2", "zustand": "^4.5.0" }, "devDependencies": { "@plasmohq/prettier-plugin-sort-imports": "4.0.1", "@types/chrome": "0.0.259", + "@types/d3-dsv": "^3.0.7", "@types/html-to-text": "^9.0.4", "@types/node": "20.11.9", + "@types/pubsub-js": "^1.8.6", "@types/react": "18.2.48", "@types/react-dom": "18.2.18", "@types/react-syntax-highlighter": "^15.5.11", + "@types/turndown": "^5.0.4", "autoprefixer": "^10.4.17", "postcss": "^8.4.33", "prettier": "3.2.4", @@ -69,4 +76,4 @@ "resolutions": { "@langchain/core": "0.1.45" } -} +} \ No newline at end of file diff --git a/src/assets/locale/en/common.json b/src/assets/locale/en/common.json index 4626817..41df538 100644 --- a/src/assets/locale/en/common.json +++ b/src/assets/locale/en/common.json @@ -48,5 +48,7 @@ "submit": "Submit", "noData": "No data", "noHistory": "No chat history", - "chatWithCurrentPage": "Chat with current page" + "chatWithCurrentPage": "Chat with current page", + "beta": "Beta", + "tts": "Read aloud" } \ No newline at end of file diff --git a/src/assets/locale/en/knowledge.json b/src/assets/locale/en/knowledge.json new file mode 100644 index 0000000..d6f1b63 --- /dev/null +++ b/src/assets/locale/en/knowledge.json @@ -0,0 +1,42 @@ +{ + "addBtn": "Add New Knowledge", + "columns": { + "title": "Title", + "status": "Status", + "embeddings": "Embedding Model", + "createdAt": "Created At", + "action": "Actions" + }, + "expandedColumns": { + "name": "Name" + }, + "tooltip": { + "delete": "Delete" + }, + "confirm": { + "delete": "Are you sure you want to delete this knowledge?" + }, + "deleteSuccess": "Knowledge deleted successfully", + "status": { + "pending": "Pending", + "finished": "Finished", + "processing": "Processing" + }, + "addKnowledge": "Add Knowledge", + "form": { + "title": { + "label": "Knowledge Title", + "placeholder": "Enter knowledge title", + "required": "Knowledge title is required" + }, + "uploadFile": { + "label": "Upload File", + "uploadText": "Drag and drop a file here or click to upload", + "uploadHint": "Supported file types: .pdf, .csv, .txt, .md", + "required": "File is required" + }, + "submit": "Submit", + "success": "Knowledge added successfully" + }, + "noEmbeddingModel": "Please add an embedding model first from the Ollama settings page" +} \ No newline at end of file diff --git a/src/assets/locale/en/playground.json b/src/assets/locale/en/playground.json index 6a91280..1d0794d 100644 --- a/src/assets/locale/en/playground.json +++ b/src/assets/locale/en/playground.json @@ -21,7 +21,8 @@ "searchInternet": "Search Internet", "speechToText": "Speech to Text", "uploadImage": "Upload Image", - "stopStreaming": "Stop Streaming" + "stopStreaming": "Stop Streaming", + "knowledge": "Knowledge" }, "sendWhenEnter": "Send when Enter pressed" } \ No newline at end of file diff --git a/src/assets/locale/en/settings.json b/src/assets/locale/en/settings.json index 2aa6083..e7e5fb3 100644 --- a/src/assets/locale/en/settings.json +++ b/src/assets/locale/en/settings.json @@ -51,6 +51,23 @@ "success": "Import Success", "error": "Import Error" } + }, + "tts": { + "heading": "Text-to-Speech Settings", + "ttsEnabled": { + "label": "Enable Text-to-Speech" + }, + "ttsProvider": { + "label": "Text-to-Speech Provider", + "placeholder": "Select a provider" + }, + "ttsVoice": { + "label": "Text-to-Speech Voice", + "placeholder": "Select a voice" + }, + "ssmlEnabled": { + "label": "Enable SSML (Speech Synthesis Markup Language)" + } } }, "manageModels": { @@ -242,5 +259,9 @@ "koFi": "Support on Ko-fi", "githubSponsor": "Sponsor on GitHub", "githubRepo": "GitHub Repository" + }, + "manageKnowledge": { + "title": "Manage Knowledge", + "heading": "Configure Knowledge Base" } } \ No newline at end of file diff --git a/src/assets/locale/en/sidepanel.json b/src/assets/locale/en/sidepanel.json index d46e213..4e24fdb 100644 --- a/src/assets/locale/en/sidepanel.json +++ b/src/assets/locale/en/sidepanel.json @@ -1,5 +1,7 @@ { "tooltip": { - "embed": "It may take a few minutes to embed the page. Please wait..." + "embed": "It may take a few minutes to embed the page. Please wait...", + "clear": "Erase chat history", + "history": "Chat history" } } \ No newline at end of file diff --git a/src/assets/locale/ja-JP/common.json b/src/assets/locale/ja-JP/common.json index 569e669..4ee3a74 100644 --- a/src/assets/locale/ja-JP/common.json +++ b/src/assets/locale/ja-JP/common.json @@ -48,5 +48,7 @@ "submit": "送信", "noData": "データがありません", "noHistory": "チャット履歴がありません", - "chatWithCurrentPage": "現在のページでチャット" + "chatWithCurrentPage": "現在のページでチャット", + "beta": "ベータ", + "tts": "読み上げ" } \ No newline at end of file diff --git a/src/assets/locale/ja-JP/knowledge.json b/src/assets/locale/ja-JP/knowledge.json new file mode 100644 index 0000000..c4d658c --- /dev/null +++ b/src/assets/locale/ja-JP/knowledge.json @@ -0,0 +1,42 @@ +{ + "addBtn": "新しい知識を追加", + "columns": { + "title": "タイトル", + "status": "ステータス", + "embeddings": "埋め込みモデル", + "createdAt": "作成日", + "action": "アクション" + }, + "expandedColumns": { + "name": "名前" + }, + "tooltip": { + "delete": "削除" + }, + "confirm": { + "delete": "この知識を削除してもよろしいですか?" + }, + "deleteSuccess": "知識が正常に削除されました", + "status": { + "pending": "保留中", + "finished": "完了", + "processing": "処理中" + }, + "addKnowledge": "知識を追加", + "form": { + "title": { + "label": "知識タイトル", + "placeholder": "知識のタイトルを入力してください", + "required": "知識のタイトルは必須です" + }, + "uploadFile": { + "label": "ファイルをアップロード", + "uploadText": "ファイルをここにドラッグアンドドロップするか、クリックしてアップロード", + "uploadHint": "サポートされているファイルタイプ: .pdf、.csv、.txt", + "required": "ファイルは必須です" + }, + "submit": "送信", + "success": "知識が正常に追加されました" + }, + "noEmbeddingModel": "最初にOllamaの設定ページから埋め込みモデルを追加してください" +} \ No newline at end of file diff --git a/src/assets/locale/ja-JP/playground.json b/src/assets/locale/ja-JP/playground.json index d26dd66..a559d18 100644 --- a/src/assets/locale/ja-JP/playground.json +++ b/src/assets/locale/ja-JP/playground.json @@ -21,7 +21,8 @@ "searchInternet": "インターネットを検索", "speechToText": "音声入力", "uploadImage": "画像をアップロード", - "stopStreaming": "ストリーミングを停止" + "stopStreaming": "ストリーミングを停止", + "knowledge": "知識" }, "sendWhenEnter": "Enterキーを押すと送信" } \ No newline at end of file diff --git a/src/assets/locale/ja-JP/settings.json b/src/assets/locale/ja-JP/settings.json index 607532e..dc4d508 100644 --- a/src/assets/locale/ja-JP/settings.json +++ b/src/assets/locale/ja-JP/settings.json @@ -54,6 +54,23 @@ "success": "インポート成功", "error": "インポートエラー" } + }, + "tts": { + "heading": "テキスト読み上げ設定", + "ttsEnabled": { + "label": "テキスト読み上げを有効にする" + }, + "ttsProvider": { + "label": "テキスト読み上げプロバイダー", + "placeholder": "プロバイダーを選択" + }, + "ttsVoice": { + "label": "テキスト読み上げの音声", + "placeholder": "音声を選択" + }, + "ssmlEnabled": { + "label": "SSML (Speech Synthesis Markup Language) を有効にする" + } } }, "manageModels": { @@ -245,5 +262,9 @@ "koFi": "Ko-fiで支援する", "githubSponsor": "GitHubでスポンサーする", "githubRepo": "GitHubリポジトリ" - } + }, + "manageKnowledge": { + "title": "知識を管理する", + "heading": "知識ベースを構成する" + } } \ No newline at end of file diff --git a/src/assets/locale/ml/common.json b/src/assets/locale/ml/common.json index 89f53c7..e81e49d 100644 --- a/src/assets/locale/ml/common.json +++ b/src/assets/locale/ml/common.json @@ -48,5 +48,6 @@ "submit": "സമർപ്പിക്കുക", "noData": "ഡാറ്റ ലഭ്യമല്ല", "noHistory": "ചാറ്റ് ചരിത്രം ലഭ്യമല്ല", - "chatWithCurrentPage": "നിലവിലെ പേജിനുമായി ചാറ്റ് ചെയ്യുക" + "chatWithCurrentPage": "നിലവിലെ പേജിനുമായി ചാറ്റ് ചെയ്യുക", + "beta": "ബീറ്റ" } \ No newline at end of file diff --git a/src/assets/locale/ml/knowledge.json b/src/assets/locale/ml/knowledge.json new file mode 100644 index 0000000..b96f356 --- /dev/null +++ b/src/assets/locale/ml/knowledge.json @@ -0,0 +1,42 @@ +{ + "addBtn": "പുതിയ വിജ്ഞാനം ചേര്‍ക്കുക", + "columns": { + "title": "തലക്കെട്ട്", + "status": "സ്ഥിതി", + "embeddings": "എംബെഡിംഗ് മോഡല്‍", + "createdAt": "സൃഷ്ടിച്ചത്", + "action": "പ്രവർത്തനങ്ങൾ" + }, + "expandedColumns": { + "name": "നാമം" + }, + "tooltip": { + "delete": "ഇല്ലാതാക്കുക" + }, + "confirm": { + "delete": "നിങ്ങൾക്ക് ഈ വിജ്ഞാനം ഇല്ലാതാക്കണമെന്ന് ഉറപ്പാണോ?" + }, + "deleteSuccess": "വിജ്ഞാനം വിജയകരമായി ഇല്ലാതാക്കി", + "status": { + "pending": "തീരുമാനിക്കാനുണ്ട്", + "finished": "പൂർത്തീകരിച്ചു", + "processing": "പ്രോസസ്സിംഗ്" + }, + "addKnowledge": "വിജ്ഞാനം ചേര്‍ക്കുക", + "form": { + "title": { + "label": "വിജ്ഞാനത്തിന്റെ തലക്കെട്ട്", + "placeholder": "വിജ്ഞാനത്തിന്റെ തലക്കെട്ട് നല്‍കുക", + "required": "വിജ്ഞാനത്തിന്റെ തലക്കെട്ട് ആവശ്യമാണ്" + }, + "uploadFile": { + "label": "ഫയല്‍ അപ്‌ലോഡ് ചെയ്യുക", + "uploadText": "ഇവിടെ ഒരു ഫയല്‍ എടുത്തിടുക അല്ലെങ്കില്‍ അപ്‌ലോഡ് ചെയ്യാന്‍ ക്ലിക്ക് ചെയ്യുക", + "uploadHint": "പിന്തുണയുള്ള ഫയല്‍ തരങ്ങള്‍: .pdf, .csv, .txt, .md", + "required": "ഫയല്‍ ആവശ്യമാണ്" + }, + "submit": "സമര്‍പ്പിക്കുക", + "success": "വിജ്ഞാനം വിജയകരമായി ചേര്‍ത്തു" + }, + "noEmbeddingModel": "ദയവായി ആദ്യം Ollama ക്രമീകരണ പേജില്‍ നിന്ന് ഒരു എംബെഡിംഗ് മോഡല്‍ ചേര്‍ക്കുക" +} \ No newline at end of file diff --git a/src/assets/locale/ml/playground.json b/src/assets/locale/ml/playground.json index 7334837..0c6b7ee 100644 --- a/src/assets/locale/ml/playground.json +++ b/src/assets/locale/ml/playground.json @@ -21,7 +21,8 @@ "searchInternet": "ഇന്റര്‍നെറ്റ് തിരയുക", "speechToText": "സംഭാഷണം ടെക്സ്റ്റായി", "uploadImage": "ഇമേജ് അപ്‌ലോഡ് ചെയ്യുക", - "stopStreaming": "സ്ട്രീമിംഗ് നിർത്തുക" + "stopStreaming": "സ്ട്രീമിംഗ് നിർത്തുക", + "knowledge": "അറിവ്" }, "sendWhenEnter": "എന്റര്‍ അമര്‍ത്തുമ്പോള്‍ അയയ്ക്കുക" } \ No newline at end of file diff --git a/src/assets/locale/ml/settings.json b/src/assets/locale/ml/settings.json index 84ab01e..1ed5902 100644 --- a/src/assets/locale/ml/settings.json +++ b/src/assets/locale/ml/settings.json @@ -54,6 +54,23 @@ "success": "ഇമ്പോർട്ട് വിജയകരമായി", "error": "ഇമ്പോർട്ട് പരാജയപ്പെട്ടു" } + }, + "tts": { + "heading": "ടെക്സ്റ്റ്-ടു-സ്പീച്ച് ക്രമീകരണങ്ങൾ", + "ttsEnabled": { + "label": "ടെക്സ്റ്റ്-ടു-സ്പീച്ച് പ്രവർത്തനക്ഷമമാക്കുക" + }, + "ttsProvider": { + "label": "ടെക്സ്റ്റ്-ടു-സ്പീച്ച് പ്രോവൈഡർ", + "placeholder": "ഒരു പ്രോവൈഡർ തിരഞ്ഞെടുക്കുക" + }, + "ttsVoice": { + "label": "ടെക്സ്റ്റ്-ടു-സ്പീച്ച് വോയ്സ്", + "placeholder": "ഒരു വോയ്സ് തിരഞ്ഞെടുക്കുക" + }, + "ssmlEnabled": { + "label": "SSML (സ്പീച്ച് സിന്തസിസ് മാർക്കപ്പ് ലാംഗ്വേജ്) പ്രവർത്തനക്ഷമമാക്കുക" + } } }, "manageModels": { @@ -245,6 +262,9 @@ "koFi": "കോഫിയിൽ പിന്തുണയ്ക്കുക", "githubSponsor": "ഗിറ്റ്ഹബ്ബിൽ സ്പോൺസർ ചെയ്യുക", "githubRepo": "ഗിറ്റ്ഹബ്ബ് റെപ്പോസിറ്ററി" - } - + }, + "manageKnowledge": { + "title": "വിജ്ഞാനം നിര്‍വ്വഹിക്കുക", + "heading": "വിജ്ഞാനാധാരം കോണ്‍ഫിഗര്‍ ചെയ്യുക" + } } \ No newline at end of file diff --git a/src/assets/locale/zh/common.json b/src/assets/locale/zh/common.json index 35f2135..0d9b7d8 100644 --- a/src/assets/locale/zh/common.json +++ b/src/assets/locale/zh/common.json @@ -48,5 +48,7 @@ "submit": "提交", "noData": "无数据", "noHistory": "无聊天记录", - "chatWithCurrentPage": "与当前页面聊天" + "chatWithCurrentPage": "与当前页面聊天", + "beta": "Beta", + "tts": "朗读" } \ No newline at end of file diff --git a/src/assets/locale/zh/knowledge.json b/src/assets/locale/zh/knowledge.json new file mode 100644 index 0000000..7e56a15 --- /dev/null +++ b/src/assets/locale/zh/knowledge.json @@ -0,0 +1,42 @@ +{ + "addBtn": "添加新知识", + "columns": { + "title": "标题", + "status": "状态", + "embeddings": "嵌入模型", + "createdAt": "创建于", + "action": "操作" + }, + "expandedColumns": { + "name": "名称" + }, + "tooltip": { + "delete": "删除" + }, + "confirm": { + "delete": "您确定要删除此知识吗?" + }, + "deleteSuccess": "知识删除成功", + "status": { + "pending": "待定", + "finished": "已完成", + "processing": "处理中" + }, + "addKnowledge": "添加知识", + "form": { + "title": { + "label": "知识标题", + "placeholder": "输入知识标题", + "required": "知识标题是必需的" + }, + "uploadFile": { + "label": "上传文件", + "uploadText": "将文件拖放到此处或点击上传", + "uploadHint": "支持的文件类型: .pdf, .csv, .txt, .md", + "required": "文件是必需的" + }, + "submit": "提交", + "success": "知识添加成功" + }, + "noEmbeddingModel": "请先从Ollama设置页面添加一个嵌入模型" +} \ No newline at end of file diff --git a/src/assets/locale/zh/playground.json b/src/assets/locale/zh/playground.json index 0b9d9a4..84a591c 100644 --- a/src/assets/locale/zh/playground.json +++ b/src/assets/locale/zh/playground.json @@ -21,7 +21,8 @@ "searchInternet": "搜索互联网", "speechToText": "语音到文本", "uploadImage": "上传图片", - "stopStreaming": "停止流媒体" + "stopStreaming": "停止流媒体", + "knowledge": "知识" }, "sendWhenEnter": "按Enter发送" } \ No newline at end of file diff --git a/src/assets/locale/zh/settings.json b/src/assets/locale/zh/settings.json index b99fead..b83adea 100644 --- a/src/assets/locale/zh/settings.json +++ b/src/assets/locale/zh/settings.json @@ -54,6 +54,23 @@ "success": "导入成功", "error": "导入错误" } + }, + "tts": { + "heading": "文本转语音设置", + "ttsEnabled": { + "label": "启用文本转语音" + }, + "ttsProvider": { + "label": "文本转语音提供商", + "placeholder": "选择一个提供商" + }, + "ttsVoice": { + "label": "文本转语音语音", + "placeholder": "选择一种语音" + }, + "ssmlEnabled": { + "label": "启用SSML(语音合成标记语言)" + } } }, "manageModels": { @@ -246,5 +263,9 @@ "koFi": "在Ko-fi上支持", "githubSponsor": "在GitHub上赞助", "githubRepo": "GitHub仓库" - } + }, + "manageKnowledge": { + "title": "管理知识", + "heading": "配置知识库" + } } \ No newline at end of file diff --git a/src/assets/tailwind.css b/src/assets/tailwind.css index f55882d..11ff412 100644 --- a/src/assets/tailwind.css +++ b/src/assets/tailwind.css @@ -55,3 +55,13 @@ background-position: 0% 50%; } } +/* Hide scrollbar for Chrome, Safari and Opera */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} \ No newline at end of file diff --git a/src/chain/chat-with-x.ts b/src/chain/chat-with-x.ts new file mode 100644 index 0000000..4a63829 --- /dev/null +++ b/src/chain/chat-with-x.ts @@ -0,0 +1,158 @@ +import { BaseLanguageModel } from "@langchain/core/language_models/base" +import { Document } from "@langchain/core/documents" +import { + ChatPromptTemplate, + MessagesPlaceholder, + PromptTemplate +} from "@langchain/core/prompts" +import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages" +import { StringOutputParser } from "@langchain/core/output_parsers" +import { + Runnable, + RunnableBranch, + RunnableLambda, + RunnableMap, + RunnableSequence +} from "@langchain/core/runnables" +type RetrievalChainInput = { + chat_history: string + question: string +} + +const formatChatHistoryAsString = (history: BaseMessage[]) => { + return history + .map((message) => `${message._getType()}: ${message.content}`) + .join("\n") +} + +export const formatDocs = (docs: Document[]) => { + return docs + .filter( + (doc, i, self) => + self.findIndex((d) => d.pageContent === doc.pageContent) === i + ) + .map((doc, i) => `${doc.pageContent}`) + .join("\n") +} + +const serializeHistory = (input: any) => { + const chatHistory = input.chat_history || [] + const convertedChatHistory = [] + for (const message of chatHistory) { + if (message.human !== undefined) { + convertedChatHistory.push(new HumanMessage({ content: message.human })) + } + if (message["ai"] !== undefined) { + convertedChatHistory.push(new AIMessage({ content: message.ai })) + } + } + return convertedChatHistory +} + +const createRetrieverChain = ( + llm: BaseLanguageModel, + retriever: Runnable, + question_template: string +) => { + const CONDENSE_QUESTION_PROMPT = + PromptTemplate.fromTemplate(question_template) + const condenseQuestionChain = RunnableSequence.from([ + CONDENSE_QUESTION_PROMPT, + llm, + new StringOutputParser() + ]).withConfig({ + runName: "CondenseQuestion" + }) + const hasHistoryCheckFn = RunnableLambda.from( + (input: RetrievalChainInput) => input.chat_history.length > 0 + ).withConfig({ runName: "HasChatHistoryCheck" }) + const conversationChain = condenseQuestionChain.pipe(retriever).withConfig({ + runName: "RetrievalChainWithHistory" + }) + const basicRetrievalChain = RunnableLambda.from( + (input: RetrievalChainInput) => input.question + ) + .withConfig({ + runName: "Itemgetter:question" + }) + .pipe(retriever) + .withConfig({ runName: "RetrievalChainWithNoHistory" }) + + return RunnableBranch.from([ + [hasHistoryCheckFn, conversationChain], + basicRetrievalChain + ]).withConfig({ + runName: "FindDocs" + }) +} + +export const createChatWithXChain = ({ + llm, + question_template, + question_llm, + retriever, + response_template +}: { + llm: BaseLanguageModel + question_llm: BaseLanguageModel + retriever: Runnable + question_template: string + response_template: string +}) => { + const retrieverChain = createRetrieverChain( + question_llm, + retriever, + question_template + ) + const context = RunnableMap.from({ + context: RunnableSequence.from([ + ({ question, chat_history }) => { + return { + question: question, + chat_history: formatChatHistoryAsString(chat_history) + } + }, + retrieverChain, + RunnableLambda.from(formatDocs).withConfig({ + runName: "FormatDocumentChunks" + }) + ]), + question: RunnableLambda.from( + (input: RetrievalChainInput) => input.question + ).withConfig({ + runName: "Itemgetter:question" + }), + chat_history: RunnableLambda.from( + (input: RetrievalChainInput) => input.chat_history + ).withConfig({ + runName: "Itemgetter:chat_history" + }) + }).withConfig({ tags: ["RetrieveDocs"] }) + const prompt = ChatPromptTemplate.fromMessages([ + ["system", response_template], + new MessagesPlaceholder("chat_history"), + ["human", "{question}"] + ]) + + const responseSynthesizerChain = RunnableSequence.from([ + prompt, + llm, + new StringOutputParser() + ]).withConfig({ + tags: ["GenerateResponse"] + }) + return RunnableSequence.from([ + { + question: RunnableLambda.from( + (input: RetrievalChainInput) => input.question + ).withConfig({ + runName: "Itemgetter:question" + }), + chat_history: RunnableLambda.from(serializeHistory).withConfig({ + runName: "SerializeHistory" + }) + }, + context, + responseSynthesizerChain + ]) +} diff --git a/src/components/Common/Markdown.tsx b/src/components/Common/Markdown.tsx index c4090e2..9d1bf13 100644 --- a/src/components/Common/Markdown.tsx +++ b/src/components/Common/Markdown.tsx @@ -1,7 +1,6 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import remarkGfm from "remark-gfm" import { nightOwl } from "react-syntax-highlighter/dist/cjs/styles/prism" -import rehypeMathjax from "rehype-mathjax" import remarkMath from "remark-math" import ReactMarkdown from "react-markdown" import "property-information" @@ -19,7 +18,6 @@ export default function Markdown({ message }: { message: string }) { ( null ) + const [embeddingController, setEmbeddingController] = + React.useState(null) return ( {children} diff --git a/src/components/Common/Playground/Message.tsx b/src/components/Common/Playground/Message.tsx index dfaeeb8..faeb610 100644 --- a/src/components/Common/Playground/Message.tsx +++ b/src/components/Common/Playground/Message.tsx @@ -2,9 +2,18 @@ import Markdown from "../../Common/Markdown" import React from "react" import { Image, Tooltip } from "antd" import { WebSearch } from "./WebSearch" -import { CheckIcon, ClipboardIcon, Pen, RotateCcw } from "lucide-react" +import { + CheckIcon, + ClipboardIcon, + Pen, + PlayIcon, + RotateCcw, + Square +} from "lucide-react" import { EditMessageForm } from "./EditMessageForm" import { useTranslation } from "react-i18next" +import { MessageSource } from "./MessageSource" +import { useTTS } from "@/hooks/useTTS" type Props = { message: string @@ -23,6 +32,8 @@ type Props = { isSearchingInternet?: boolean sources?: any[] hideEditAndRegenerate?: boolean + onSourceClick?: (source: any) => void + isTTSEnabled?: boolean } export const PlaygroundMessage = (props: Props) => { @@ -30,6 +41,7 @@ export const PlaygroundMessage = (props: Props) => { const [editMode, setEditMode] = React.useState(false) const { t } = useTranslation("common") + const { cancel, isSpeaking, speak } = useTTS() return (
@@ -95,13 +107,11 @@ export const PlaygroundMessage = (props: Props) => { {props.isBot && props?.sources && props?.sources.length > 0 && (
{props?.sources?.map((source, index) => ( - - {source.name} - + source={source} + /> ))}
)} @@ -112,11 +122,31 @@ export const PlaygroundMessage = (props: Props) => { ? "hidden group-hover:flex" : "flex" }`}> + {props.isTTSEnabled && ( + + + + )} {props.isBot && ( <> {!props.hideCopy && ( - + + ) + } + + return ( + + {source.name} + + ) +} diff --git a/src/components/Common/Playground/MessageSourcePopup.tsx b/src/components/Common/Playground/MessageSourcePopup.tsx new file mode 100644 index 0000000..72f4934 --- /dev/null +++ b/src/components/Common/Playground/MessageSourcePopup.tsx @@ -0,0 +1,52 @@ +import { KnowledgeIcon } from "@/components/Option/Knowledge/KnowledgeIcon" +import { Modal } from "antd" + +type Props = { + source: any + open: boolean + setOpen: (open: boolean) => void +} + +export const MessageSourcePopup: React.FC = ({ + source, + open, + setOpen +}) => { + return ( + setOpen(false)} + footer={null} + onOk={() => setOpen(false)}> +
+

+ {source?.type && ( + + )} + {source?.name} +

+ {source?.type === "pdf" ? ( + <> +

{source?.pageContent}

+ +
+ + {`Page ${source?.metadata?.page}`} + + + + {`Line ${source?.metadata?.loc?.lines?.from} - ${source?.metadata?.loc?.lines?.to}`} + +
+ + ) : ( + <> +

{source?.pageContent}

+ + )} +
+
+ ) +} diff --git a/src/components/Common/ShareBtn.tsx b/src/components/Common/ShareBtn.tsx index 1dfdc5f..98dddb1 100644 --- a/src/components/Common/ShareBtn.tsx +++ b/src/components/Common/ShareBtn.tsx @@ -7,7 +7,7 @@ import React from "react" import { useMutation } from "@tanstack/react-query" import { getPageShareUrl } from "~/services/ollama" import { cleanUrl } from "~/libs/clean-url" -import { getUserId, saveWebshare } from "~/libs/db" +import { getUserId, saveWebshare } from "@/db" import { useTranslation } from "react-i18next" type Props = { diff --git a/src/components/Icons/CSVIcon.tsx b/src/components/Icons/CSVIcon.tsx new file mode 100644 index 0000000..18febbe --- /dev/null +++ b/src/components/Icons/CSVIcon.tsx @@ -0,0 +1,44 @@ +import React from "react" + +export const CSVIcon = React.forwardRef< + SVGSVGElement, + React.SVGProps +>((props, ref) => { + return ( + + + + + + + + + + + + + + + + + + + + ) +}) diff --git a/src/components/Icons/PDFIcon.tsx b/src/components/Icons/PDFIcon.tsx new file mode 100644 index 0000000..eedc930 --- /dev/null +++ b/src/components/Icons/PDFIcon.tsx @@ -0,0 +1,30 @@ +import React from "react" + +export const PDFIcon = React.forwardRef< + SVGSVGElement, + React.SVGProps +>((props, ref) => { + return ( + + + + + + + + + + ) +}) diff --git a/src/components/Icons/TXTIcon.tsx b/src/components/Icons/TXTIcon.tsx new file mode 100644 index 0000000..22c737a --- /dev/null +++ b/src/components/Icons/TXTIcon.tsx @@ -0,0 +1,32 @@ +import React from "react" + +export const TXTIcon = React.forwardRef< + SVGSVGElement, + React.SVGProps +>((props, ref) => { + return ( + + + + + + + + + ) +}) diff --git a/src/components/Layouts/Layout.tsx b/src/components/Layouts/Layout.tsx index 23e36db..b1ec2ed 100644 --- a/src/components/Layouts/Layout.tsx +++ b/src/components/Layouts/Layout.tsx @@ -4,7 +4,7 @@ import { useLocation, NavLink } from "react-router-dom" import { Sidebar } from "../Option/Sidebar" import { Drawer, Select, Tooltip } from "antd" import { useQuery } from "@tanstack/react-query" -import { getAllModels } from "~/services/ollama" +import { fetchChatModels, getAllModels } from "~/services/ollama" import { useMessageOption } from "~/hooks/useMessageOption" import { ChevronLeft, @@ -15,10 +15,11 @@ import { SquarePen, ZapIcon } from "lucide-react" -import { getAllPrompts } from "~/libs/db" +import { getAllPrompts } from "@/db" import { ShareBtn } from "~/components/Common/ShareBtn" import { useTranslation } from "react-i18next" import { OllamaIcon } from "../Icons/Ollama" +import { SelectedKnowledge } from "../Option/Knowledge/SelectedKnwledge" export default function OptionLayout({ children @@ -45,7 +46,7 @@ export default function OptionLayout({ isFetching: isModelsFetching } = useQuery({ queryKey: ["fetchModel"], - queryFn: () => getAllModels({ returnEmpty: true }), + queryFn: () => fetchChatModels({ returnEmpty: true }), refetchInterval: 15000 }) @@ -106,7 +107,10 @@ export default function OptionLayout({
+ + { + if (Array.isArray(e)) { + return e + } + return e?.fileList + }}> + { + const allowedTypes = [ + "application/pdf", + // "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/csv", + "text/plain" + ] + .map((type) => type.toLowerCase()) + .join(", ") + + if (!allowedTypes.includes(file.type.toLowerCase())) { + message.error( + t("form.uploadFile.uploadError", { allowedTypes }) + ) + return Upload.LIST_IGNORE + } + + return false + }}> +
+

+ +

+

+ {t("form.uploadFile.uploadText")} +

+

+ {t("form.uploadFile.uploadHint")} +

+
+
+
+ + + + + + + ) +} diff --git a/src/components/Option/Knowledge/KnowledgeIcon.tsx b/src/components/Option/Knowledge/KnowledgeIcon.tsx new file mode 100644 index 0000000..4a08746 --- /dev/null +++ b/src/components/Option/Knowledge/KnowledgeIcon.tsx @@ -0,0 +1,18 @@ +import { CSVIcon } from "@/components/Icons/CSVIcon" +import { PDFIcon } from "@/components/Icons/PDFIcon" +import { TXTIcon } from "@/components/Icons/TXTIcon" + +type Props = { + type: string + className?: string +} + +export const KnowledgeIcon = ({ type, className = "w-6 h-6" }: Props) => { + if (type === "pdf" || type === "application/pdf") { + return + } else if (type === "csv" || type === "text/csv") { + return + } else if (type === "txt" || type === "text/plain") { + return + } +} diff --git a/src/components/Option/Knowledge/KnowledgeSelect.tsx b/src/components/Option/Knowledge/KnowledgeSelect.tsx new file mode 100644 index 0000000..95b036a --- /dev/null +++ b/src/components/Option/Knowledge/KnowledgeSelect.tsx @@ -0,0 +1,64 @@ +import { getAllKnowledge } from "@/db/knowledge" +import { useMessageOption } from "@/hooks/useMessageOption" +import { useQuery } from "@tanstack/react-query" +import { Dropdown, Tooltip } from "antd" +import { Blocks } from "lucide-react" +import React from "react" +import { useTranslation } from "react-i18next" + +export const KnowledgeSelect: React.FC = () => { + const { t } = useTranslation("playground") + const { setSelectedKnowledge, selectedKnowledge } = useMessageOption() + const { data } = useQuery({ + queryKey: ["getAllKnowledge"], + queryFn: async () => { + const data = await getAllKnowledge("finished") + return data + }, + refetchInterval: 1000 + }) + + return ( + <> + {data && data.length > 0 && ( + ({ + key: d.id, + label: ( +
+
+ +
+ {d.title} +
+ ), + onClick: () => { + const knowledge = data?.find((k) => k.id === d.id) + if (selectedKnowledge?.id === d.id) { + setSelectedKnowledge(null) + } else { + setSelectedKnowledge(knowledge) + } + } + })) || [], + style: { + maxHeight: 500, + overflowY: "scroll" + }, + className: "no-scrollbar", + activeKey: selectedKnowledge?.id + }} + placement={"topLeft"} + trigger={["click"]}> + + + +
+ )} + + ) +} diff --git a/src/components/Option/Knowledge/SelectedKnwledge.tsx b/src/components/Option/Knowledge/SelectedKnwledge.tsx new file mode 100644 index 0000000..b7eaa2d --- /dev/null +++ b/src/components/Option/Knowledge/SelectedKnwledge.tsx @@ -0,0 +1,32 @@ +import { Blocks, XIcon } from "lucide-react" +import { useMessageOption } from "@/hooks/useMessageOption" + +export const SelectedKnowledge = () => { + const { selectedKnowledge: knowledge, setSelectedKnowledge } = + useMessageOption() + + if (!knowledge) return <> + + return ( +
+ + {"/"} + +
+
+ + + {knowledge.title} + +
+
+ +
+
+
+ ) +} diff --git a/src/components/Option/Knowledge/index.tsx b/src/components/Option/Knowledge/index.tsx new file mode 100644 index 0000000..8d5f28a --- /dev/null +++ b/src/components/Option/Knowledge/index.tsx @@ -0,0 +1,141 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { AddKnowledge } from "./AddKnowledge" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { deleteKnowledge, getAllKnowledge } from "@/db/knowledge" +import { Skeleton, Table, Tag, Tooltip, message } from "antd" +import { Trash2 } from "lucide-react" +import { KnowledgeIcon } from "./KnowledgeIcon" +import { useMessageOption } from "@/hooks/useMessageOption" + +export const KnowledgeSettings = () => { + const { t } = useTranslation(["knowledge", "common"]) + const [open, setOpen] = useState(false) + const queryClient = useQueryClient() + const { selectedKnowledge, setSelectedKnowledge } = useMessageOption() + + const { data, status } = useQuery({ + queryKey: ["fetchAllKnowledge"], + queryFn: () => getAllKnowledge(), + refetchInterval: 1000 + }) + + const { mutate: deleteKnowledgeMutation, isPending: isDeleting } = + useMutation({ + mutationFn: deleteKnowledge, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["fetchAllKnowledge"] + }) + + message.success(t("deleteSuccess")) + }, + onError: (error) => { + message.error(error.message) + } + }) + + const statusColor = { + finished: "green", + processing: "blue", + pending: "gray" + } + + return ( +
+
+ {/* Add new model button */} +
+
+
+ +
+
+
+ {status === "pending" && } + + {status === "success" && ( + ( + {t(`status.${text}`)} + ) + }, + { + title: t("columns.embeddings"), + dataIndex: "embedding_model", + key: "embedding_model" + }, + { + title: t("columns.createdAt"), + dataIndex: "createdAt", + key: "createdAt", + render: (text: number) => new Date(text).toLocaleString() + }, + { + title: t("columns.action"), + key: "action", + render: (text: string, record: any) => ( +
+ + + +
+ ) + } + ]} + expandable={{ + expandedRowRender: (record) => ( +
+ ), + defaultExpandAllRows: false + }} + bordered + dataSource={data} + rowKey={(record) => `${record.name}-${record.id}`} + /> + )} + + + + + ) +} diff --git a/src/components/Option/Models/index.tsx b/src/components/Option/Models/index.tsx index 88f4804..7728484 100644 --- a/src/components/Option/Models/index.tsx +++ b/src/components/Option/Models/index.tsx @@ -8,6 +8,7 @@ import { useState } from "react" import { useForm } from "@mantine/form" import { Download, RotateCcw, Trash2 } from "lucide-react" import { useTranslation } from "react-i18next" +import { useStorage } from "@plasmohq/storage/hook" dayjs.extend(relativeTime) @@ -15,6 +16,7 @@ export const ModelsBody = () => { const queryClient = useQueryClient() const [open, setOpen] = useState(false) const { t } = useTranslation(["settings", "common"]) + const [selectedModel, setSelectedModel] = useStorage("selectedModel") const form = useForm({ initialValues: { @@ -131,6 +133,12 @@ export const ModelsBody = () => { window.confirm(t("manageModels.confirm.delete")) ) { deleteOllamaModel(record.model) + if ( + selectedModel && + selectedModel === record.model + ) { + setSelectedModel(null) + } } }} className="text-red-500 dark:text-red-400"> @@ -193,8 +201,7 @@ export const ModelsBody = () => { }} /> ), - defaultExpandAllRows: false, - + defaultExpandAllRows: false }} bordered dataSource={data} diff --git a/src/components/Option/Playground/Playground.tsx b/src/components/Option/Playground/Playground.tsx index 58a5446..a18b258 100644 --- a/src/components/Option/Playground/Playground.tsx +++ b/src/components/Option/Playground/Playground.tsx @@ -1,15 +1,21 @@ import React from "react" import { PlaygroundForm } from "./PlaygroundForm" import { PlaygroundChat } from "./PlaygroundChat" +import { useMessageOption } from "@/hooks/useMessageOption" export const Playground = () => { const drop = React.useRef(null) const [dropedFile, setDropedFile] = React.useState() + const { selectedKnowledge } = useMessageOption() const [dropState, setDropState] = React.useState< "idle" | "dragging" | "error" >("idle") React.useEffect(() => { + if (selectedKnowledge) { + return + } + if (!drop.current) { return } @@ -64,7 +70,7 @@ export const Playground = () => { drop.current.removeEventListener("dragleave", handleDragLeave) } } - }, []) + }, [selectedKnowledge]) return (
{ const { @@ -9,44 +10,60 @@ export const PlaygroundChat = () => { streaming, regenerateLastMessage, isSearchingInternet, - editMessage + editMessage, + ttsEnabled } = useMessageOption() const divRef = React.useRef(null) + const [isSourceOpen, setIsSourceOpen] = React.useState(false) + const [source, setSource] = React.useState(null) React.useEffect(() => { if (divRef.current) { divRef.current.scrollIntoView({ behavior: "smooth" }) } }) return ( -
- {messages.length === 0 && ( -
- -
- )} - {/* {messages.length > 0 &&
} */} - {messages.map((message, index) => ( - { - editMessage(index, value, !message.isBot) - }} - /> - ))} - {messages.length > 0 && ( -
- )} -
-
+ <> + {" "} +
+ {messages.length === 0 && ( +
+ +
+ )} + {/* {messages.length > 0 &&
} */} + {messages.map((message, index) => ( + { + editMessage(index, value, !message.isBot) + }} + onSourceClick={(data) => { + setSource(data) + setIsSourceOpen(true) + }} + isTTSEnabled={ttsEnabled} + /> + ))} + {messages.length > 0 && ( +
+ )} +
+
+ + ) } diff --git a/src/components/Option/Playground/PlaygroundForm.tsx b/src/components/Option/Playground/PlaygroundForm.tsx index 87c6005..64c3683 100644 --- a/src/components/Option/Playground/PlaygroundForm.tsx +++ b/src/components/Option/Playground/PlaygroundForm.tsx @@ -4,7 +4,7 @@ import React from "react" import useDynamicTextareaSize from "~/hooks/useDynamicTextareaSize" import { toBase64 } from "~/libs/to-base64" import { useMessageOption } from "~/hooks/useMessageOption" -import { Checkbox, Dropdown, Switch, Tooltip } from "antd" +import { Checkbox, Dropdown, Select, Switch, Tooltip } from "antd" import { Image } from "antd" import { useSpeechRecognition } from "~/hooks/useSpeechRecognition" import { useWebUI } from "~/store/webui" @@ -12,6 +12,8 @@ import { defaultEmbeddingModelForRag } from "~/services/ollama" import { ImageIcon, MicIcon, StopCircleIcon, X } from "lucide-react" import { getVariable } from "~/utils/select-varaible" import { useTranslation } from "react-i18next" +import { KnowledgeSelect } from "../Knowledge/KnowledgeSelect" +import { SelectedKnowledge } from "../Knowledge/SelectedKnwledge" type Props = { dropedFile: File | undefined @@ -32,7 +34,8 @@ export const PlaygroundForm = ({ dropedFile }: Props) => { setWebSearch, selectedQuickPrompt, textareaRef, - setSelectedQuickPrompt + setSelectedQuickPrompt, + selectedKnowledge } = useMessageOption() const textAreaFocus = () => { @@ -224,31 +227,34 @@ export const PlaygroundForm = ({ dropedFile }: Props) => { />
- -
- - +
+ + + + setWebSearch(e)} + checkedChildren={t("form.webSearch.on")} + unCheckedChildren={t("form.webSearch.off")} /> - - setWebSearch(e)} - checkedChildren={t("form.webSearch.on")} - unCheckedChildren={t("form.webSearch.off")} - /> -
- +
+
+ )}
+ - - - + + {!selectedKnowledge && ( + + + + )} {!isSending ? ( { const queryClient = useQueryClient() diff --git a/src/components/Option/Settings/about.tsx b/src/components/Option/Settings/about.tsx index b30f39d..024bf82 100644 --- a/src/components/Option/Settings/about.tsx +++ b/src/components/Option/Settings/about.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next" import { useQuery } from "@tanstack/react-query" import { Skeleton } from "antd" import { cleanUrl } from "@/libs/clean-url" +import { Descriptions } from "antd" export const AboutApp = () => { const { t } = useTranslation("settings") @@ -41,37 +42,23 @@ export const AboutApp = () => { {status === "pending" && } {status === "success" && (
-
-
-

- {t("about.heading")} -

-
-
-
- -
-
-
- - {t("about.chromeVersion")} - - - {data.chromeVersion} - -
- -
- - {t("about.ollamaVersion")} - - - {data.ollama} - -
-
-
- +

{t("about.support")} diff --git a/src/components/Option/Settings/other.tsx b/src/components/Option/Settings/other.tsx index a608f98..65383f0 100644 --- a/src/components/Option/Settings/other.tsx +++ b/src/components/Option/Settings/other.tsx @@ -1,13 +1,14 @@ import { useQueryClient } from "@tanstack/react-query" import { useDarkMode } from "~/hooks/useDarkmode" import { useMessageOption } from "~/hooks/useMessageOption" -import { PageAssitDatabase } from "~/libs/db" +import { PageAssitDatabase } from "@/db" import { Select } from "antd" import { SUPPORTED_LANGUAGES } from "~/utils/supporetd-languages" import { MoonIcon, SunIcon } from "lucide-react" import { SearchModeSettings } from "./search-mode" import { useTranslation } from "react-i18next" import { useI18n } from "@/hooks/useI18n" +import { TTSModeSettings } from "./tts-mode" export const SettingOther = () => { const { clearChat, speechToTextLanguage, setSpeechToTextLanguage } = @@ -89,6 +90,7 @@ export const SettingOther = () => {

+

diff --git a/src/components/Option/Settings/prompt.tsx b/src/components/Option/Settings/prompt.tsx index f0cbbc2..fc7efee 100644 --- a/src/components/Option/Settings/prompt.tsx +++ b/src/components/Option/Settings/prompt.tsx @@ -1,22 +1,20 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" -import { Skeleton, Radio, Form, Alert } from "antd" +import { Skeleton, Radio, Form, Input } from "antd" import React from "react" import { useTranslation } from "react-i18next" import { SaveButton } from "~/components/Common/SaveButton" import { getWebSearchPrompt, - setSystemPromptForNonRagOption, - systemPromptForNonRagOption, geWebSearchFollowUpPrompt, - setWebPrompts + setWebPrompts, + promptForRag, + setPromptForRag } from "~/services/ollama" export const SettingPrompt = () => { const { t } = useTranslation("settings") - const [selectedValue, setSelectedValue] = React.useState<"normal" | "web">( - "web" - ) + const [selectedValue, setSelectedValue] = React.useState<"web" | "rag">("rag") const queryClient = useQueryClient() @@ -25,7 +23,7 @@ export const SettingPrompt = () => { queryFn: async () => { const [prompt, webSearchPrompt, webSearchFollowUpPrompt] = await Promise.all([ - systemPromptForNonRagOption(), + promptForRag(), getWebSearchPrompt(), geWebSearchFollowUpPrompt() ]) @@ -48,46 +46,60 @@ export const SettingPrompt = () => { setSelectedValue(e.target.value)}> - - {t("ollamaSettings.settings.prompt.option1")} - + RAG {t("ollamaSettings.settings.prompt.option2")}

- {selectedValue === "normal" && ( + {selectedValue === "rag" && (
{ - setSystemPromptForNonRagOption(values?.prompt || "") + // setSystemPromptForNonRagOption(values?.prompt || "") + setPromptForRag( + values?.systemPrompt || "", + values?.questionPrompt || "" + ) queryClient.invalidateQueries({ queryKey: ["fetchOllaPrompt"] }) }} initialValues={{ - prompt: data.prompt + systemPrompt: data.prompt.ragPrompt, + questionPrompt: data.prompt.ragQuestionPrompt }}> - - + -