From d96c9ff773d9bbcc341b2731bc486f0b35b3c59e Mon Sep 17 00:00:00 2001 From: an-lee Date: Wed, 9 Oct 2024 16:57:32 +0800 Subject: [PATCH] Refactor Chat (#1108) * modify chat table & migrate * refactor layout * update layout * actions for chats & agents * refactor chat form * refactor chat form * update chat form * rename * refactor types & locales * refactor tts engine * refactor * fix config * refactor chat form * refactor chat member form * fixing * refactor ask agent * chat in conversation * fix chat message update * may update chat member * update chat member from message * refacto group propmt * chat member gpt settings * update ui * more config for chat * add locales * update chat agent form * add locales for agent form * update UI * auto reply for text input * update chat * update chat input * rename colomns * update chat * udpate agent message * add chat member * add/remove chat member * fix chat member * refactor * auto update chat name * fix chat update * refactor chat column * fix chat * add agent loading * use fresh new prompt when ask agent * add chat forwarder * refactor chat * fix * add copilot * toggle copilot * add copilot chat * avoid open the same chat at the same time * update copilot header * add agent introduction in the first place of chat * rename column * update style * refactor * invoke all agents in group after asking * chat sidebar collopse * may select chat in copilot * update ui * new chat from agent * upgrade deps * refactor chat & chatAgent * add limit for chat member create * update chat form * update db & migration * fix up * fix group chat * fix panel warning * display chat agent type * tts message * fit tts agent * refactor * chat fowarder * update UI * setup default values for tts agent * fix chat member add/remove * edit tts agent * display chat date * Fix UI * add system message * refactor * fix hook * refactor * touch chat&agent when new message created * fix auto reply * migrate conversation to chat * add migrate api * fix migrate * update migrate * refactor * refactor * refactor * fix delete agent * add hotkey for copilot * fix bugs * upgrade deps * refactor tts hook * stop auto playback when azure transcribed * refactor * clean up * fix UI * fix conversation migrate * handle error * update model * declare types * audo backup db file when started * fix db backup * refactor db migration * fix UI * refactor * fix chat auto update name * fix authorization lost when hot reload * refactor * refactor * fix tts form * keep agent avatar up to date * clean code --- 1000-hours/package.json | 8 +- 1000h-portal/package.json | 8 +- enjoy/package.json | 76 +- enjoy/src/api/client.ts | 9 +- enjoy/src/commands/chat-suggestion.command.ts | 9 +- enjoy/src/commands/summarize-topic.command.ts | 2 +- enjoy/src/constants/index.ts | 34 +- enjoy/src/i18n/en.json | 95 +- enjoy/src/i18n/zh-CN.json | 123 +- .../main/db/handlers/chat-agents-handler.ts | 37 +- .../main/db/handlers/chat-members-handler.ts | 100 +- .../main/db/handlers/chat-messages-handler.ts | 124 +- enjoy/src/main/db/handlers/chats-handler.ts | 217 +- .../main/db/handlers/conversations-handler.ts | 12 + .../main/db/handlers/user-settings-handler.ts | 54 +- enjoy/src/main/db/index.ts | 131 +- .../migrations/1726781106038-modify-chat.js | 149 + enjoy/src/main/db/models/chat-agent.ts | 175 +- enjoy/src/main/db/models/chat-member.ts | 101 +- enjoy/src/main/db/models/chat-message.ts | 95 +- enjoy/src/main/db/models/chat.ts | 70 +- enjoy/src/main/db/models/conversation.ts | 152 +- enjoy/src/preload.ts | 20 + enjoy/src/renderer/app.tsx | 5 +- .../components/chats/chat-agent-card.tsx | 78 + .../components/chats/chat-agent-form.tsx | 535 +-- .../components/chats/chat-agent-message.tsx | 461 +- .../renderer/components/chats/chat-agents.tsx | 170 +- .../renderer/components/chats/chat-card.tsx | 87 +- .../renderer/components/chats/chat-form.tsx | 684 ++- .../components/chats/chat-gpt-form.tsx | 233 + .../renderer/components/chats/chat-header.tsx | 77 + .../renderer/components/chats/chat-input.tsx | 286 +- .../renderer/components/chats/chat-list.tsx | 190 + .../components/chats/chat-member-form.tsx | 233 + .../components/chats/chat-message.tsx | 49 +- .../components/chats/chat-messages.tsx | 129 +- .../components/chats/chat-session.tsx | 38 + .../components/chats/chat-settings.tsx | 219 + .../components/chats/chat-sidebar.tsx | 176 - .../chats/chat-suggestion-button.tsx | 170 + .../components/chats/chat-tts-form.tsx | 167 + .../components/chats/chat-user-message.tsx | 344 +- enjoy/src/renderer/components/chats/chat.tsx | 71 - enjoy/src/renderer/components/chats/index.ts | 11 +- .../conversations/conversation-card.tsx | 63 +- .../copilots/copilot-chat-agents.tsx | 56 + .../components/copilots/copilot-chats.tsx | 112 + .../components/copilots/copilot-forwarder.tsx | 111 + .../components/copilots/copilot-header.tsx | 144 + .../components/copilots/copilot-session.tsx | 34 + .../src/renderer/components/copilots/index.ts | 5 + .../courses/enrollments-segment.tsx | 11 +- enjoy/src/renderer/components/index.ts | 1 + .../components/llm-chats/llm-message.tsx | 4 +- .../media-current-recording.tsx | 8 +- .../media-transcription-read-button.tsx | 6 +- .../components/messages/assistant-message.tsx | 4 +- .../src/renderer/components/misc/db-state.tsx | 14 +- enjoy/src/renderer/components/misc/layout.tsx | 47 +- .../src/renderer/components/misc/sidebar.tsx | 20 +- .../preferences/default-engine-settings.tsx | 6 +- .../components/preferences/hotkeys.tsx | 21 +- .../pronunciation-assessment-form.tsx | 8 +- .../components/stories/stories-segment.tsx | 15 +- .../renderer/context/ai-settings-provider.tsx | 75 +- .../context/app-settings-provider.tsx | 21 +- enjoy/src/renderer/context/chat-provider.tsx | 110 - .../context/chat-session-provider.tsx | 289 +- .../src/renderer/context/copilot-provider.tsx | 167 + .../context/hotkeys-settings-provider.tsx | 1 + enjoy/src/renderer/context/index.ts | 2 +- .../context/media-shadow-provider.tsx | 1 - enjoy/src/renderer/hooks/index.ts | 4 +- enjoy/src/renderer/hooks/use-ai-command.tsx | 52 +- enjoy/src/renderer/hooks/use-chat-agent.tsx | 17 +- enjoy/src/renderer/hooks/use-chat-member.tsx | 59 + enjoy/src/renderer/hooks/use-chat-message.tsx | 326 +- enjoy/src/renderer/hooks/use-chat-session.tsx | 408 ++ enjoy/src/renderer/hooks/use-chat.tsx | 83 +- enjoy/src/renderer/hooks/use-conversation.tsx | 126 +- enjoy/src/renderer/hooks/use-speech.tsx | 129 + enjoy/src/renderer/lib/utils.ts | 14 + enjoy/src/renderer/pages/chats.tsx | 91 +- enjoy/src/renderer/pages/conversations.tsx | 44 +- enjoy/src/renderer/pages/error-page.tsx | 2 +- enjoy/src/renderer/pages/home.tsx | 9 +- .../renderer/reducers/chat-members-reducer.ts | 43 + .../reducers/chat-messages-reducer.ts | 3 + enjoy/src/renderer/reducers/index.ts | 1 + enjoy/src/types/chat.d.ts | 103 +- enjoy/src/types/enjoy-app.d.ts | 8 + enjoy/src/types/enums.ts | 29 + enjoy/src/types/index.d.ts | 9 + enjoy/tailwind.config.js | 4 + yarn.lock | 4142 ++++++++--------- 96 files changed, 8387 insertions(+), 4889 deletions(-) create mode 100644 enjoy/src/main/db/migrations/1726781106038-modify-chat.js create mode 100644 enjoy/src/renderer/components/chats/chat-agent-card.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-gpt-form.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-header.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-list.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-member-form.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-session.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-settings.tsx delete mode 100644 enjoy/src/renderer/components/chats/chat-sidebar.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-suggestion-button.tsx create mode 100644 enjoy/src/renderer/components/chats/chat-tts-form.tsx delete mode 100644 enjoy/src/renderer/components/chats/chat.tsx create mode 100644 enjoy/src/renderer/components/copilots/copilot-chat-agents.tsx create mode 100644 enjoy/src/renderer/components/copilots/copilot-chats.tsx create mode 100644 enjoy/src/renderer/components/copilots/copilot-forwarder.tsx create mode 100644 enjoy/src/renderer/components/copilots/copilot-header.tsx create mode 100644 enjoy/src/renderer/components/copilots/copilot-session.tsx create mode 100644 enjoy/src/renderer/components/copilots/index.ts delete mode 100644 enjoy/src/renderer/context/chat-provider.tsx create mode 100644 enjoy/src/renderer/context/copilot-provider.tsx create mode 100644 enjoy/src/renderer/hooks/use-chat-member.tsx create mode 100644 enjoy/src/renderer/hooks/use-chat-session.tsx create mode 100644 enjoy/src/renderer/hooks/use-speech.tsx create mode 100644 enjoy/src/renderer/reducers/chat-members-reducer.ts diff --git a/1000-hours/package.json b/1000-hours/package.json index 24f9fe80..ba7468a2 100644 --- a/1000-hours/package.json +++ b/1000-hours/package.json @@ -7,11 +7,11 @@ "markdown-it-mathjax3": "^4.3.2", "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", - "mermaid": "^11.2.1", - "sass": "^1.79.3", + "mermaid": "^11.3.0", + "sass": "^1.79.4", "vitepress": "^1.3.4", - "vitepress-plugin-mermaid": "^2.0.16", - "vue": "^3.5.8" + "vitepress-plugin-mermaid": "^2.0.17", + "vue": "^3.5.11" }, "scripts": { "dev": "vitepress dev", diff --git a/1000h-portal/package.json b/1000h-portal/package.json index 9bde2a8c..5b092ee3 100644 --- a/1000h-portal/package.json +++ b/1000h-portal/package.json @@ -10,16 +10,16 @@ "postinstall": "nuxt prepare" }, "dependencies": { - "@nuxtjs/seo": "^2.0.0-rc.21", + "@nuxtjs/seo": "^2.0.0-rc.23", "nuxt": "^3.13.2", - "nuxt-og-image": "^3.0.2", - "vue": "^3.5.8", + "nuxt-og-image": "^3.0.4", + "vue": "^3.5.11", "vue-router": "^4.4.5" }, "devDependencies": { "autoprefixer": "^10.4.20", "postcss": "^8.4.47", - "sass": "^1.79.3", + "sass": "^1.79.4", "tailwindcss": "^3.4.13" } } diff --git a/enjoy/package.json b/enjoy/package.json index 75063d88..765a8f4f 100644 --- a/enjoy/package.json +++ b/enjoy/package.json @@ -50,76 +50,76 @@ "@types/fluent-ffmpeg": "^2.1.26", "@types/html-to-text": "^9.0.4", "@types/intl-tel-input": "^18.1.4", - "@types/lodash": "^4.17.9", + "@types/lodash": "^4.17.10", "@types/mark.js": "^8.11.12", "@types/mustache": "^4.2.5", - "@types/node": "^22.6.1", + "@types/node": "^22.7.4", "@types/prop-types": "^15.7.13", "@types/rails__actioncable": "^6.1.11", - "@types/react": "^18.3.8", + "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@types/semver": "^7.5.8", "@types/unzipper": "^0.10.10", "@types/validator": "^13.12.2", "@types/wavesurfer.js": "^6.0.12", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", - "@vitejs/plugin-react": "^4.3.1", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "@typescript-eslint/parser": "^8.8.0", + "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "electron": "^32.1.2", "electron-devtools-installer": "^3.2.0", "electron-playwright-helpers": "^1.7.1", - "eslint": "^9.11.1", + "eslint": "^9.12.0", "eslint-import-resolver-typescript": "^3.6.3", - "eslint-plugin-import": "^2.30.0", + "eslint-plugin-import": "^2.31.0", "flora-colossus": "^2.0.0", "octokit": "^4.0.2", "progress": "^2.0.3", "prop-types": "^15.8.1", - "tailwind-merge": "^2.5.2", + "tailwind-merge": "^2.5.3", "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.13", "tailwindcss-animate": "^1.0.7", "ts-node": "^10.9.2", "tslib": "^2.7.0", "typescript": "^5.6.2", - "vite": "^5.4.7", + "vite": "^5.4.8", "vite-plugin-static-copy": "^1.0.6", - "zx": "^8.1.8" + "zx": "^8.1.9" }, "dependencies": { "@andrkrn/ffprobe-static": "^5.2.0", "@divisey/js-mdict": "^5.0.0", "@electron-forge/publisher-s3": "^7.5.0", "@hookform/resolvers": "^3.9.0", - "@langchain/community": "^0.3.2", - "@langchain/core": "^0.3.3", + "@langchain/community": "^0.3.4", + "@langchain/core": "^0.3.7", "@langchain/ollama": "^0.1.0", "@mozilla/readability": "^0.5.0", - "@radix-ui/react-accordion": "^1.2.0", - "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-accordion": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-collapsible": "^1.1.0", - "@radix-ui/react-dropdown-menu": "^2.1.1", - "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-menubar": "^1.1.1", - "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-menubar": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", - "@radix-ui/react-radio-group": "^1.2.0", - "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slider": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.0", - "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.3", "@rails/actioncable": "7.2.100", "@ricky0123/vad-react": "^0.0.24", "@ricky0123/vad-web": "^0.0.18", @@ -137,7 +137,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "command-exists": "^1.2.9", - "compromise": "^14.14.0", + "compromise": "^14.14.1", "compromise-paragraphs": "^0.1.0", "compromise-stats": "^0.1.0", "dayjs": "^1.11.13", @@ -153,23 +153,23 @@ "fs-extra": "^11.2.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.5", - "i18next": "^23.15.1", - "intl-tel-input": "^24.5.0", + "i18next": "^23.15.2", + "intl-tel-input": "^24.6.0", "js-md5": "^0.8.3", "langchain": "^0.3.2", "lodash": "^4.17.21", "lru-cache": "^11.0.1", - "lucide-react": "^0.445.0", + "lucide-react": "^0.447.0", "mark.js": "^8.11.1", "microsoft-cognitiveservices-speech-sdk": "^1.40.0", "mustache": "^4.2.0", "next-themes": "^0.3.0", - "openai": "^4.63.0", + "openai": "^4.67.1", "pitchfinder": "^2.3.2", "postcss": "^8.4.47", "proxy-agent": "^6.4.0", "react": "^18.3.1", - "react-activity-calendar": "^2.5.2", + "react-activity-calendar": "^2.6.2", "react-audio-visualize": "^1.2.0", "react-audio-voice-recorder": "^2.2.0", "react-dom": "^18.3.1", @@ -178,14 +178,14 @@ "react-hotkeys-hook": "^4.5.1", "react-i18next": "^15.0.2", "react-markdown": "^9.0.1", - "react-resizable-panels": "^2.1.3", + "react-resizable-panels": "^2.1.4", "react-router-dom": "^6.26.2", "react-shadow-root": "^6.2.0", "react-tooltip": "^5.28.0", "reflect-metadata": "^0.2.2", "rimraf": "^6.0.1", "semver": "^7.6.3", - "sequelize": "^6.37.3", + "sequelize": "^6.37.4", "sequelize-typescript": "^2.1.6", "sonner": "^1.5.0", "sqlite3": "^5.1.7", diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts index aa0a8d5b..55703440 100644 --- a/enjoy/src/api/client.ts +++ b/enjoy/src/api/client.ts @@ -74,7 +74,14 @@ export class Client { ); if (err.response.data) { - err.message = err.response.data; + if (typeof err.response.data === "string") { + err.message = err.response.data; + } else if (typeof err.response.data === "object") { + err.message = + err.response.data.error || + err.response.data.message || + JSON.stringify(err.response.data); + } } return Promise.reject(err); } diff --git a/enjoy/src/commands/chat-suggestion.command.ts b/enjoy/src/commands/chat-suggestion.command.ts index 2f0f6953..817fc42d 100644 --- a/enjoy/src/commands/chat-suggestion.command.ts +++ b/enjoy/src/commands/chat-suggestion.command.ts @@ -42,11 +42,8 @@ export const chatSuggestionCommand = async ( return jsonCommand(prompt, { ...options, schema }); }; -const SYSTEM_PROMPT = `I speak {native_language}. You're my {learning_language} coach. I'am chatting with foreign friends. And I don't know what to say next. - -{context}`; - -const PROMPT = `Please provide me with at least 5 suggestions for what counld I say in {learning_language} and explain them in {native_language}. +const SYSTEM_PROMPT = `I speak {native_language}. You're my {learning_language} coach. I'am chatting with foreign friends. +I'll provide you with the context of the chat. Please provide me with at least 5 suggestions for what counld I say in {learning_language} and explain them in {native_language}. Reply in JSON format only. The output should be structured like this: {{ @@ -57,3 +54,5 @@ Reply in JSON format only. The output should be structured like this: }} ] }}`; + +const PROMPT = `{context}`; diff --git a/enjoy/src/commands/summarize-topic.command.ts b/enjoy/src/commands/summarize-topic.command.ts index 2c7d26f4..c188f151 100644 --- a/enjoy/src/commands/summarize-topic.command.ts +++ b/enjoy/src/commands/summarize-topic.command.ts @@ -25,4 +25,4 @@ export const summarizeTopicCommand = async ( }; const SYSTEM_PROMPT = - "Please generate a four to five words title summarizing our conversation in {learning_language} without any lead-in, punctuation, quotation marks, periods, symbols, bold text, or additional text. Remove enclosing quotation marks."; + "Please generate a four to five words title summarizing our conversation without any lead-in, punctuation, quotation marks, periods, symbols, bold text, or additional text. Remove enclosing quotation marks. Please use the main language of the text."; diff --git a/enjoy/src/constants/index.ts b/enjoy/src/constants/index.ts index 2818f674..df1def0a 100644 --- a/enjoy/src/constants/index.ts +++ b/enjoy/src/constants/index.ts @@ -22,6 +22,8 @@ export const AI_WORKER_ENDPOINT = "https://ai-worker.enjoy.bot"; export const WEB_API_URL = "https://enjoy.bot"; export const WS_URL = "wss://enjoy.bot"; +export const DOWNLOAD_URL = "https://1000h.org/enjoy-app/download.html"; + export const REPO_URL = "https://github.com/zuodaotech/everyone-can-use-english"; @@ -58,28 +60,28 @@ export const NOT_SUPPORT_JSON_FORMAT_MODELS = [ "gpt-4-32k", ]; -export const CHAT_SYSTEM_PROMPT_TEMPLATE = `You are {name}. -{agent_prompt} -{agent_chat_prompt} - -[Rules must be followed] -1. Always reply in {language}. -2. Reply in your personality style and talk in casual way. -3. Reply what you would say only, do not include any other format. - -[Chat Topic] -{topic} - -[Chat Members] -{members} +export const CHAT_GROUP_PROMPT_TEMPLATE = `You are {name} in this chat. You should reply to everyone in this chat and always stay in character. [Chat History] {history} + +Return reply as {name}. `; +export const DEFAULT_GPT_CONFIG = { + model: "gpt-4o", + engine: "enjoyai", + temperature: 0.8, + historyBufferSize: 10, + maxCompletionTokens: -1, + presencePenalty: 0, + frequencyPenalty: 0, + numberOfChoices: 1, +}; + export const AGENT_FIXTURE_AVA = { name: "Ava", - introduction: "I'm Ava, your English speaking teacher.", + description: "I'm Ava, your English speaking teacher.", language: "en-US", config: { engine: "enjoyai", @@ -95,7 +97,7 @@ export const AGENT_FIXTURE_AVA = { export const AGENT_FIXTURE_ANDREW = { name: "Andrew", - introduction: "I'm Andrew, your American friend.", + description: "I'm Andrew, your American friend.", language: "en-US", config: { engine: "enjoyai", diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 7b7ea851..7ce616e0 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -135,17 +135,33 @@ "created": "Chat agent created", "updated": "Chat agent updated", "deleted": "Chat agent deleted", + "type": "Type", + "typeGptDescription": "GPT agent can chat with you.", + "typeTtsDescription": "TTS agent can convert text to speech.", + "typeSttDescription": "STT agent can convert speech to text.", "name": "Name", - "introduction": "Introduction", - "language": "Language", + "namePlaceholder": "Enjoy Assistant", + "nameDescription": "Help you to identify this chat agent", + "description": "Description", + "descriptionPlaceholder": "Ask me anything", + "descriptionDescription": "Describe this chat agent shortly (Will not included in the prompt)", "prompt": "Prompt", - "engine": "AI Engine", - "model": "AI Model", - "temperature": "Temperature", - "temperatureDescription": "The higher the temperature, the more creative the result", - "ttsEngine": "TTS Engine", - "ttsModel": "TTS Model", - "ttsVoice": "TTS Voice" + "promptPlaceholder": "You are my AI assistant, please answer my any questions.", + "promptDescription": "The role definition (prompt) of the chat agent" + }, + "chatMember": { + "prompt": "Extra Prompt", + "promptDescription": "The extra prompt will be added to the system prompt of the chat agent. It is useful when you want to customize the chat agent's behavior in this specific chat.", + "promptPlaceholder": "For example: please use simple sentences as much as possible, and reply no more than 10 sentences at a time.", + "gptSettings": "LLM Settings", + "ttsSettings": "Text to Speech", + "moreSettings": "More Settings", + "added": "Chat member added", + "removed": "Chat member removed", + "notFound": "Chat member not found", + "atLeastOneAgent": "The chat must have at least 1 agent.", + "cannotAddMemberToThisChat": "Cannot add member to this chat", + "onlyGPTAgentCanBeAddedToThisChat": "Only GPT agent can be added to this chat" }, "chat": { "notFound": "Chat not found", @@ -156,7 +172,23 @@ "language": "Language", "topic": "Topic", "members": "Members", - "memberConfig": "Member Configuration" + "enableChatAssistant": "Enable Chat Assistant", + "enableChatAssistantDescription": "When enabled, you can use SUGGEST or REFINE to help you chat with the chat agent.", + "enableAutoTts": "Enable Auto TTS", + "enableAutoTtsDescription": "When enabled, all replies will be converted to audio automatically.", + "generateTopic": "Generate Topic", + "sttAiServicePlaceholder": "Please select", + "sttAiServiceDescription": "Choose service to transcribe your speech to text.", + "prompt": "Extra Prompt", + "promptPlaceholder": "For example: the chat is about how to learn English, please do not deviate from the topic.", + "promptDescription": "The prompt will be added to the system prompt of all chat agents in this chat. It is useful when you want to customize the chat agent's behavior in this specific chat.", + "chatSettings": "Chat Settings", + "moreSettings": "More Settings", + "memberSettings": "Member Settings", + "atLeastOneAgent": "The chat must have at least 1 agent.", + "onlyGPTAgentCanBeAddedToThisChat": "Only GPT agent can be added to this chat", + "invalidAgentType": "Invalid agent type", + "invalidMembers": "Invalid members" } }, "sidebar": { @@ -301,6 +333,7 @@ "player": "Player", "quitApp": "Quit APP", "openPreferences": "Open preferences", + "openCopilot": "Open copilot", "playOrPause": "Play or pause", "playOrPauseRecording": "Play or pause recording", "startOrStopRecording": "start or stop recording", @@ -422,6 +455,7 @@ "reloadIsNeededAfterChanged": "Reload is needed after changed", "defaultAiEngine": "Default AI engine", "aiEngine": "AI engine", + "aiEngineNotSupported": "AI engine not supported", "defaultAiModel": "Default AI model", "lookupAiModel": "AI Lookup", "translateAiModel": "AI Translate", @@ -431,7 +465,7 @@ "enjoyAiEngineTips": "Use EnjoyAI as default AI engine. It is a paid service.", "openaiKeySaved": "OpenAI key saved", "openaiConfigSaved": "OpenAI config saved", - "openaiKeyRequired": "OpenAI key required", + "openaiKeyRequired": "OpenAI key required, please set it in settings", "baseUrl": "baseURL", "customModels": "Custom models", "customModelsDescription": "Customize your LLM models. Split by comma.", @@ -448,6 +482,8 @@ "deleteConversation": "Delete conversation", "deleteConversationConfirmation": "Are you sure to delete this conversation inclcuding all messages?", "noConversationsYet": "No conversations yet", + "conversationMigrated": "Conversation has been migrated to Chat", + "conversationDeleted": "Conversation deleted", "translation": "Translation", "pressEnterToSend": "Press enter to send", "send": "Send", @@ -705,6 +741,7 @@ "editChat": "Edit chat", "newChat": "New chat", "addChat": "Add chat", + "recents": "Recents", "agentsManagement": "Agents management", "newAgent": "New agent", "editAgent": "Edit agent", @@ -794,5 +831,39 @@ "exportRecordingsSuccess": "Export recordings successfully", "upgrade": "Upgrade", "upgradeNotice": "Enjoy App v{{version}} is available. Please upgrade to the latest version.", - "downloadedTranscriptionFromCloud": "Downloaded transcription from cloud" + "downloadedTranscriptionFromCloud": "Downloaded transcription from cloud", + "gpt": { + "engine": "AI engine", + "model": "AI model", + "temperature": "Temperature", + "temperatureDescription": "The higher the value, the more creative the generated text, otherwise it is more stable", + "maxCompletionTokens": "Max Tokens", + "maxCompletionTokensDescription": "The maximum number of tokens consumed per interaction, -1 means no limit", + "presencePenalty": "Presence Penalty", + "presencePenaltyDescription": "-2.0 ~ 2.0, the higher the value, the more likely to expand to new topics", + "frequencyPenalty": "Frequency Penalty", + "frequencyPenaltyDescription": "-2.0 ~ 2.0, the higher the value, the more likely to reduce repetition", + "historyBufferSize": "History Buffer Size", + "historyBufferSizeDescription": "The more context, the more coherent the generated text, the more resources consumed", + "numberOfChoices": "Number of Choices", + "numberOfChoicesDescription": "When greater than 1, it will generate multiple versions of text each time" + }, + "tts": { + "engine": "TTS 引擎", + "model": "TTS Model", + "voice": "TTS Voice", + "language": "TTS Language" + }, + "chatMemberUpdated": "Chat member updated successfully", + "chatMemberRemoved": "Chat member removed successfully", + "chatMemberAdded": "Chat member added successfully", + "confirmBeforeSending": "You can re-record, modify the text content, and click send after confirming there are no errors.", + "chatNoContentYet": "Chat has no content yet.", + "removeChatMember": "Remove member", + "removeChatMemberConfirmation": "Are you sure to remove this member from the chat?", + "addMember": "Add member", + "added": "Added", + "migrateToChat": "Migrate to chat", + "memberJoined": "{{name}} has joined the chat.", + "memberLeft": "{{name}} has left the chat." } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 5f8a1675..405b7832 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -71,8 +71,8 @@ "configuration": "AI 配置", "model": "AI 模型", "type": "AI 类型", - "roleDefinition": "角色定义", - "roleDefinitionPlaceholder": "描述 AI 扮演的角色", + "roleDefinition": "智能体定义", + "roleDefinitionPlaceholder": "描述 AI 扮演的智能体", "temperature": "随机性 (temperature)", "temperatureDescription": "值越高,生成的文本越具创造性,反之则越稳定", "maxTokens": "单次回复限制", @@ -131,21 +131,37 @@ } }, "chatAgent": { - "notFound": "未找到角色", - "created": "角色创建成功", - "updated": "角色更新成功", - "deleted": "角色删除成功", - "name": "角色名称", - "introduction": "角色介绍", - "language": "语言", + "notFound": "未找到智能体", + "created": "智能体创建成功", + "updated": "智能体更新成功", + "deleted": "智能体删除成功", + "type": "类型", + "typeGptDescription": "GPT 智能体可以与您聊天。", + "typeTtsDescription": "TTS 智能体可以将文本转换为语音。", + "typeSttDescription": "STT 智能体可以将语音转换为文本。", + "name": "智能体名称", + "namePlaceholder": "Enjoy 助手", + "nameDescription": "帮助你识别这个智能体", + "description": "智能体描述", + "descriptionPlaceholder": "问我任何问题", + "descriptionDescription": "简短介绍这个智能体(不会包含在提示语中)", "prompt": "提示语", - "engine": "AI 引擎", - "model": "AI 模型", - "temperature": "随机性", - "temperatureDescription": "值越高,生成的文本越具创造性,反之则越稳定", - "ttsEngine": "TTS 引擎", - "ttsModel": "TTS 模型", - "ttsVoice": "TTS 音色" + "promptPlaceholder": "你是我的 AI 助手,请回答我任何问题。", + "promptDescription": "智能体定义" + }, + "chatMember": { + "prompt": "额外提示语", + "promptDescription": "额外提示将添加到智能体的系统提示中。当您希望在该聊天中约束当前智能体的行为时,这非常有用。", + "promptPlaceholder": "例如:请尽可能使用简单句,每次回复不要超过 10 个句子。", + "gptSettings": "LLM 设置", + "ttsSettings": "语音设置", + "moreSettings": "更多设置", + "added": "成员添加成功", + "removed": "成员删除成功", + "notFound": "未找到成员", + "atLeastOneAgent": "聊天至少需要 1 个智能体参与", + "cannotAddMemberToThisChat": "无法将成员添加到此聊天", + "onlyGPTAgentCanBeAddedToThisChat": "只有 GPT 智能体可以添加到此聊天" }, "chat": { "notFound": "未找到聊天", @@ -156,7 +172,23 @@ "language": "语言", "topic": "话题", "members": "成员", - "memberConfig": "成员配置" + "enableChatAssistant": "启用聊天助手", + "enableChatAssistantDescription": "启用后,您可以使用“建议”或“润色”来帮助您聊天。", + "enableAutoTts": "启用自动 TTS", + "enableAutoTtsDescription": "启用后,所有回复将自动转换为语音。", + "generateTopic": "生成话题", + "sttAiServicePlaceholder": "请选择", + "sttAiServiceDescription": "选择服务将您的语音转录为文本。", + "prompt": "额外提示语", + "promptPlaceholder": "例如:你们正在讨论如何学习英语,请不要偏移话题。", + "promptDescription": "额外提示将添加到所有聊天智能体的系统提示中。当您希望在该聊天中约束智能体的行为时,这非常有用。", + "chatSettings": "聊天设置", + "moreSettings": "更多设置", + "memberSettings": "成员设置", + "atLeastOneAgent": "聊天至少需要 1 个智能体参与", + "onlyGPTAgentCanBeAddedToThisChat": "只有 GPT 智能体可以添加到此聊天", + "invalidAgentType": "无效的智能体类型", + "invalidMembers": "无效的成员" } }, "sidebar": { @@ -207,7 +239,7 @@ "displayNotes": "显示笔记", "downloadSegment": "下载选段", "detail": "详情", - "remove": "删除", + "remove": "移除", "share": "分享", "forward": "转发", "loadMore": "加载更多", @@ -302,6 +334,7 @@ "player": "播放器", "quitApp": "退出应用", "openPreferences": "打开设置", + "openCopilot": "打开 Copilot", "playOrPause": "播放/暂停", "playOrPauseRecording": "播放/暂停录音", "startOrStopRecording": "开始/结束录音", @@ -422,6 +455,7 @@ "reloadIsNeededAfterChanged": "更改后需要重新加载", "defaultAiEngine": "默认 AI 引擎", "aiEngine": "AI 引擎", + "aiEngineNotSupported": "AI 引擎不支持", "defaultAiModel": "默认 AI 模型", "lookupAiModel": "智能词典模型", "translateAiModel": "智能翻译模型", @@ -431,7 +465,7 @@ "enjoyAiEngineTips": "使用 EnjoyAI 作为默认 AI 引擎,收费服务。", "openaiKeySaved": "OpenAI 密钥已保存", "openaiConfigSaved": "OpenAI 配置已保存", - "openaiKeyRequired": "未配置 OpenAI 密钥", + "openaiKeyRequired": "未配置 OpenAI 密钥,请在设置中配置", "baseUrl": "接口地址", "customModels": "自定义模型", "customModelsDescription": "自定义模型,设置多个模型时用英文逗号隔开", @@ -440,7 +474,7 @@ "leaveEmptyToUseDefault": "留空则使用默认值", "openaiBaseUrlDescription": "支持所有兼容 OpenAI 风格的 API", "newConversation": "新对话", - "selectAiRole": "选择 AI 角色", + "selectAiRole": "选择 AI 智能体", "chooseFromPresetGpts": "从预设的 GPTs 中选择", "custom": "自定义", "startConversation": "开始对话", @@ -448,6 +482,8 @@ "deleteConversation": "删除对话", "deleteConversationConfirmation": "您确定要删除此对话,以及对话中的所有消息吗?", "noConversationsYet": "还没有对话", + "conversationMigrated": "对话已迁移到聊天", + "conversationDeleted": "对话已删除", "translation": "翻译", "pressEnterToSend": "按 Enter 发送", "send": "发送", @@ -696,8 +732,8 @@ "refresh": "刷新", "deleteChat": "删除聊天", "deleteChatConfirmation": "您确定要删除此聊天吗?", - "deleteChatAgent": "删除角色", - "deleteChatAgentConfirmation": "您确定要删除此角色吗?", + "deleteChatAgent": "删除智能体", + "deleteChatAgentConfirmation": "您确定要删除此智能体吗?", "deleteMessage": "删除消息", "deleteMessageConfirmation": "您确定要删除此消息吗?", "refine": "修改润色", @@ -705,9 +741,10 @@ "editChat": "编辑聊天", "newChat": "新聊天", "addChat": "添加聊天", - "agentsManagement": "角色管理", - "newAgent": "新角色", - "editAgent": "编辑角色", + "recents": "最近聊天", + "agentsManagement": "智能体管理", + "newAgent": "新智能体", + "editAgent": "编辑智能体", "addToChat": "添加到聊天", "introduction": "介绍", "introduceYourself": "自我介绍", @@ -794,5 +831,39 @@ "exportRecordingsSuccess": "导出录音成功", "upgrade": "升级", "upgradeNotice": "Enjoy App v{{version}} 已发布。请升级到最新版本。", - "downloadedTranscriptionFromCloud": "从云端下载了语音文本" + "downloadedTranscriptionFromCloud": "从云端下载了语音文本", + "gpt": { + "engine": "AI 引擎", + "model": "AI 模型", + "temperature": "随机性", + "temperatureDescription": "值越高,生成的文本越具创造性,反之则越稳定", + "maxCompletionTokens": "最大 token 数", + "maxCompletionTokensDescription": "单次交互消耗的最大 token 数,-1 表示无限制", + "presencePenalty": "存在惩罚", + "presencePenaltyDescription": "-2.0 ~ 2.0, 值越大,越有可能扩展到新的话题", + "frequencyPenalty": "频率惩罚", + "frequencyPenaltyDescription": "-2.0 ~ 2.0, 值越大,越有可能降低重复性", + "historyBufferSize": "历史消息数量", + "historyBufferSizeDescription": "上下文越多,生成的文本越具连贯性,消耗的资源也越多", + "numberOfChoices": "生成版本数量", + "numberOfChoicesDescription": "大于 1 时,将每次生成多版本的文本" + }, + "tts": { + "engine": "语音引擎", + "model": "语音模型", + "voice": "语音音色", + "language": "TTS 语言" + }, + "chatMemberUpdated": "聊天成员更新成功", + "chatMemberRemoved": "聊天成员移除成功", + "chatMemberAdded": "聊天成员添加成功", + "confirmBeforeSending": "您可以重新录音、修改文字内容,确认无误后点击发送", + "chatNoContentYet": "聊天还没有内容", + "removeChatMember": "移除群成员", + "removeChatMemberConfirmation": "您确定要将此成员移出群聊吗?", + "addMember": "增加群成员", + "added": "已添加", + "migrateToChat": "迁移到聊天", + "memberJoined": "{{name}} 已加入聊天", + "memberLeft": "{{name}} 已离开聊天" } diff --git a/enjoy/src/main/db/handlers/chat-agents-handler.ts b/enjoy/src/main/db/handlers/chat-agents-handler.ts index 2fd9d445..6639fd29 100644 --- a/enjoy/src/main/db/handlers/chat-agents-handler.ts +++ b/enjoy/src/main/db/handlers/chat-agents-handler.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainEvent } from "electron"; -import { ChatAgent } from "@main/db/models"; -import { FindOptions, WhereOptions, Attributes, Op } from "sequelize"; +import { Chat, ChatAgent, ChatMember } from "@main/db/models"; +import { FindOptions, Attributes, Op } from "sequelize"; import log from "@main/logger"; import { t } from "i18next"; @@ -21,7 +21,7 @@ class ChatAgentsHandler { }; } const agents = await ChatAgent.findAll({ - order: [["name", "ASC"]], + order: [["updatedAt", "DESC"]], where, ...options, }); @@ -71,7 +71,36 @@ class ChatAgentsHandler { if (!agent) { throw new Error(t("models.chatAgent.notFound")); } - agent.destroy(); + + const transaction = await ChatAgent.sequelize.transaction(); + try { + const chatMembers = await ChatMember.findAll({ + where: { + userId: id, + }, + }); + + const chats = await Chat.findAll({ + where: { + id: { + [Op.in]: chatMembers.map((member) => member.chatId), + }, + }, + }); + + for (const chat of chats) { + if ( + chat.members.filter((member) => member.userId !== id).length === 0 + ) { + await chat.destroy({ transaction }); + } + } + await agent.destroy({ transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } } register() { diff --git a/enjoy/src/main/db/handlers/chat-members-handler.ts b/enjoy/src/main/db/handlers/chat-members-handler.ts index bc2907e3..88288720 100644 --- a/enjoy/src/main/db/handlers/chat-members-handler.ts +++ b/enjoy/src/main/db/handlers/chat-members-handler.ts @@ -1,26 +1,110 @@ import { ipcMain, IpcMainEvent } from "electron"; -import { ChatMember } from "@main/db/models"; -import { FindOptions, WhereOptions, Attributes, Op } from "sequelize"; -import downloader from "@main/downloader"; +import { Chat, ChatAgent, ChatMember } from "@main/db/models"; +import { FindOptions, Attributes } from "sequelize"; import log from "@main/logger"; import { t } from "i18next"; -import youtubedr from "@main/youtubedr"; -import { pathToEnjoyUrl } from "@/main/utils"; -const logger = log.scope("db/handlers/chats-handler"); +const logger = log.scope("db/handlers/chat-members-handler"); class ChatMembersHandler { private async findAll( _event: IpcMainEvent, - options: FindOptions> & { query?: string } - ) {} + options: FindOptions> + ) { + const chatMembers = await ChatMember.findAll({ + ...options, + }); + + return chatMembers.map((member) => member.toJSON()); + } + + private async findOne( + _event: IpcMainEvent, + options: FindOptions> + ) { + const chatMember = await ChatMember.findOne({ + ...options, + }); + return chatMember?.toJSON(); + } + + private async create(_event: IpcMainEvent, member: ChatMemberDtoType) { + const chat = await Chat.findOne({ + where: { id: member.chatId }, + }); + + if (!chat) { + throw new Error(t("models.chats.notFound")); + } + + if (["TTS", "STT"].includes(chat.type)) { + throw new Error(t("models.chatMembers.cannotAddMemberToThisChat")); + } + + const chatAgent = await ChatAgent.findOne({ + where: { id: member.userId }, + }); + + if (!chatAgent) { + throw new Error(t("models.chatAgents.notFound")); + } + + if (chatAgent.type !== "GPT") { + throw new Error(t("models.chatMembers.onlyGPTAgentCanBeAddedToThisChat")); + } + + const chatMember = await ChatMember.create(member); + await chatMember.reload(); + return chatMember.toJSON(); + } + + private async update( + _event: IpcMainEvent, + id: string, + member: ChatMemberDtoType + ) { + const chatMember = await ChatMember.findOne({ + where: { id }, + }); + if (!chatMember) { + throw new Error(t("models.chatMember.notFound")); + } + await chatMember.update(member); + return chatMember.toJSON(); + } + + private async destroy(_event: IpcMainEvent, id: string) { + const chatMember = await ChatMember.findOne({ + where: { id }, + }); + const chatMembers = await ChatMember.findAll({ + where: { chatId: chatMember.chatId }, + }); + if ( + chatMembers.filter((member) => member.userType === "ChatAgent").length <= + 1 + ) { + throw new Error(t("models.chatMember.atLeastOneAgent")); + } + + await chatMember.destroy(); + return chatMember.toJSON(); + } register() { ipcMain.handle("chat-members-find-all", this.findAll); + ipcMain.handle("chat-members-find-one", this.findOne); + ipcMain.handle("chat-members-create", this.create); + ipcMain.handle("chat-members-update", this.update); + ipcMain.handle("chat-members-destroy", this.destroy); } unregister() { ipcMain.removeHandler("chat-members-find-all"); + ipcMain.removeHandler("chat-members-find-one"); + ipcMain.removeHandler("chat-members-create"); + ipcMain.removeHandler("chat-members-update"); + ipcMain.removeHandler("chat-members-destroy"); } } diff --git a/enjoy/src/main/db/handlers/chat-messages-handler.ts b/enjoy/src/main/db/handlers/chat-messages-handler.ts index cde15f10..5320535e 100644 --- a/enjoy/src/main/db/handlers/chat-messages-handler.ts +++ b/enjoy/src/main/db/handlers/chat-messages-handler.ts @@ -2,8 +2,9 @@ import { ipcMain, IpcMainEvent } from "electron"; import { ChatMessage, Recording } from "@main/db/models"; import { FindOptions, WhereOptions, Attributes, Op } from "sequelize"; import log from "@main/logger"; -import { enjoyUrlToPath, pathToEnjoyUrl } from "@/main/utils"; +import { enjoyUrlToPath } from "@/main/utils"; import fs from "fs-extra"; +import { ChatMessageStateEnum } from "@/types/enums"; const logger = log.scope("db/handlers/chats-handler"); @@ -52,34 +53,45 @@ class ChatMessagesHandler { delete data.recordingUrl; const transaction = await ChatMessage.sequelize.transaction(); - const message = await ChatMessage.create(data); + try { + const message = await ChatMessage.create(data); - if (recordingUrl) { - // create new recording - const filePath = enjoyUrlToPath(recordingUrl); - const blob = fs.readFileSync(filePath); - const recording = await Recording.createFromBlob( - { - type: "audio/wav", - arrayBuffer: blob, - }, - { - targetType: "ChatMessage", - targetId: message.id, - }, - transaction - ); - message.recording = recording; + if (recordingUrl) { + // create new recording + const filePath = enjoyUrlToPath(recordingUrl); + const blob = fs.readFileSync(filePath); + const recording = await Recording.createFromBlob( + { + type: "audio/wav", + arrayBuffer: blob, + }, + { + targetType: "ChatMessage", + targetId: message.id, + }, + transaction + ); + message.recording = recording; + } + + await transaction.commit(); + + return (await message.reload()).toJSON(); + } catch (error) { + await transaction.rollback(); + logger.error(error); + throw error; } - await transaction.commit(); - - return (await message.reload()).toJSON(); } private async update( _event: IpcMainEvent, id: string, - data: { state?: string; content?: string; recordingUrl?: string } + data: { + state?: ChatMessageStateEnum; + content?: string; + recordingUrl?: string; + } ) { const { recordingUrl } = data; delete data.recordingUrl; @@ -89,40 +101,46 @@ class ChatMessagesHandler { const transaction = await ChatMessage.sequelize.transaction(); - // update content - await message.update({ ...data }, { transaction }); + try { + // update content + await message.update({ ...data }, { transaction }); - if (recordingUrl) { - // destroy existing recording - await message.recording?.destroy({ transaction }); + if (recordingUrl) { + // destroy existing recording + await message.recording?.destroy({ transaction }); - // create new recording - const filePath = enjoyUrlToPath(recordingUrl); - const blob = fs.readFileSync(filePath); - const recording = await Recording.createFromBlob( - { - type: "audio/wav", - arrayBuffer: blob, - }, - { - targetType: "ChatMessage", - targetId: message.id, - referenceText: message.content, - }, - transaction - ); - message.recording = recording; - } else if (message.recording) { - message.recording.update( - { - referenceText: message.content, - }, - { transaction } - ); + // create new recording + const filePath = enjoyUrlToPath(recordingUrl); + const blob = fs.readFileSync(filePath); + const recording = await Recording.createFromBlob( + { + type: "audio/wav", + arrayBuffer: blob, + }, + { + targetType: "ChatMessage", + targetId: message.id, + referenceText: message.content, + }, + transaction + ); + message.recording = recording; + } else if (message.recording) { + await message.recording.update( + { + referenceText: message.content, + }, + { transaction } + ); + } + await transaction.commit(); + + return (await message.reload()).toJSON(); + } catch (error) { + await transaction.rollback(); + logger.error(error); + throw error; } - await transaction.commit(); - - return (await message.reload()).toJSON(); } private async destroy(_event: IpcMainEvent, id: string) { diff --git a/enjoy/src/main/db/handlers/chats-handler.ts b/enjoy/src/main/db/handlers/chats-handler.ts index 05631891..c1b57763 100644 --- a/enjoy/src/main/db/handlers/chats-handler.ts +++ b/enjoy/src/main/db/handlers/chats-handler.ts @@ -11,17 +11,37 @@ const logger = log.scope("db/handlers/chats-handler"); class ChatsHandler { private async findAll( _event: IpcMainEvent, - options: FindOptions> & { query?: string } + options: FindOptions> & { + query?: string; + chatAgentId?: string; + } ) { - const { query, where = {} } = options || {}; + const { query, where = {}, chatAgentId } = options || {}; delete options.query; delete options.where; + delete options.chatAgentId; if (query) { (where as any).name = { [Op.like]: `%${query}%`, }; } + + let chatIds; + if (chatAgentId) { + const chatMembers = await ChatMember.findAll({ + where: { + userId: chatAgentId, + userType: "ChatAgent", + }, + }); + chatIds = chatMembers.map((member) => member.chatId); + + (where as any)["id"] = { + [Op.in]: chatIds, + }; + } + const chats = await Chat.findAll({ order: [["updatedAt", "DESC"]], where, @@ -37,116 +57,76 @@ class ChatsHandler { private async findOne( _event: IpcMainEvent, options: FindOptions> & { - where: WhereOptions>; + not: WhereOptions>; } ) { - const chat = await Chat.findOne(options); + const { not } = options; + if (not) { + options.where = { + ...options.where, + [Op.not]: not, + }; + delete options.not; + } + const chat = await Chat.findOne({ + order: [["updatedAt", "DESC"]], + ...options, + }); if (!chat) { return null; } return chat.toJSON(); } - private async create( - _event: IpcMainEvent, - data: { - name: string; - language: string; - topic: string; - config: { - sttEngine: string; - }; - members: Array<{ - userId: string; - userType: string; - config?: { - prompt?: string; - introduction?: string; - }; - }>; - } - ) { + private async create(_event: IpcMainEvent, data: ChatDtoType) { const { members, ...chatData } = data; if (!members || members.length === 0) { - throw new Error(t("models.chats.membersRequired")); + throw new Error(t("models.chat.atLeastOneAgent")); + } + + const chatAgents = await ChatAgent.findAll({ + where: { + id: { + [Op.in]: members.map((m) => m.userId), + }, + }, + }); + + if (chatAgents.length !== members.length) { + throw new Error(t("models.chat.invalidMembers")); + } + + let type: "CONVERSATION" | "GROUP" | "TTS"; + if (chatAgents.length === 1 && chatAgents[0].type === "TTS") { + type = "TTS"; + } else if (chatAgents.length === 1 && chatAgents[0].type === "GPT") { + type = "CONVERSATION"; + } else if ( + chatAgents.length > 1 && + chatAgents.every((agent) => agent.type === "GPT") + ) { + type = "GROUP"; + } else { + throw new Error(t("models.chat.invalidMembers")); } const transaction = await db.connection.transaction(); - if (!chatData.config?.sttEngine) { - chatData.config.sttEngine = (await UserSetting.get( - UserSettingKeyEnum.STT_ENGINE - )) as string; - } - const chat = await Chat.create(chatData, { - transaction, - }); - for (const member of members) { - await ChatMember.create( + try { + if (!chatData.config?.sttEngine) { + chatData.config.sttEngine = (await UserSetting.get( + UserSettingKeyEnum.STT_ENGINE + )) as string; + } + const chat = await Chat.create( { - chatId: chat.id, - ...member, + type, + ...chatData, }, { - include: [Chat], transaction, } ); - } - await transaction.commit(); - await chat.reload(); - - return chat.toJSON(); - } - - private async update( - _event: IpcMainEvent, - id: string, - data: { - name: string; - language: string; - topic: string; - config: { - sttEngine: string; - }; - members: Array<{ - userId: string; - userType: string; - config: { - prompt?: string; - introduction?: string; - }; - }>; - } - ) { - const { members, ...chatData } = data; - if (!members || members.length === 0) { - throw new Error(t("models.chats.membersRequired")); - } - const chat = await Chat.findOne({ - where: { id }, - }); - if (!chat) { - throw new Error(t("models.chats.notFound")); - } - - const transaction = await db.connection.transaction(); - await chat.update(chatData, { transaction }); - - // Remove members - for (const member of chat.members) { - if (member.userType === "User") continue; - if (!members.find((m) => m.userId === member.userId)) { - await member.destroy({ transaction }); - } - } - - // Add or update members - for (const member of members) { - const chatMember = chat.members.find((m) => m.userId === member.userId); - - if (chatMember) { - await chatMember.update(member, { transaction }); - } else { + for (const member of members) { await ChatMember.create( { chatId: chat.id, @@ -158,23 +138,46 @@ class ChatsHandler { } ); } + await transaction.commit(); + await chat.reload(); + return chat.toJSON(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + private async update(_event: IpcMainEvent, id: string, data: ChatDtoType) { + const chat = await Chat.findOne({ + where: { id }, + }); + if (!chat) { + throw new Error(t("models.chats.notFound")); } - await transaction.commit(); - await chat.reload({ - include: [ - { - association: Chat.associations.members, - include: [ - { - association: ChatMember.associations.agent, - }, - ], - }, - ], - }); + try { + await chat.update({ + name: data.name, + config: data.config, + }); + await chat.reload({ + include: [ + { + association: Chat.associations.members, + include: [ + { + association: ChatMember.associations.agent, + }, + ], + }, + ], + }); - return chat.toJSON(); + return chat.toJSON(); + } catch (error) { + logger.error(error); + throw error; + } } private async destroy(_event: IpcMainEvent, id: string) { diff --git a/enjoy/src/main/db/handlers/conversations-handler.ts b/enjoy/src/main/db/handlers/conversations-handler.ts index a2b0ba20..c7a4eaba 100644 --- a/enjoy/src/main/db/handlers/conversations-handler.ts +++ b/enjoy/src/main/db/handlers/conversations-handler.ts @@ -113,12 +113,23 @@ class ConversationsHandler { }); } + private async migrate(_event: IpcMainEvent, id: string) { + const conversation = await Conversation.findOne({ + where: { id }, + }); + if (!conversation) { + throw new Error(t("models.conversation.notFound")); + } + await conversation.migrateToChat(); + } + register() { ipcMain.handle("conversations-find-all", this.findAll); ipcMain.handle("conversations-find-one", this.findOne); ipcMain.handle("conversations-create", this.create); ipcMain.handle("conversations-update", this.update); ipcMain.handle("conversations-destroy", this.destroy); + ipcMain.handle("conversations-migrate", this.migrate); } unregister() { @@ -127,6 +138,7 @@ class ConversationsHandler { ipcMain.removeHandler("conversations-create"); ipcMain.removeHandler("conversations-update"); ipcMain.removeHandler("conversations-destroy"); + ipcMain.removeHandler("conversations-migrate"); } } diff --git a/enjoy/src/main/db/handlers/user-settings-handler.ts b/enjoy/src/main/db/handlers/user-settings-handler.ts index 1c8bae24..a97663c6 100644 --- a/enjoy/src/main/db/handlers/user-settings-handler.ts +++ b/enjoy/src/main/db/handlers/user-settings-handler.ts @@ -4,61 +4,25 @@ import db from "@main/db"; import { UserSettingKeyEnum } from "@/types/enums"; class UserSettingsHandler { - private async get(event: IpcMainEvent, key: UserSettingKeyEnum) { - return UserSetting.get(key) - .then((value) => { - return value; - }) - .catch((err) => { - event.sender.send("on-notification", { - type: "error", - message: err.message, - }); - }); + private async get(_event: IpcMainEvent, key: UserSettingKeyEnum) { + return await UserSetting.get(key); } private async set( - event: IpcMainEvent, + _event: IpcMainEvent, key: UserSettingKeyEnum, value: string | object ) { - return UserSetting.set(key, value) - .then(() => { - return; - }) - .catch((err) => { - event.sender.send("on-notification", { - type: "error", - message: err.message, - }); - }); + await UserSetting.set(key, value); } - private async delete(event: IpcMainEvent, key: UserSettingKeyEnum) { - return UserSetting.destroy({ where: { key } }) - .then(() => { - return; - }) - .catch((err) => { - event.sender.send("on-notification", { - type: "error", - message: err.message, - }); - }); + private async delete(_event: IpcMainEvent, key: UserSettingKeyEnum) { + await UserSetting.destroy({ where: { key } }); } - private async clear(event: IpcMainEvent) { - return UserSetting.destroy({ where: {} }) - .then(() => { - db.connection.query("VACUUM"); - return; - }) - .catch((err) => { - event.sender.send("on-notification", { - type: "error", - message: err.message, - }); - }); + private async clear(_event: IpcMainEvent) { + await UserSetting.destroy({ where: {} }); + db.connection.query("VACUUM"); } register() { diff --git a/enjoy/src/main/db/index.ts b/enjoy/src/main/db/index.ts index 1efc47d8..74faa216 100644 --- a/enjoy/src/main/db/index.ts +++ b/enjoy/src/main/db/index.ts @@ -44,6 +44,7 @@ import url from "url"; import { i18n } from "@main/i18n"; import { UserSettingKeyEnum } from "@/types/enums"; import log from "@main/logger"; +import fs from "fs-extra"; const __filename = url.fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -55,6 +56,8 @@ const db = { disconnect: async () => {}, registerIpcHandlers: () => {}, isConnecting: false, + backup: async (options?: { force: boolean }) => {}, + restore: async (backupFilePath: string) => {}, }; const handlers = [ @@ -163,21 +166,43 @@ db.connect = async () => { logger: logger, }); - try { - // migrate up to the latest state - await umzug.up(); + const pendingMigrations = await umzug.pending(); + logger.info(pendingMigrations); + if (pendingMigrations.length > 0) { + try { + await db.backup({ force: true }); - await sequelize.query("PRAGMA foreign_keys = false;"); - await sequelize.sync(); - await sequelize.authenticate(); - } catch (err) { - logger.error(err); - await sequelize.close(); - throw err; + // migrate up to the latest state + await umzug.up(); + + await sequelize.query("PRAGMA foreign_keys = false;"); + await sequelize.sync(); + await sequelize.authenticate(); + } catch (err) { + logger.error(err); + await sequelize.close(); + throw err; + } + + const pendingMigrationTimestamp = pendingMigrations[0].name.split("-")[0]; + if (parseInt(pendingMigrationTimestamp) <= 1725411577564) { + // migrate settings + logger.info("Migrating settings"); + await UserSetting.migrateFromSettings(); + } + + if (parseInt(pendingMigrationTimestamp) <= 1726781106038) { + // migrate chat agents + logger.info("Migrating chat agents"); + await ChatAgent.migrateConfigToChatMember(); + } + } else { + await db.backup(); } - // migrate settings - await UserSetting.migrateFromSettings(); + // vacuum the database + logger.info("Vacuuming the database"); + await sequelize.query("VACUUM"); // initialize i18n const language = (await UserSetting.get( @@ -185,11 +210,8 @@ db.connect = async () => { )) as string; i18n(language); - // vacuum the database - await sequelize.query("VACUUM"); - // register handlers - + logger.info(`Registering handlers`); for (const handler of handlers) { handler.register(); } @@ -214,6 +236,75 @@ db.disconnect = async () => { db.connection = null; }; +db.backup = async (options?: { force: boolean }) => { + const force = options?.force ?? false; + + const dbPath = settings.dbPath(); + if (!dbPath) { + logger.error("Db path is not ready"); + return; + } + + const backupPath = path.join(settings.userDataPath(), "backup"); + fs.ensureDirSync(backupPath); + + const backupFiles = fs + .readdirSync(backupPath) + .filter((file) => file.startsWith(path.basename(dbPath))) + .sort(); + + // Check if the last backup is older than 1 day + const lastBackup = backupFiles.pop(); + const timestamp = lastBackup?.match(/\d{13}/)?.[0]; + if ( + !force && + lastBackup && + timestamp && + new Date(parseInt(timestamp)) > new Date(Date.now() - 1000 * 60 * 60 * 24) + ) { + logger.info(`Backup is up to date: ${lastBackup}`); + return; + } + + // Only keep the latest 10 backups + if (backupFiles.length >= 10) { + fs.removeSync(path.join(backupPath, backupFiles[0])); + } + + const backupFilePath = path.join( + backupPath, + `${path.basename(dbPath)}.${Date.now().toString().padStart(13, "0")}` + ); + fs.copySync(dbPath, backupFilePath); + + logger.info(`Backup created at ${backupFilePath}`); +}; + +db.restore = async (backupFilePath: string) => { + const dbPath = settings.dbPath(); + if (!dbPath) { + logger.error("Db path is not ready"); + return; + } + + if (!fs.existsSync(backupFilePath)) { + logger.error(`Backup file not found at ${backupFilePath}`); + return; + } + + try { + await db.disconnect(); + + fs.copySync(backupFilePath, dbPath); + logger.info(`Database restored from ${backupFilePath}`); + } catch (err) { + logger.error(err); + throw err; + } finally { + db.connect(); + } +}; + db.registerIpcHandlers = () => { ipcMain.handle("db-connect", async () => { if (db.isConnecting) @@ -242,6 +333,14 @@ db.registerIpcHandlers = () => { ipcMain.handle("db-disconnect", async () => { db.disconnect(); }); + + ipcMain.handle("db-backup", async () => { + db.backup(); + }); + + ipcMain.handle("db-restore", async (_, backupFilePath: string) => { + db.restore(backupFilePath); + }); }; export default db; diff --git a/enjoy/src/main/db/migrations/1726781106038-modify-chat.js b/enjoy/src/main/db/migrations/1726781106038-modify-chat.js new file mode 100644 index 00000000..79de45c3 --- /dev/null +++ b/enjoy/src/main/db/migrations/1726781106038-modify-chat.js @@ -0,0 +1,149 @@ +import { DataTypes } from "sequelize"; + +async function up({ context: queryInterface }) { + await queryInterface.addColumn("chats", "type", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.removeColumn("chats", "language", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.removeColumn("chats", "topic", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.renameColumn( + "chat_agents", + "introduction", + "description" + ); + + await queryInterface.addColumn("chat_agents", "type", { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "GPT", + }); + + await queryInterface.addColumn("chat_agents", "avatar_url", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chat_agents", "source", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.removeColumn("chat_agents", "language", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chat_messages", "role", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chat_messages", "category", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chat_messages", "agent_id", { + type: DataTypes.UUID, + allowNull: true, + }); + + await queryInterface.addColumn("chat_messages", "mentions", { + type: DataTypes.JSON, + defaultValue: [], + allowNull: true, + }); + + await queryInterface.changeColumn("chat_messages", "member_id", { + type: DataTypes.UUID, + allowNull: true, + }); + + await queryInterface.addIndex("chat_members", ["chat_id", "user_id"], { + unique: true, + }); +} + +async function down({ context: queryInterface }) { + await queryInterface.removeColumn("chats", "type", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chats", "language", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chats", "topic", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.renameColumn( + "chat_agents", + "description", + "introduction" + ); + + await queryInterface.removeColumn("chat_agents", "type", { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "GPT", + }); + + await queryInterface.removeColumn("chat_agents", "avatar_url", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.removeColumn("chat_agents", "source", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.addColumn("chat_agents", "language", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.removeColumn("chat_messages", "mentions", { + type: DataTypes.JSON, + defaultValue: [], + allowNull: true, + }); + + await queryInterface.removeColumn("chat_messages", "role", { + type: DataTypes.STRING, + allowNull: false, + }); + + await queryInterface.removeColumn("chat_messages", "category", { + type: DataTypes.STRING, + allowNull: true, + }); + + await queryInterface.removeColumn("chat_messages", "agent_id", { + type: DataTypes.UUID, + allowNull: true, + }); + + await queryInterface.changeColumn("chat_messages", "member_id", { + type: DataTypes.UUID, + allowNull: false, + }); + + await queryInterface.removeIndex("chat_members", ["chat_id", "user_id"]); +} + +export { up, down }; diff --git a/enjoy/src/main/db/models/chat-agent.ts b/enjoy/src/main/db/models/chat-agent.ts index 0ee7bc4e..20e0ab15 100644 --- a/enjoy/src/main/db/models/chat-agent.ts +++ b/enjoy/src/main/db/models/chat-agent.ts @@ -9,12 +9,18 @@ import { DataType, AfterCreate, AllowNull, - BeforeDestroy, HasMany, + BeforeSave, } from "sequelize-typescript"; import mainWindow from "@main/window"; import log from "@main/logger"; -import { ChatMember } from "@main/db/models"; +import { Chat, ChatMember, ChatMessage, UserSetting } from "@main/db/models"; +import { + ChatAgentTypeEnum, + ChatMessageRoleEnum, + UserSettingKeyEnum, +} from "@/types/enums"; +import { DEFAULT_GPT_CONFIG } from "@/constants"; const logger = log.scope("db/models/chat-agent"); @Table({ @@ -29,16 +35,21 @@ export class ChatAgent extends Model { @Column({ primaryKey: true, type: DataType.UUID }) id: string; + @Column(DataType.STRING) + type: ChatAgentTypeEnum; + @AllowNull(false) @Column(DataType.STRING) name: string; @Column(DataType.STRING) - introduction: string; + description: string; - @AllowNull(false) @Column(DataType.STRING) - language: string; + avatarUrl: string; + + @Column(DataType.STRING) + source: string; @Column(DataType.JSON) config: any; @@ -51,48 +62,11 @@ export class ChatAgent extends Model { }) members: ChatMember[]; - @Column(DataType.VIRTUAL) - get avatarUrl(): string { - return `https://api.dicebear.com/9.x/thumbs/svg?seed=${this.getDataValue( - "name" - )}`; - } - - @Column(DataType.VIRTUAL) - get engine(): string { - return this.getDataValue("config")?.engine; - } - - @Column(DataType.VIRTUAL) - get model(): string { - return this.getDataValue("config")?.model; - } - @Column(DataType.VIRTUAL) get prompt(): string { return this.getDataValue("config")?.prompt; } - @Column(DataType.VIRTUAL) - get temperature(): number { - return this.getDataValue("config")?.temperature; - } - - @Column(DataType.VIRTUAL) - get ttsEngine(): string { - return this.getDataValue("config")?.ttsEngine; - } - - @Column(DataType.VIRTUAL) - get ttsModel(): string { - return this.getDataValue("config")?.ttsModel; - } - - @Column(DataType.VIRTUAL) - get ttsVoice(): string { - return this.getDataValue("config")?.ttsVoice; - } - @AfterCreate static notifyForCreate(chatAgent: ChatAgent) { this.notify(chatAgent, "create"); @@ -119,8 +93,123 @@ export class ChatAgent extends Model { }); } - @BeforeDestroy + @BeforeSave + static setupDefaultAvatar(chatAgent: ChatAgent) { + if (!chatAgent.avatarUrl) { + chatAgent.avatarUrl = `https://api.dicebear.com/9.x/shapes/svg?seed=${encodeURIComponent( + chatAgent.name + )}`; + } + } + + @AfterDestroy static destroyMembers(chatAgent: ChatAgent) { ChatMember.destroy({ where: { userId: chatAgent.id } }); } + + // Migrate old data structure before v0.6.0 to new data structure + static async migrateConfigToChatMember() { + logger.info("Migrating config to chat member"); + const chatAgents = await ChatAgent.findAll({ + include: [ChatMember], + }); + for (const chatAgent of chatAgents) { + if (!chatAgent.config.engine) return; + + const tx = await ChatAgent.sequelize.transaction(); + const learningLanguage = await UserSetting.get( + UserSettingKeyEnum.LEARNING_LANGUAGE + ); + logger.info("Migrating from chat agent", chatAgent.id); + for (const member of chatAgent.members) { + logger.info("Migrating to chat member", member.id); + const chatMessages = await ChatMessage.findAll({ + where: { + memberId: member.id, + }, + }); + + if (member.userType === "Agent") { + member.userType = "ChatAgent"; + member.config = { + ...member.config, + gpt: { + ...DEFAULT_GPT_CONFIG, + engine: chatAgent.config.engine, + model: chatAgent.config.model, + temperature: chatAgent.config.temperature, + }, + tts: { + engine: chatAgent.config.ttsEngine, + model: chatAgent.config.ttsModel, + language: learningLanguage, + voice: chatAgent.config.ttsVoice, + }, + }; + for (const chatMessage of chatMessages) { + await chatMessage.update( + { + role: ChatMessageRoleEnum.AGENT, + agentId: chatAgent.id, + }, + { + transaction: tx, + hooks: false, + } + ); + } + + await member.save({ transaction: tx, hooks: false }); + } + } + await chatAgent.update( + { + type: ChatAgentTypeEnum.GPT, + avatarUrl: `https://api.dicebear.com/9.x/shapes/svg?seed=${chatAgent.name}`, + config: { + prompt: chatAgent.config.prompt, + }, + }, + { + transaction: tx, + } + ); + await tx.commit(); + } + + const members = await ChatMember.findAll({ + where: { + userType: "User", + }, + }); + const tx = await ChatAgent.sequelize.transaction(); + for (const member of members) { + const chatMessages = await ChatMessage.findAll({ + where: { + memberId: member.id, + }, + }); + for (const chatMessage of chatMessages) { + await chatMessage.update( + { + role: ChatMessageRoleEnum.USER, + agentId: null, + memberId: null, + }, + { + transaction: tx, + hooks: false, + } + ); + } + await member.destroy({ transaction: tx, hooks: false }); + } + await tx.commit(); + + Chat.findAll().then((chats) => { + for (const chat of chats) { + chat.update({ updatedAt: new Date() }); + } + }); + } } diff --git a/enjoy/src/main/db/models/chat-member.ts b/enjoy/src/main/db/models/chat-member.ts index 018af3fa..c7be4c82 100644 --- a/enjoy/src/main/db/models/chat-member.ts +++ b/enjoy/src/main/db/models/chat-member.ts @@ -14,8 +14,9 @@ import { Scopes, } from "sequelize-typescript"; import log from "@main/logger"; -import settings from "@main/settings"; import { Chat, ChatAgent, ChatMessage } from "@main/db/models"; +import mainWindow from "@main/window"; +import { ChatMessageCategoryEnum, ChatMessageRoleEnum } from "@/types/enums"; const logger = log.scope("db/models/chat-member"); @Table({ @@ -68,46 +69,82 @@ export class ChatMember extends Model { @Column(DataType.VIRTUAL) get name(): string { - if (this.userType === "User") { - return this.user.name; - } else if (this.userType === "Agent") { - return this.agent.name; - } - return ""; + return this.agent?.name; } - @Column(DataType.VIRTUAL) - get user(): { - name: string; - avatarUrl: string; - } { - if (this.userType === "User") { - const user = settings.getSync("user") as { - name: string; - avatarUrl: string; - }; + @AfterCreate + static async updateChats(member: ChatMember) { + const agent = await ChatAgent.findByPk(member.userId); + if (agent) { + agent.changed("updatedAt", true); + agent.update({ updatedAt: new Date() }, { hooks: false }); + } - if (!user.avatarUrl) { - user.avatarUrl = `https://api.dicebear.com/9.x/thumbs/svg?seed=${user.name}`; - } - - return user; - } else { - return null; + const chat = await Chat.findByPk(member.chatId); + if (chat) { + chat.changed("updatedAt", true); + chat.update({ updatedAt: new Date() }, { hooks: false }); } } @AfterCreate - @AfterUpdate - @AfterDestroy - static async updateChats(member: ChatMember) { - const chat = await Chat.findByPk(member.chatId); - if (!chat) return; - await chat.update({ updatedAt: new Date() }); + static async chatSystemAddedMessage(member: ChatMember) { + const chatAgent = await ChatAgent.findByPk(member.userId); + ChatMessage.create({ + chatId: member.chatId, + content: `${chatAgent.name} has joined the chat.`, + agentId: chatAgent.id, + role: ChatMessageRoleEnum.SYSTEM, + category: ChatMessageCategoryEnum.MEMBER_JOINED, + }); } - @BeforeDestroy + @AfterDestroy static async destroyMessages(member: ChatMember) { - ChatMessage.destroy({ where: { memberId: member.id } }); + ChatMessage.destroy({ where: { memberId: member.id }, hooks: false }); + + ChatAgent.findByPk(member.userId).then((chatAgent) => { + if (!chatAgent) return; + + ChatMessage.create({ + chatId: member.chatId, + content: `${chatAgent.name} has left the chat.`, + agentId: chatAgent.id, + role: ChatMessageRoleEnum.SYSTEM, + category: ChatMessageCategoryEnum.MEMBER_LEFT, + }); + }); + } + + @AfterCreate + static notifyForCreate(member: ChatMember) { + this.notify(member, "create"); + } + + @AfterUpdate + static notifyForUpdate(member: ChatMember) { + this.notify(member, "update"); + } + + @AfterDestroy + static notifyForDestroy(member: ChatMember) { + this.notify(member, "destroy"); + } + + static async notify( + member: ChatMember, + action: "create" | "update" | "destroy" + ) { + if (!mainWindow.win) return; + + if (action !== "destroy" && !member.agent) { + await member.reload(); + } + mainWindow.win.webContents.send("db-on-transaction", { + model: "ChatMember", + id: member.id, + action, + record: member.toJSON(), + }); } } diff --git a/enjoy/src/main/db/models/chat-message.ts b/enjoy/src/main/db/models/chat-message.ts index e0b28060..38b483dc 100644 --- a/enjoy/src/main/db/models/chat-message.ts +++ b/enjoy/src/main/db/models/chat-message.ts @@ -12,10 +12,22 @@ import { AllowNull, Scopes, HasOne, + BeforeSave, } from "sequelize-typescript"; import mainWindow from "@main/window"; import log from "@main/logger"; -import { Chat, ChatMember, Recording, Speech } from "@main/db/models"; +import { + Chat, + ChatAgent, + ChatMember, + Recording, + Speech, +} from "@main/db/models"; +import { + ChatMessageCategoryEnum, + ChatMessageRoleEnum, + ChatMessageStateEnum, +} from "@/types/enums"; const logger = log.scope("db/models/chat-message"); @Table({ @@ -30,11 +42,10 @@ const logger = log.scope("db/models/chat-message"); { association: ChatMessage.associations.member, model: ChatMember, - include: [ - { - association: ChatMember.associations.agent, - }, - ], + }, + { + association: ChatMessage.associations.agent, + model: ChatAgent, }, { association: ChatMessage.associations.recording, @@ -63,9 +74,22 @@ export class ChatMessage extends Model { @Column(DataType.UUID) chatId: string; - @AllowNull(false) + @AllowNull(true) + @Column(DataType.STRING) + role: ChatMessageRoleEnum; + + @Default("DEFAULT") + @Column(DataType.STRING) + category: ChatMessageCategoryEnum; + @Column(DataType.UUID) - memberId: string; + memberId: string | null; + + @Column(DataType.UUID) + agentId: string | null; + + @Column(DataType.JSON) + mentions: string[]; @Column(DataType.TEXT) content: string; @@ -73,7 +97,7 @@ export class ChatMessage extends Model { @AllowNull(false) @Default("pending") @Column(DataType.STRING) - state: string; + state: ChatMessageStateEnum; @BelongsTo(() => Chat, { foreignKey: "chatId", @@ -87,6 +111,12 @@ export class ChatMessage extends Model { }) member: ChatMember; + @BelongsTo(() => ChatAgent, { + foreignKey: "agentId", + constraints: false, + }) + agent: ChatAgent; + @HasOne(() => Recording, { foreignKey: "targetId", constraints: false, @@ -105,6 +135,49 @@ export class ChatMessage extends Model { }) speech: Speech; + @BeforeSave + static async setupRole(chatMessage: ChatMessage) { + if (chatMessage.role) return; + + if (chatMessage.memberId) { + chatMessage.role = ChatMessageRoleEnum.AGENT; + } else { + chatMessage.role = ChatMessageRoleEnum.USER; + } + } + + @BeforeSave + static async setupAgentId(chatMessage: ChatMessage) { + if (chatMessage.agentId) return; + if (!chatMessage.memberId) return; + + const member = await ChatMember.findByPk(chatMessage.memberId); + if (!member) return; + + chatMessage.agentId = member.userId; + } + + @AfterCreate + static async updateChat(chatMessage: ChatMessage) { + const chat = await Chat.findByPk(chatMessage.chatId); + if (chat) { + chat.changed("updatedAt", true); + chat.update({ updatedAt: new Date() }, { hooks: false }); + } + + const member = await ChatMember.findByPk(chatMessage.memberId, { + include: [ + { + association: ChatMember.associations.agent, + }, + ], + }); + if (member?.agent) { + member.agent.changed("updatedAt", true); + member.agent.update({ updatedAt: new Date() }, { hooks: false }); + } + } + @AfterCreate static async notifyForCreate(chatMessage: ChatMessage) { ChatMessage.notify(chatMessage, "create"); @@ -126,6 +199,10 @@ export class ChatMessage extends Model { ) { if (!mainWindow.win) return; + if (action !== "destroy" && !chatMessage.agent) { + await chatMessage.reload(); + } + mainWindow.win.webContents.send("db-on-transaction", { model: "ChatMessage", id: chatMessage.id, diff --git a/enjoy/src/main/db/models/chat.ts b/enjoy/src/main/db/models/chat.ts index b8f247fb..12c65798 100644 --- a/enjoy/src/main/db/models/chat.ts +++ b/enjoy/src/main/db/models/chat.ts @@ -11,11 +11,13 @@ import { AllowNull, HasMany, Scopes, - BeforeDestroy, + BeforeSave, } from "sequelize-typescript"; import log from "@main/logger"; import { ChatAgent, ChatMember, ChatMessage } from "@main/db/models"; import mainWindow from "@main/window"; +import { t } from "i18next"; +import { ChatAgentTypeEnum, ChatTypeEnum } from "@/types/enums"; const logger = log.scope("db/models/chat"); @Table({ @@ -48,17 +50,13 @@ export class Chat extends Model { @Column({ primaryKey: true, type: DataType.UUID }) id: string; + @Column(DataType.STRING) + type: ChatTypeEnum; + @AllowNull(false) @Column(DataType.STRING) name: string; - @Column(DataType.TEXT) - topic: string; - - @AllowNull(false) - @Column(DataType.STRING) - language: string; - @Column(DataType.TEXT) digest: string; @@ -110,15 +108,67 @@ export class Chat extends Model { static async notify(chat: Chat, action: "create" | "update" | "destroy") { if (!mainWindow.win) return; + if ( + action !== "destroy" && + (!chat.members || !chat.members.some((m) => m.agent)) + ) { + chat.members = await ChatMember.findAll({ + where: { chatId: chat.id }, + include: [ + { + association: "agent", + }, + ], + }); + } mainWindow.win.webContents.send("db-on-transaction", { model: "Chat", id: chat.id, - action: action, + action, record: chat.toJSON(), }); } - @BeforeDestroy + @BeforeSave + static async setupChatType(chat: Chat) { + if (chat.isNewRecord && chat.type) { + return; + } + + const members = await ChatMember.findAll({ + where: { chatId: chat.id }, + }); + + if (members.length < 1) { + throw new Error(t("models.chat.atLeastOneAgent")); + } else if (members.length > 1) { + // For group chat, all members must be GPT agent + if (members.some((m) => m.agent?.type !== ChatAgentTypeEnum.GPT)) { + throw new Error(t("models.chat.onlyGPTAgentCanBeAddedToThisChat")); + } + chat.type = ChatTypeEnum.GROUP; + } else { + const agent = members[0].agent; + if (!agent) { + logger.error("Chat.setupChatType: agent not found", chat.id); + throw new Error(t("models.chat.atLeastOneAgent")); + } + + switch (agent.type) { + case ChatAgentTypeEnum.GPT: + chat.type = ChatTypeEnum.CONVERSATION; + break; + case ChatAgentTypeEnum.TTS: + chat.type = ChatTypeEnum.TTS; + break; + default: + logger.error("Chat.setupChatType: invalid agent type", chat.id); + throw new Error(t("models.chat.invalidAgentType")); + } + } + } + + @AfterDestroy static async destroyMembers(chat: Chat) { ChatMember.destroy({ where: { chatId: chat.id } }); } diff --git a/enjoy/src/main/db/models/conversation.ts b/enjoy/src/main/db/models/conversation.ts index 2fb8e1ad..65692495 100644 --- a/enjoy/src/main/db/models/conversation.ts +++ b/enjoy/src/main/db/models/conversation.ts @@ -12,10 +12,20 @@ import { AllowNull, BeforeSave, } from "sequelize-typescript"; -import { Message, Speech } from "@main/db/models"; +import { + Chat, + ChatAgent, + ChatMember, + ChatMessage, + Message, + Speech, + UserSetting, +} from "@main/db/models"; import mainWindow from "@main/window"; import log from "@main/logger"; import { t } from "i18next"; +import { SttEngineOptionEnum, UserSettingKeyEnum } from "@/types/enums"; +import { DEFAULT_GPT_CONFIG } from "@/constants"; const logger = log.scope("db/models/conversation"); @Table({ @@ -71,6 +81,146 @@ export class Conversation extends Model { @HasMany(() => Message) messages: Message[]; + async migrateToChat() { + const source = `conversations://${this.id}`; + let agent = await ChatAgent.findOne({ + where: { + source, + }, + }); + + if (agent) return; + + const gpt = { + engine: this.engine, + model: this.configuration.model, + temperature: this.configuration.temperature, + maxCompletionTokens: this.configuration.maxTokens, + presencePenalty: this.configuration.presencePenalty, + frequencyPenalty: this.configuration.frequencyPenalty, + historyBufferSize: this.configuration.historyBufferSize, + numberOfChoices: this.configuration.numberOfChoices, + }; + + if (!["openai", "enjoyai"].includes(this.engine)) { + const defaultGptEngine = await UserSetting.get( + UserSettingKeyEnum.GPT_ENGINE + ); + gpt.engine = defaultGptEngine?.name || DEFAULT_GPT_CONFIG.engine; + gpt.model = defaultGptEngine?.models?.default || DEFAULT_GPT_CONFIG.model; + } + + const tts = { + engine: this.configuration.tts?.engine || "enjoyai", + model: this.configuration.tts?.model || "openai/tts-1", + language: this.language, + voice: this.configuration.tts?.voice || "alloy", + }; + + agent = await ChatAgent.create({ + name: + this.configuration.type === "tts" ? tts.voice || this.name : this.name, + type: this.configuration.type === "tts" ? "TTS" : "GPT", + source, + description: "", + config: + this.configuration.type === "tts" + ? { + tts, + } + : { + prompt: this.configuration.roleDefinition, + }, + }); + + const transaction = await Conversation.sequelize.transaction(); + + try { + const chat = await Chat.create( + { + name: t("newChat"), + type: this.type === "tts" ? "TTS" : "CONVERSATION", + config: { + stt: SttEngineOptionEnum.ENJOY_AZURE, + }, + }, + { + transaction, + } + ); + const chatMember = await ChatMember.create( + { + chatId: chat.id, + userId: agent.id, + userType: "ChatAgent", + config: + this.configuration.type === "tts" + ? { + tts, + } + : { + gpt, + tts, + }, + }, + { + transaction, + hooks: false, + } + ); + + const messages = await Message.findAll({ + where: { + conversationId: this.id, + }, + include: [ + { + association: "speeches", + model: Speech, + where: { sourceType: "Message" }, + required: false, + }, + ], + order: [["createdAt", "ASC"]], + }); + + for (const message of messages) { + const chatMessage = await ChatMessage.create( + { + chatId: chat.id, + content: message.content, + role: message.role === "user" ? "USER" : "AGENT", + state: "completed", + memberId: message.role === "assistant" ? chatMember.id : null, + agentId: message.role === "assistant" ? agent.id : null, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }, + { + transaction, + hooks: false, + } + ); + if (chat.type === "TTS") { + for (const speech of message.speeches) { + await speech.update( + { + sourceId: chatMessage.id, + sourceType: "ChatMessage", + }, + { transaction } + ); + } + } + } + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + logger.error(error); + throw error; + } + } + @BeforeSave static validateConfiguration(conversation: Conversation) { if (conversation.type === "tts") { diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index 69e9ee70..8b032d1f 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -390,6 +390,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { destroy: (id: string) => { return ipcRenderer.invoke("conversations-destroy", id); }, + migrate: (id: string) => { + return ipcRenderer.invoke("conversations-migrate", id); + }, }, pronunciationAssessments: { findAll: (params: { where?: any; offset?: number; limit?: number }) => { @@ -681,6 +684,23 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { return ipcRenderer.invoke("chat-agents-destroy", id); }, }, + chatMembers: { + findAll: (params: any) => { + return ipcRenderer.invoke("chat-members-find-all", params); + }, + findOne: (params: any) => { + return ipcRenderer.invoke("chat-members-find-one", params); + }, + create: (params: any) => { + return ipcRenderer.invoke("chat-members-create", params); + }, + update: (id: string, params: any) => { + return ipcRenderer.invoke("chat-members-update", id, params); + }, + destroy: (id: string) => { + return ipcRenderer.invoke("chat-members-destroy", id); + }, + }, chatMessages: { findAll: (params: { chatSessionId: string; diff --git a/enjoy/src/renderer/app.tsx b/enjoy/src/renderer/app.tsx index c8c662ed..f6b57439 100644 --- a/enjoy/src/renderer/app.tsx +++ b/enjoy/src/renderer/app.tsx @@ -5,6 +5,7 @@ import { DbProvider, HotKeysSettingsProvider, DictProvider, + CopilotProvider, } from "@renderer/context"; import router from "./router"; import { RouterProvider } from "react-router-dom"; @@ -40,7 +41,9 @@ function App() { - + + + diff --git a/enjoy/src/renderer/components/chats/chat-agent-card.tsx b/enjoy/src/renderer/components/chats/chat-agent-card.tsx new file mode 100644 index 00000000..5b088cc7 --- /dev/null +++ b/enjoy/src/renderer/components/chats/chat-agent-card.tsx @@ -0,0 +1,78 @@ +import { + Avatar, + AvatarFallback, + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@renderer/components/ui"; +import { t } from "i18next"; +import { EllipsisIcon } from "lucide-react"; + +export const ChatAgentCard = (props: { + chatAgent: ChatAgentType; + selected: boolean; + onSelect: (chatAgent: ChatAgentType) => void; + onEdit?: (chatAgent: ChatAgentType) => void; + onDelete?: (chatAgent: ChatAgentType) => void; +}) => { + const { chatAgent, selected = false, onSelect, onEdit, onDelete } = props; + + return ( +
onSelect(chatAgent)} + > + + {chatAgent.name} + {chatAgent.name[0]} + +
+
+
{chatAgent.name}
+ + {chatAgent.type} + +
+
+ {chatAgent.description} +
+
+ {(onEdit || onDelete) && ( + + + + + + {onEdit && ( + { + event.stopPropagation(); + onEdit(chatAgent); + }} + > + {t("edit")} + + )} + {onDelete && ( + { + event.stopPropagation(); + onDelete(chatAgent); + }} + > + {t("delete")} + + )} + + + )} +
+ ); +}; diff --git a/enjoy/src/renderer/components/chats/chat-agent-form.tsx b/enjoy/src/renderer/components/chats/chat-agent-form.tsx index 525d05f3..5b00f413 100644 --- a/enjoy/src/renderer/components/chats/chat-agent-form.tsx +++ b/enjoy/src/renderer/components/chats/chat-agent-form.tsx @@ -2,15 +2,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, Avatar, Button, Form, @@ -26,237 +17,167 @@ import { SelectItem, SelectTrigger, SelectValue, - Slider, Textarea, + toast, } from "@renderer/components/ui"; import { t } from "i18next"; -import { LANGUAGES } from "@/constants"; +import { ChatTTSForm } from "@renderer/components"; import { AISettingsProviderContext, AppSettingsProviderContext, } from "@renderer/context"; -import { useContext, useEffect, useState } from "react"; -import { GPT_PROVIDERS, TTS_PROVIDERS } from "@renderer/components"; +import { useContext } from "react"; +import { ChatAgentTypeEnum } from "@/types/enums"; export const ChatAgentForm = (props: { agent?: ChatAgentType; - onSave: (data: { - name: string; - language: string; - introduction: string; - config: any; - }) => Promise; - onDestroy: () => void; + onFinish: () => void; }) => { - const { agent, onSave, onDestroy } = props; - const { learningLanguage, webApi } = useContext( - AppSettingsProviderContext - ); - const { openai } = useContext(AISettingsProviderContext); - const [gptProviders, setGptProviders] = useState(GPT_PROVIDERS); - const [ttsProviders, setTtsProviders] = useState(TTS_PROVIDERS); - + const { agent, onFinish } = props; + const { EnjoyApp, learningLanguage } = useContext(AppSettingsProviderContext); + const { currentTtsEngine } = useContext(AISettingsProviderContext); const agentFormSchema = z.object({ + type: z.enum([ChatAgentTypeEnum.GPT, ChatAgentTypeEnum.TTS]), name: z.string().min(1), - introduction: z.string().min(1), - language: z.string().min(1), - engine: z.enum(["enjoyai", "openai", "ollama"]), - model: z.string(), - prompt: z.string(), - temperature: z.number().optional(), - ttsEngine: z.enum(["enjoyai", "openai"]), - ttsModel: z.string(), - ttsVoice: z.string(), + description: z.string().min(1), + config: z.object({ + prompt: z.string().optional(), + tts: z + .object({ + engine: z.string().optional(), + model: z.string().optional(), + language: z.string().optional(), + voice: z.string().optional(), + }) + .optional(), + }), }); const form = useForm>({ resolver: zodResolver(agentFormSchema), values: agent || { + type: ChatAgentTypeEnum.GPT, name: "", - language: learningLanguage, - introduction: "", - prompt: "", - engine: "enjoyai", - model: "gpt-4o", - temperature: 0.7, - ttsEngine: "enjoyai", - ttsModel: "azure/speech", - ttsVoice: ttsProviders?.enjoyai?.voices?.["azure"].find( - (voice: any) => voice?.language === learningLanguage - )?.value, + description: "", }, }); const onSubmit = form.handleSubmit((data) => { - const { name, language, introduction, ...config } = data; - onSave({ - name, - language, - introduction, - config, - }).then(() => { - if (agent?.id) return; - form.reset(); - }); + const { type, name, description, config } = data; + if (type === ChatAgentTypeEnum.TTS) { + config.tts = { + engine: config.tts?.engine || currentTtsEngine.name, + model: config.tts?.model || currentTtsEngine.model, + language: config.tts?.language || learningLanguage, + voice: config.tts?.voice || currentTtsEngine.voice, + }; + } + + if (agent?.id) { + EnjoyApp.chatAgents + .update(agent.id, { + type, + name, + description, + config, + }) + .then(() => { + toast.success(t("models.chatAgent.updated")); + form.reset(); + onFinish(); + }) + .catch((error) => { + toast.error(error.message); + }); + } else { + EnjoyApp.chatAgents + .create({ + type, + name, + description, + config, + }) + .then(() => { + toast.success(t("models.chatAgent.created")); + form.reset(); + onFinish(); + }) + .catch((error) => { + toast.error(error.message); + }); + } }); - const refreshGptProviders = async () => { - let providers = GPT_PROVIDERS; - - try { - const config = await webApi.config("gpt_providers"); - providers = Object.assign(providers, config); - } catch (e) { - console.warn(`Failed to fetch remote GPT config: ${e.message}`); - } - - try { - const response = await fetch(providers["ollama"]?.baseUrl + "/api/tags"); - providers["ollama"].models = (await response.json()).models.map( - (m: any) => m.name - ); - } catch (e) { - console.warn(`No ollama server found: ${e.message}`); - } - - if (openai.models) { - providers["openai"].models = openai.models.split(","); - } - - setGptProviders({ ...providers }); - }; - - const refreshTtsProviders = async () => { - let providers = TTS_PROVIDERS; - - try { - const config = await webApi.config("tts_providers_v2"); - providers = Object.assign(providers, config); - } catch (e) { - console.warn(`Failed to fetch remote TTS config: ${e.message}`); - } - - setTtsProviders({ ...providers }); - }; - - useEffect(() => { - refreshGptProviders(); - refreshTtsProviders(); - }, []); - return (
{agent?.id ? t("editAgent") : t("newAgent")}
-
+
{form.watch("name") && ( - + {form.watch("name")} )} + ( + + {t("models.chatAgent.type")} + + {form.watch("type") === ChatAgentTypeEnum.GPT && ( + + {t("models.chatAgent.typeGptDescription")} + + )} + {form.watch("type") === ChatAgentTypeEnum.TTS && ( + + {t("models.chatAgent.typeTtsDescription")} + + )} + + + )} + /> ( {t("models.chatAgent.name")} - - - - )} - /> - - ( - - {t("models.chatAgent.introduction")} -