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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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}} 已离开聊天"
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<Attributes<ChatMember>> & { query?: string }
|
||||
) {}
|
||||
options: FindOptions<Attributes<ChatMember>>
|
||||
) {
|
||||
const chatMembers = await ChatMember.findAll({
|
||||
...options,
|
||||
});
|
||||
|
||||
return chatMembers.map((member) => member.toJSON());
|
||||
}
|
||||
|
||||
private async findOne(
|
||||
_event: IpcMainEvent,
|
||||
options: FindOptions<Attributes<ChatMember>>
|
||||
) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,17 +11,37 @@ const logger = log.scope("db/handlers/chats-handler");
|
||||
class ChatsHandler {
|
||||
private async findAll(
|
||||
_event: IpcMainEvent,
|
||||
options: FindOptions<Attributes<Chat>> & { query?: string }
|
||||
options: FindOptions<Attributes<Chat>> & {
|
||||
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<Attributes<Chat>> & {
|
||||
where: WhereOptions<Attributes<Chat>>;
|
||||
not: WhereOptions<Attributes<Chat>>;
|
||||
}
|
||||
) {
|
||||
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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
149
enjoy/src/main/db/migrations/1726781106038-modify-chat.js
Normal file
149
enjoy/src/main/db/migrations/1726781106038-modify-chat.js
Normal file
@@ -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 };
|
||||
@@ -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<ChatAgent> {
|
||||
@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<ChatAgent> {
|
||||
})
|
||||
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<ChatAgent> {
|
||||
});
|
||||
}
|
||||
|
||||
@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() });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatMember> {
|
||||
|
||||
@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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatMessage> {
|
||||
@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<ChatMessage> {
|
||||
@AllowNull(false)
|
||||
@Default("pending")
|
||||
@Column(DataType.STRING)
|
||||
state: string;
|
||||
state: ChatMessageStateEnum;
|
||||
|
||||
@BelongsTo(() => Chat, {
|
||||
foreignKey: "chatId",
|
||||
@@ -87,6 +111,12 @@ export class ChatMessage extends Model<ChatMessage> {
|
||||
})
|
||||
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<ChatMessage> {
|
||||
})
|
||||
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<ChatMessage> {
|
||||
) {
|
||||
if (!mainWindow.win) return;
|
||||
|
||||
if (action !== "destroy" && !chatMessage.agent) {
|
||||
await chatMessage.reload();
|
||||
}
|
||||
|
||||
mainWindow.win.webContents.send("db-on-transaction", {
|
||||
model: "ChatMessage",
|
||||
id: chatMessage.id,
|
||||
|
||||
@@ -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<Chat> {
|
||||
@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<Chat> {
|
||||
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 } });
|
||||
}
|
||||
|
||||
@@ -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<Conversation> {
|
||||
@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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<HotKeysSettingsProvider>
|
||||
<AISettingsProvider>
|
||||
<DictProvider>
|
||||
<RouterProvider router={router} />
|
||||
<CopilotProvider>
|
||||
<RouterProvider router={router} />
|
||||
</CopilotProvider>
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
<TranslateWidget />
|
||||
|
||||
78
enjoy/src/renderer/components/chats/chat-agent-card.tsx
Normal file
78
enjoy/src/renderer/components/chats/chat-agent-card.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`flex items-center space-x-2 px-2 py-1 rounded-lg cursor-pointer hover:bg-muted ${
|
||||
selected ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(chatAgent)}
|
||||
>
|
||||
<Avatar className="w-8 h-8">
|
||||
<img src={chatAgent.avatarUrl} alt={chatAgent.name} />
|
||||
<AvatarFallback>{chatAgent.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between space-x-1 line-clamp-1 w-full">
|
||||
<div className="text-sm flex-1 line-clamp-1">{chatAgent.name}</div>
|
||||
<Badge className="text-xs px-1" variant="secondary">
|
||||
{chatAgent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{chatAgent.description}
|
||||
</div>
|
||||
</div>
|
||||
{(onEdit || onDelete) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{onEdit && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit(chatAgent);
|
||||
}}
|
||||
>
|
||||
<span>{t("edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(chatAgent);
|
||||
}}
|
||||
>
|
||||
<span className="text-destructive">{t("delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<any>;
|
||||
onDestroy: () => void;
|
||||
onFinish: () => void;
|
||||
}) => {
|
||||
const { agent, onSave, onDestroy } = props;
|
||||
const { learningLanguage, webApi } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { openai } = useContext(AISettingsProviderContext);
|
||||
const [gptProviders, setGptProviders] = useState<any>(GPT_PROVIDERS);
|
||||
const [ttsProviders, setTtsProviders] = useState<any>(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<z.infer<typeof agentFormSchema>>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="mb-4">{agent?.id ? t("editAgent") : t("newAgent")}</div>
|
||||
<div className="space-y-4 px-2 mb-6">
|
||||
<div className="space-y-2 px-2 mb-6">
|
||||
{form.watch("name") && (
|
||||
<Avatar className="w-16 h-16 border">
|
||||
<Avatar className="w-12 h-12 border">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/thumbs/svg?seed=${form.watch(
|
||||
"name"
|
||||
)}`}
|
||||
src={
|
||||
agent?.avatarUrl ||
|
||||
`https://api.dicebear.com/9.x/shapes/svg?seed=${form.watch(
|
||||
"name"
|
||||
)}`
|
||||
}
|
||||
alt={form.watch("name")}
|
||||
/>
|
||||
</Avatar>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.type")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("models.chatAgent.type")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={ChatAgentTypeEnum.GPT}>
|
||||
{ChatAgentTypeEnum.GPT}
|
||||
</SelectItem>
|
||||
<SelectItem value={ChatAgentTypeEnum.TTS}>
|
||||
{ChatAgentTypeEnum.TTS}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.watch("type") === ChatAgentTypeEnum.GPT && (
|
||||
<FormDescription>
|
||||
{t("models.chatAgent.typeGptDescription")}
|
||||
</FormDescription>
|
||||
)}
|
||||
{form.watch("type") === ChatAgentTypeEnum.TTS && (
|
||||
<FormDescription>
|
||||
{t("models.chatAgent.typeTtsDescription")}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.name")}</FormLabel>
|
||||
<Input required {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="introduction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.introduction")}</FormLabel>
|
||||
<Textarea required className="max-h-36" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.prompt")}</FormLabel>
|
||||
<Textarea required className="max-h-48" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.language")}</FormLabel>
|
||||
<Select
|
||||
<Input
|
||||
placeholder={t("models.chatAgent.namePlaceholder")}
|
||||
required
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue>
|
||||
{
|
||||
LANGUAGES.find((lang) => lang.code === field.value)
|
||||
?.name
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem
|
||||
className="text-xs"
|
||||
value={lang.code}
|
||||
key={lang.code}
|
||||
>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="engine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.engine")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectAiEngine")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.keys(gptProviders).map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{gptProviders[key].name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription>
|
||||
{gptProviders[form.watch("engine")]?.description}
|
||||
{t("models.chatAgent.nameDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -265,195 +186,57 @@ export const ChatAgentForm = (props: {
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.model")}</FormLabel>
|
||||
<Select
|
||||
<FormLabel>{t("models.chatAgent.description")}</FormLabel>
|
||||
<Textarea
|
||||
placeholder={t("models.chatAgent.descriptionPlaceholder")}
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectAiModel")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(gptProviders[form.watch("engine")]?.models || []).map(
|
||||
(option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
className="max-h-36"
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("models.chatAgent.descriptionDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="temperature"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.temperature")}</FormLabel>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
value={[field.value]}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
{form.watch("type") === ChatAgentTypeEnum.GPT && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.prompt")}</FormLabel>
|
||||
<Textarea
|
||||
placeholder={t("models.chatAgent.promptPlaceholder")}
|
||||
required
|
||||
className="min-h-36 max-h-64"
|
||||
{...field}
|
||||
/>
|
||||
<span>{field.value}</span>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("models.chatAgent.temperatureDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttsEngine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.ttsEngine")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectTtsEngine")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.keys(ttsProviders).map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{ttsProviders[key].name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttsModel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.ttsModel")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectTtsModel")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(ttsProviders[form.watch("ttsEngine")]?.models || []).map(
|
||||
(model: string) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttsVoice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatAgent.ttsVoice")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectTtsVoice")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(
|
||||
(form.watch("ttsEngine") === "enjoyai"
|
||||
? ttsProviders.enjoyai.voices[
|
||||
form.watch("ttsModel")?.split("/")?.[0]
|
||||
]
|
||||
: ttsProviders[form.watch("ttsEngine")].voices) || []
|
||||
).map((voice: any) => {
|
||||
if (typeof voice === "string") {
|
||||
return (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
<span className="capitalize">{voice}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
} else if (voice.language === form.watch("language")) {
|
||||
return (
|
||||
<SelectItem key={voice.value} value={voice.value}>
|
||||
<span className="capitalize">{voice.label}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{agent?.id && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="text-destructive" variant="secondary">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteChatAgent")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteChatAgentConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={onDestroy}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<FormDescription>
|
||||
{t("models.chatAgent.promptDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button>{t("save")}</Button>
|
||||
|
||||
{form.watch("type") === ChatAgentTypeEnum.TTS && (
|
||||
<ChatTTSForm form={form} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<Button type="button" variant="ghost" onClick={onFinish}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" onClick={onSubmit}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
ConversationShortcuts,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
WavesurferPlayer,
|
||||
CopilotForwarder,
|
||||
} from "@renderer/components";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
LanguagesIcon,
|
||||
LoaderIcon,
|
||||
MicIcon,
|
||||
MoreVerticalIcon,
|
||||
RotateCcwIcon,
|
||||
MoreHorizontalIcon,
|
||||
SpeechIcon,
|
||||
Volume2Icon,
|
||||
} from "lucide-react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
@@ -36,31 +36,154 @@ import {
|
||||
AppSettingsProviderContext,
|
||||
ChatSessionProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useAiCommand, useConversation } from "@renderer/hooks";
|
||||
import { useAiCommand, useSpeech } from "@renderer/hooks";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { md5 } from "js-md5";
|
||||
import { ChatAgentTypeEnum, ChatTypeEnum } from "@/types/enums";
|
||||
|
||||
export const ChatAgentMessage = (props: {
|
||||
chatMessage: ChatMessageType;
|
||||
isLastMessage: boolean;
|
||||
isLastMessage?: boolean;
|
||||
onEditChatMember: (chatMember: ChatMemberType) => void;
|
||||
}) => {
|
||||
const { chatMessage, isLastMessage } = props;
|
||||
const { dispatchChatMessages, setShadowing, onDeleteMessage } = useContext(
|
||||
const { chatMessage, onEditChatMember, isLastMessage } = props;
|
||||
const { chat, chatMembers, askAgent } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [speeching, setSpeeching] = useState(false);
|
||||
const [translation, setTranslation] = useState<string>();
|
||||
const [displayContent, setDisplayContent] = useState(
|
||||
!(chat.type === ChatTypeEnum.TTS || chat.config.enableAutoTts)
|
||||
);
|
||||
const [displayPlayer, setDisplayPlayer] = useState(false);
|
||||
|
||||
const chatMember = chatMembers.find((m) => m.id === chatMessage.member.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLastMessage) {
|
||||
askAgent();
|
||||
}
|
||||
}, [chatMessage]);
|
||||
|
||||
if (!chatMember?.agent) return;
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className="mb-2 flex">
|
||||
<div
|
||||
className="flex items-center space-x-1 cursor-pointer"
|
||||
onClick={() => onEditChatMember(chatMember)}
|
||||
>
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage src={chatMember.agent.avatarUrl}></AvatarImage>
|
||||
<AvatarFallback className="bg-background">
|
||||
{chatMember.agent.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-xs">{chatMember.agent.name}</div>
|
||||
<div className="italic text-xs text-muted-foreground/50">
|
||||
{chatMember.agent.type === ChatAgentTypeEnum.TTS &&
|
||||
chatMember.agent.config.tts?.voice}
|
||||
{chatMember.agent.type === ChatAgentTypeEnum.GPT &&
|
||||
chatMember.config.gpt.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 py-2 mb-2 rounded-lg w-full">
|
||||
{Boolean(chatMessage.speech?.id) ? (
|
||||
<>
|
||||
{displayPlayer ? (
|
||||
<WavesurferPlayer
|
||||
id={chatMessage.speech.id}
|
||||
src={chatMessage.speech.src}
|
||||
autoplay={true}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setDisplayPlayer(true)}
|
||||
className="w-8 h-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Volume2Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
speeching && <LoaderSpin />
|
||||
)}
|
||||
{displayContent && (
|
||||
<>
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert max-w-full">
|
||||
{chatMessage.content}
|
||||
</MarkdownWrapper>
|
||||
{translation && (
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert max-w-full">
|
||||
{translation}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChatAgentMessageActions
|
||||
chatMessage={chatMessage}
|
||||
speeching={speeching}
|
||||
setSpeeching={setSpeeching}
|
||||
displayContent={displayContent}
|
||||
setDisplayContent={setDisplayContent}
|
||||
translation={translation}
|
||||
setTranslation={setTranslation}
|
||||
autoSpeech={
|
||||
isLastMessage &&
|
||||
(chat.type === ChatTypeEnum.TTS || chat.config.enableAutoTts)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-start text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(chatMessage.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatAgentMessageActions = (props: {
|
||||
chatMessage: ChatMessageType;
|
||||
speeching: boolean;
|
||||
setSpeeching: (speeching: boolean) => void;
|
||||
displayContent: boolean;
|
||||
setDisplayContent: (displayContent: boolean) => void;
|
||||
translation: string;
|
||||
setTranslation: (translation: string) => void;
|
||||
autoSpeech: boolean;
|
||||
}) => {
|
||||
const {
|
||||
chatMessage,
|
||||
speeching,
|
||||
setSpeeching,
|
||||
displayContent,
|
||||
setDisplayContent,
|
||||
translation,
|
||||
setTranslation,
|
||||
autoSpeech,
|
||||
} = props;
|
||||
const { chat, setShadowing, onDeleteMessage } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { agent } = chatMessage.member || {};
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [speeching, setSpeeching] = useState(false);
|
||||
const [resourcing, setResourcing] = useState<boolean>(false);
|
||||
const { tts } = useConversation();
|
||||
const [translation, setTranslation] = useState<string>();
|
||||
const { tts } = useSpeech();
|
||||
const [translating, setTranslating] = useState<boolean>(false);
|
||||
const { translate, summarizeTopic } = useAiCommand();
|
||||
const [displayContent, setDisplayContent] = useState(!isLastMessage);
|
||||
const [displayPlayer, setDisplayPlayer] = useState(false);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (translating) return;
|
||||
@@ -84,28 +207,33 @@ export const ChatAgentMessage = (props: {
|
||||
}
|
||||
};
|
||||
|
||||
const createSpeech = () => {
|
||||
const createSpeech = async () => {
|
||||
if (chatMessage?.speech) return;
|
||||
if (speeching) return;
|
||||
|
||||
// To use fresh config from chat member
|
||||
const chatMember = await EnjoyApp.chatMembers.findOne({
|
||||
where: {
|
||||
id: chatMessage.member.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!chatMember) {
|
||||
toast.error(t("models.chatMembers.notFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSpeeching(true);
|
||||
|
||||
tts({
|
||||
sourceType: "ChatMessage",
|
||||
sourceId: chatMessage.id,
|
||||
text: chatMessage.content,
|
||||
configuration: {
|
||||
engine: chatMessage.member.agent.config.ttsEngine,
|
||||
model: chatMessage.member.agent.config.ttsModel,
|
||||
voice: chatMessage.member.agent.config.ttsVoice,
|
||||
},
|
||||
configuration:
|
||||
chatMember.agent.type === ChatAgentTypeEnum.TTS
|
||||
? chatMember.agent.config.tts
|
||||
: chatMember.config.tts,
|
||||
})
|
||||
.then((speech) => {
|
||||
dispatchChatMessages({
|
||||
type: "update",
|
||||
record: Object.assign({}, chatMessage, { speech }),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
@@ -125,20 +253,20 @@ export const ChatAgentMessage = (props: {
|
||||
|
||||
if (!audio) {
|
||||
setResourcing(true);
|
||||
let title =
|
||||
let name =
|
||||
speech.text.length > 20
|
||||
? speech.text.substring(0, 17).trim() + "..."
|
||||
: speech.text;
|
||||
|
||||
try {
|
||||
title = await summarizeTopic(speech.text);
|
||||
name = await summarizeTopic(speech.text);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
EnjoyApp.audios
|
||||
.create(speech.filePath, {
|
||||
name: title,
|
||||
name,
|
||||
originalText: speech.text,
|
||||
})
|
||||
.then((audio) => setShadowing(audio))
|
||||
@@ -180,179 +308,122 @@ export const ChatAgentMessage = (props: {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatMessage?.speech) return;
|
||||
|
||||
createSpeech();
|
||||
if (autoSpeech) {
|
||||
createSpeech();
|
||||
}
|
||||
}, [chatMessage]);
|
||||
|
||||
if (!agent) return;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mb-6">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage src={agent.avatarUrl}></AvatarImage>
|
||||
<AvatarFallback className="bg-background">
|
||||
{agent.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-sm text-muted-foreground">{agent.name}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 py-2 mb-2 bg-background border rounded-lg shadow-sm w-full max-w-prose">
|
||||
{Boolean(chatMessage.speech) ? (
|
||||
<>
|
||||
{displayPlayer ? (
|
||||
<WavesurferPlayer
|
||||
id={chatMessage.speech.id}
|
||||
src={chatMessage.speech.src}
|
||||
autoplay={true}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setDisplayPlayer(true)}
|
||||
className="w-8 h-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Volume2Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{displayContent && (
|
||||
<>
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{chatMessage.content}
|
||||
</MarkdownWrapper>
|
||||
{translation && (
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{translation}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : speeching ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={createSpeech}>
|
||||
<RotateCcwIcon className="w-4 h-4 mr-2" />
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center space-x-4">
|
||||
{Boolean(chatMessage.speech) &&
|
||||
(resourcing ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addingResource")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<MicIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("shadowingExercise")}
|
||||
data-testid="message-start-shadow"
|
||||
onClick={startShadow}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
{displayContent ? (
|
||||
<EyeOffIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("hideContent")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => setDisplayContent(false)}
|
||||
/>
|
||||
) : (
|
||||
<EyeIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("displayContent")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => setDisplayContent(true)}
|
||||
/>
|
||||
)}
|
||||
{translating ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("translating")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
displayContent && (
|
||||
<LanguagesIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("translation")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={handleTranslate}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(chatMessage.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConversationShortcuts
|
||||
prompt={chatMessage.content}
|
||||
excludedIds={[]}
|
||||
trigger={
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
}
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center space-x-4">
|
||||
{Boolean(chatMessage.speech) &&
|
||||
(resourcing ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addingResource")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
{Boolean(chatMessage.speech) && (
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="chat-message-download-speech"
|
||||
onClick={handleDownload}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<MicIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("shadowingExercise")}
|
||||
data-testid="message-start-shadow"
|
||||
onClick={startShadow}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
{!Boolean(chatMessage.speech) && (
|
||||
<SpeechIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("textToSpeech")}
|
||||
onClick={createSpeech}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
{displayContent ? (
|
||||
<EyeOffIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("hideContent")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => setDisplayContent(false)}
|
||||
/>
|
||||
) : (
|
||||
<EyeIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("displayContent")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => setDisplayContent(true)}
|
||||
/>
|
||||
)}
|
||||
{translating ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("translating")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
displayContent && (
|
||||
<LanguagesIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("translation")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={handleTranslate}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(chatMessage.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CopilotForwarder
|
||||
prompt={chatMessage.content}
|
||||
trigger={
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Boolean(chatMessage.speech) && (
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="chat-message-download-speech"
|
||||
onClick={handleDownload}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuTrigger>
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDeleteMessage(chatMessage.id)}
|
||||
>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex justify-start text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(chatMessage.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDeleteMessage(chatMessage.id)}
|
||||
>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,86 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Input,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ChatAgentForm } from "@renderer/components";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
import { ChatAgentCard, ChatAgentForm } from "@renderer/components";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { t } from "i18next";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { ChatProviderContext } from "@renderer/context";
|
||||
import { AppSettingsProviderContext } from "@/renderer/context";
|
||||
|
||||
export const ChatAgents = () => {
|
||||
const {
|
||||
chatAgents,
|
||||
fetchChatAgents,
|
||||
updateChatAgent,
|
||||
createChatAgent,
|
||||
destroyChatAgent,
|
||||
} = useContext(ChatProviderContext);
|
||||
const [selected, setSelected] = useState<ChatAgentType>();
|
||||
export const ChatAgents = (props: {
|
||||
chatAgents: ChatAgentType[];
|
||||
fetchChatAgents: (query?: string) => void;
|
||||
currentChatAgent: ChatAgentType;
|
||||
setCurrentChatAgent: (chatAgent: ChatAgentType) => void;
|
||||
}) => {
|
||||
const { currentChatAgent, setCurrentChatAgent, chatAgents, fetchChatAgents } =
|
||||
props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [deletingChatAgent, setDeletingChatAgent] =
|
||||
useState<ChatAgentType>(null);
|
||||
const [editingChatAgent, setEditingChatAgent] = useState<ChatAgentType>(null);
|
||||
const [creatingChatAgent, setCreatingChatAgent] = useState<boolean>(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const handleDeleteChatAgent = () => {
|
||||
if (!deletingChatAgent) return;
|
||||
|
||||
if (currentChatAgent?.id === deletingChatAgent.id) {
|
||||
setCurrentChatAgent(null);
|
||||
}
|
||||
|
||||
EnjoyApp.chatAgents
|
||||
.destroy(deletingChatAgent.id)
|
||||
.then(() => {
|
||||
toast.success(t("models.chatAgent.deleted"));
|
||||
setDeletingChatAgent(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatAgent) return;
|
||||
|
||||
setCurrentChatAgent(chatAgents[0]);
|
||||
}, [chatAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatAgents(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 overflow-hidden h-full">
|
||||
<ScrollArea className="h-full col-span-1 bg-muted/50 p-4">
|
||||
<div className="sticky flex items-center space-x-2 mb-4">
|
||||
<>
|
||||
<div className="overflow-y-auto h-full relative py-2 px-1">
|
||||
<div className="sticky flex items-center space-x-2 py-2 px-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded-full"
|
||||
className="rounded h-8 text-xs"
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setSelected(null)}
|
||||
className="w-8 h-8"
|
||||
className="w-8 h-8 p-0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCreatingChatAgent(true)}
|
||||
>
|
||||
<PlusCircleIcon className="w-5 h-5" />
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{chatAgents.length === 0 && (
|
||||
@@ -52,46 +88,62 @@ export const ChatAgents = () => {
|
||||
<span className="text-sm text-muted-foreground">{t("noData")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-1">
|
||||
{chatAgents.map((chatAgent) => (
|
||||
<div
|
||||
<ChatAgentCard
|
||||
key={chatAgent.id}
|
||||
className={`flex items-center space-x-1 px-2 py-1 rounded-lg cursor-pointer hover:bg-background hover:border ${
|
||||
chatAgent.id === selected?.id ? "bg-background border" : ""
|
||||
}`}
|
||||
onClick={() => setSelected(chatAgent)}
|
||||
>
|
||||
<Avatar className="w-12 h-12">
|
||||
<img src={chatAgent.avatarUrl} alt={chatAgent.name} />
|
||||
<AvatarFallback>{chatAgent.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm line-clamp-1">{chatAgent.name}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{chatAgent.introduction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
chatAgent={chatAgent}
|
||||
selected={currentChatAgent?.id === chatAgent.id}
|
||||
onSelect={setCurrentChatAgent}
|
||||
onEdit={setEditingChatAgent}
|
||||
onDelete={setDeletingChatAgent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea className="h-full col-span-2 py-6 px-10">
|
||||
<ChatAgentForm
|
||||
agent={selected}
|
||||
onSave={(data) => {
|
||||
if (selected) {
|
||||
return updateChatAgent(selected.id, data);
|
||||
} else {
|
||||
return createChatAgent(data).then(() => setSelected(null));
|
||||
}
|
||||
}}
|
||||
onDestroy={() => {
|
||||
if (!selected) return;
|
||||
destroyChatAgent(selected.id);
|
||||
setSelected(null);
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={!!deletingChatAgent}
|
||||
onOpenChange={() => setDeletingChatAgent(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t("deleteChatAgent")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteChatAgentConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeletingChatAgent(null)}>
|
||||
{t("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={handleDeleteChatAgent}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Dialog
|
||||
open={!!editingChatAgent}
|
||||
onOpenChange={() => setEditingChatAgent(null)}
|
||||
>
|
||||
<DialogContent className="max-w-screen-md max-h-full overflow-auto">
|
||||
<DialogTitle className="sr-only"></DialogTitle>
|
||||
<ChatAgentForm
|
||||
agent={editingChatAgent}
|
||||
onFinish={() => setEditingChatAgent(null)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={creatingChatAgent} onOpenChange={setCreatingChatAgent}>
|
||||
<DialogContent className="max-w-screen-md max-h-full overflow-auto">
|
||||
<DialogTitle className="sr-only"></DialogTitle>
|
||||
<ChatAgentForm
|
||||
agent={null}
|
||||
onFinish={() => setCreatingChatAgent(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,74 @@
|
||||
import { Avatar, AvatarFallback } from "@renderer/components/ui";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { ChatBubbleIcon } from "@radix-ui/react-icons";
|
||||
import { EllipsisIcon, SpeechIcon, UsersRoundIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import dayjs from "@renderer/lib/dayjs";
|
||||
import { ChatTypeEnum } from "@/types/enums";
|
||||
|
||||
export const ChatCard = (props: {
|
||||
chat: ChatType;
|
||||
selected?: boolean;
|
||||
selected: boolean;
|
||||
displayDate?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: (chat: ChatType) => void;
|
||||
onDelete?: (chat: ChatType) => void;
|
||||
}) => {
|
||||
const { chat, selected = false, onSelect } = props;
|
||||
const {
|
||||
chat,
|
||||
selected = false,
|
||||
displayDate = false,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
onDelete,
|
||||
} = props;
|
||||
return (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`rounded-lg border py-2 px-4 hover:bg-background cursor-pointer ${
|
||||
selected ? "bg-background" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(chat)}
|
||||
>
|
||||
<div className="text-sm line-clamp-1 mb-2">
|
||||
{chat.name}({chat.membersCount})
|
||||
</div>
|
||||
<div className="flex items-center -space-x-2 justify-end">
|
||||
{(chat.members || []).slice(0, 5).map((member) => (
|
||||
<Avatar key={member.id} className="w-6 h-6 border bg-background">
|
||||
<img src={(member.agent || member.user).avatarUrl} />
|
||||
<AvatarFallback>
|
||||
{(member.agent || member.user).name[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
<div className="px-2">
|
||||
{displayDate && (
|
||||
<div className="text-xs text-muted-foreground my-2 capitalize">
|
||||
{dayjs(chat.updatedAt).fromNow()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center space-x-2 rounded-lg py-1 hover:bg-muted/50 cursor-pointer ${
|
||||
selected ? "bg-muted/50" : ""
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
onClick={() => !disabled && onSelect(chat)}
|
||||
>
|
||||
{chat.type === ChatTypeEnum.CONVERSATION && (
|
||||
<ChatBubbleIcon className="w-4 h-4" />
|
||||
)}
|
||||
{chat.type === ChatTypeEnum.GROUP && (
|
||||
<UsersRoundIcon className="w-4 h-4" />
|
||||
)}
|
||||
{chat.type === ChatTypeEnum.TTS && <SpeechIcon className="w-4 h-4" />}
|
||||
<div className="flex-1 text-sm font-serif line-clamp-1">
|
||||
{chat.name}
|
||||
</div>
|
||||
{onDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-4">
|
||||
<EllipsisIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(chat);
|
||||
}}
|
||||
>
|
||||
<span className="text-destructive">{t("delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
@@ -22,338 +22,312 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Label,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useState } from "react";
|
||||
import { CheckCircleIcon } from "lucide-react";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { CHAT_SYSTEM_PROMPT_TEMPLATE, LANGUAGES } from "@/constants";
|
||||
import Mustache from "mustache";
|
||||
import { SttEngineOptionEnum } from "@/types/enums";
|
||||
import {
|
||||
ChatMessageRoleEnum,
|
||||
ChatTypeEnum,
|
||||
SttEngineOptionEnum,
|
||||
} from "@/types/enums";
|
||||
import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useAiCommand } from "@/renderer/hooks";
|
||||
import { cn } from "@/renderer/lib/utils";
|
||||
|
||||
export const ChatForm = (props: {
|
||||
chat?: ChatType;
|
||||
chatAgents: ChatAgentType[];
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
topic: string;
|
||||
language: string;
|
||||
members: Array<Partial<ChatMemberType>>;
|
||||
config: any;
|
||||
}) => void;
|
||||
onDestroy?: () => void;
|
||||
}) => {
|
||||
const { chat, chatAgents, onSave, onDestroy } = props;
|
||||
const { user, learningLanguage, nativeLanguage } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
export const ChatForm = (props: { chat: ChatType; onFinish?: () => void }) => {
|
||||
const { chat, onFinish } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { sttEngine } = useContext(AISettingsProviderContext);
|
||||
const [editingMember, setEditingMember] =
|
||||
useState<Partial<ChatMemberType> | null>();
|
||||
|
||||
const { summarizeTopic } = useAiCommand();
|
||||
const [isMoreSettingsOpen, setIsMoreSettingsOpen] = useState(false);
|
||||
const [isGeneratingTopic, setIsGeneratingTopic] = useState(false);
|
||||
const chatFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
topic: z.string(),
|
||||
language: z.string(),
|
||||
config: z.object({
|
||||
sttEngine: z.string(),
|
||||
sttEngine: z.string().default(sttEngine),
|
||||
prompt: z.string().optional(),
|
||||
enableChatAssistant: z.boolean().default(false),
|
||||
enableAutoTts: z.boolean().default(false),
|
||||
}),
|
||||
members: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
userType: z.enum(["User", "Agent"]).default("Agent"),
|
||||
config: z.object({
|
||||
prompt: z.string().optional(),
|
||||
introduction: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
.min(2),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof chatFormSchema>>({
|
||||
resolver: zodResolver(chatFormSchema),
|
||||
values: chat
|
||||
values: chat?.id
|
||||
? {
|
||||
name: chat.name,
|
||||
topic: chat.topic,
|
||||
language: chat.language,
|
||||
config: chat.config,
|
||||
members: [...chat.members],
|
||||
}
|
||||
: {
|
||||
name: t("newChat"),
|
||||
topic: "Casual Chat.",
|
||||
language: learningLanguage,
|
||||
config: {
|
||||
sttEngine,
|
||||
prompt: "",
|
||||
enableChatAssistant: true,
|
||||
enableAutoTts: true,
|
||||
},
|
||||
members: [
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
userType: "User",
|
||||
config: {
|
||||
introduction: `I am ${nativeLanguage} speaker learning ${learningLanguage}.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
const { name, topic, language, members, config } = data;
|
||||
return onSave({
|
||||
name,
|
||||
topic,
|
||||
language,
|
||||
members,
|
||||
config,
|
||||
});
|
||||
const { name, config } = data;
|
||||
EnjoyApp.chats
|
||||
.update(chat.id, {
|
||||
name,
|
||||
config: {
|
||||
sttEngine: config.sttEngine,
|
||||
prompt: config.prompt,
|
||||
enableChatAssistant: config.enableChatAssistant,
|
||||
enableAutoTts: config.enableAutoTts,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.then(() => {
|
||||
onFinish();
|
||||
});
|
||||
});
|
||||
|
||||
const handleDeleteChat = () => {
|
||||
EnjoyApp.chats
|
||||
.destroy(chat.id)
|
||||
.then(() => {
|
||||
onFinish();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const generateTopic = async () => {
|
||||
setIsGeneratingTopic(true);
|
||||
try {
|
||||
let messages = await EnjoyApp.chatMessages.findAll({
|
||||
where: { chatId: chat.id },
|
||||
order: [["createdAt", "ASC"]],
|
||||
});
|
||||
messages = messages.filter(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.AGENT ||
|
||||
m.role === ChatMessageRoleEnum.USER
|
||||
);
|
||||
if (messages.length < 1) {
|
||||
toast.warning(t("chatNoContentYet"));
|
||||
return;
|
||||
}
|
||||
const content = messages
|
||||
.slice(0, 10)
|
||||
.map((m) => m.content)
|
||||
.join("\n");
|
||||
|
||||
return await summarizeTopic(content);
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsGeneratingTopic(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="">
|
||||
<div className="mb-6">{chat?.id ? t("editChat") : t("newChat")}</div>
|
||||
<div className="space-y-4 mb-6">
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4 px-2 mb-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chat.name")}</FormLabel>
|
||||
<Input {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chat.language")}</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue>
|
||||
{
|
||||
LANGUAGES.find((lang) => lang.code === field.value)
|
||||
?.name
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem
|
||||
className="text-xs"
|
||||
value={lang.code}
|
||||
key={lang.code}
|
||||
>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chat.topic")}</FormLabel>
|
||||
<Textarea className="max-h-96" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.sttEngine"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid w-full items-center">
|
||||
<FormLabel>{t("sttAiService")}</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SttEngineOptionEnum.LOCAL}>
|
||||
{t("local")}
|
||||
</SelectItem>
|
||||
<SelectItem value={SttEngineOptionEnum.ENJOY_AZURE}>
|
||||
{t("enjoyAzure")}
|
||||
</SelectItem>
|
||||
<SelectItem value={SttEngineOptionEnum.ENJOY_CLOUDFLARE}>
|
||||
{t("enjoyCloudflare")}
|
||||
</SelectItem>
|
||||
<SelectItem value={SttEngineOptionEnum.OPENAI}>
|
||||
{t("openai")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.LOCAL &&
|
||||
t("localSpeechToTextDescription")}
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.ENJOY_AZURE &&
|
||||
t("enjoyAzureSpeechToTextDescription")}
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.ENJOY_CLOUDFLARE &&
|
||||
t("enjoyCloudflareSpeechToTextDescription")}
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.OPENAI &&
|
||||
t("openaiSpeechToTextDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="members"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("models.chat.members")}({field.value.length})
|
||||
</FormLabel>
|
||||
<ScrollArea className="w-full h-36 rounded-lg p-2 bg-muted">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div
|
||||
className={`flex items-center space-x-1 px-2 py-1 rounded-lg w-full overflow-hidden relative hover:shadow border cursor-pointer ${
|
||||
editingMember?.userType === "User"
|
||||
? "border-blue-500 bg-background"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
const member = field.value.find(
|
||||
(m) => m.userType === "User"
|
||||
);
|
||||
<div className="flex items-center space-x-2 justify-between">
|
||||
<Input className="flex-1" {...field} />
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isGeneratingTopic}
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("models.chat.generateTopic")}
|
||||
onClick={async () => {
|
||||
if (isGeneratingTopic) return;
|
||||
const topic = await generateTopic();
|
||||
if (!topic) return;
|
||||
|
||||
setEditingMember(member);
|
||||
}}
|
||||
>
|
||||
<Avatar className="w-10 h-10">
|
||||
<img src={user.avatarUrl} alt={user.name} />
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm line-clamp-1">{user.name}</div>
|
||||
</div>
|
||||
<CheckCircleIcon className="absolute top-2 right-2 w-4 h-4 text-green-500" />
|
||||
field.onChange(topic);
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
isGeneratingTopic && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{[ChatTypeEnum.CONVERSATION, ChatTypeEnum.GROUP].includes(
|
||||
chat.type
|
||||
) && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enableChatAssistant"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormLabel>
|
||||
{t("models.chat.enableChatAssistant")}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
{chatAgents
|
||||
.filter((a) => a.language === form.watch("language"))
|
||||
.map((chatAgent) => (
|
||||
<div
|
||||
key={chatAgent.id}
|
||||
className={`flex items-center space-x-1 px-2 py-1 rounded-lg w-full overflow-hidden relative cursor-pointer border hover:shadow ${
|
||||
editingMember?.userId === chatAgent.id
|
||||
? "border-blue-500 bg-background"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
const member = field.value.find(
|
||||
(m) => m.userId === chatAgent.id
|
||||
);
|
||||
if (editingMember?.userId === chatAgent.id) {
|
||||
setEditingMember(null);
|
||||
} else {
|
||||
setEditingMember(
|
||||
member || {
|
||||
userId: chatAgent.id,
|
||||
userType: "Agent",
|
||||
config: {},
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar className="w-12 h-12">
|
||||
<img
|
||||
src={chatAgent.avatarUrl}
|
||||
alt={chatAgent.name}
|
||||
/>
|
||||
<AvatarFallback>{chatAgent.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm line-clamp-1">
|
||||
{chatAgent.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{chatAgent.introduction}
|
||||
</div>
|
||||
</div>
|
||||
{field.value.findIndex(
|
||||
(m) => m.userId === chatAgent.id
|
||||
) > -1 && (
|
||||
<CheckCircleIcon className="absolute top-2 right-2 w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{editingMember && (
|
||||
<MemberForm
|
||||
language={form.watch("language")}
|
||||
topic={form.watch("topic")}
|
||||
members={form.watch("members")}
|
||||
member={editingMember}
|
||||
chatAgents={chatAgents}
|
||||
checked={
|
||||
field.value.findIndex(
|
||||
(m) => m.userId === editingMember.userId
|
||||
) > -1
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{
|
||||
...editingMember,
|
||||
prompt: "",
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value.filter(
|
||||
(m) => m.userId !== editingMember.userId
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
onConfigChange={(config) => {
|
||||
editingMember.config = config;
|
||||
setEditingMember({ ...editingMember });
|
||||
|
||||
field.onChange(
|
||||
field.value.map((m) =>
|
||||
m.userId === editingMember.userId ? editingMember : m
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("models.chat.enableChatAssistantDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.enableAutoTts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormLabel>{t("models.chat.enableAutoTts")}</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("models.chat.enableAutoTtsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
{[ChatTypeEnum.CONVERSATION, ChatTypeEnum.GROUP].includes(
|
||||
chat.type
|
||||
) && (
|
||||
<>
|
||||
<Collapsible open={isMoreSettingsOpen} className="mb-6">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="w-full justify-center text-muted-foreground"
|
||||
size="sm"
|
||||
onClick={() => setIsMoreSettingsOpen(!isMoreSettingsOpen)}
|
||||
>
|
||||
{t("models.chat.moreSettings")}
|
||||
{isMoreSettingsOpen ? (
|
||||
<ChevronUpIcon className="w-4 h-4 ml-2" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4 ml-2" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 px-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.sttEngine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("sttAiService")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"models.chat.sttAiServicePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SttEngineOptionEnum.LOCAL}>
|
||||
{t("local")}
|
||||
</SelectItem>
|
||||
<SelectItem value={SttEngineOptionEnum.ENJOY_AZURE}>
|
||||
{t("enjoyAzure")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value={SttEngineOptionEnum.ENJOY_CLOUDFLARE}
|
||||
>
|
||||
{t("enjoyCloudflare")}
|
||||
</SelectItem>
|
||||
<SelectItem value={SttEngineOptionEnum.OPENAI}>
|
||||
{t("openai")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("models.chat.sttAiServiceDescription")}
|
||||
</FormDescription>
|
||||
<FormDescription>
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.LOCAL &&
|
||||
t("localSpeechToTextDescription")}
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.ENJOY_AZURE &&
|
||||
t("enjoyAzureSpeechToTextDescription")}
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.ENJOY_CLOUDFLARE &&
|
||||
t("enjoyCloudflareSpeechToTextDescription")}
|
||||
{form.watch("config.sttEngine") ===
|
||||
SttEngineOptionEnum.OPENAI &&
|
||||
t("openaiSpeechToTextDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chat.prompt")}</FormLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t("models.chat.promptPlaceholder")}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("models.chat.promptDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end space-x-4 w-full">
|
||||
{chat?.id && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -361,7 +335,6 @@ export const ChatForm = (props: {
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteChat")}</AlertDialogTitle>
|
||||
@@ -373,7 +346,7 @@ export const ChatForm = (props: {
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={onDestroy}
|
||||
onClick={handleDeleteChat}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
@@ -381,141 +354,12 @@ export const ChatForm = (props: {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<Button>{t("save")}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onFinish}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberForm = (props: {
|
||||
language: string;
|
||||
topic: string;
|
||||
members: Array<Partial<ChatMemberType>>;
|
||||
member: Partial<ChatMemberType>;
|
||||
chatAgents: ChatAgentType[];
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
onConfigChange?: (config: ChatMemberType["config"]) => void;
|
||||
}) => {
|
||||
const {
|
||||
language,
|
||||
topic,
|
||||
members,
|
||||
member,
|
||||
chatAgents,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
onConfigChange,
|
||||
} = props;
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
const chatAgent = chatAgents.find((a) => a.id === member.userId);
|
||||
|
||||
const fullPrompt = chatAgent
|
||||
? Mustache.render(
|
||||
CHAT_SYSTEM_PROMPT_TEMPLATE,
|
||||
{
|
||||
name: chatAgent.name,
|
||||
agent_prompt: chatAgent.config.prompt,
|
||||
agent_chat_prompt: member.config.chatPrompt,
|
||||
language,
|
||||
topic,
|
||||
members: members
|
||||
.map((m) => {
|
||||
if (m.userType === "User") {
|
||||
return `- ${user.name} (${m.config.introduction})`;
|
||||
} else {
|
||||
const agent = chatAgents.find((a) => a.id === m.userId);
|
||||
if (!agent) return "";
|
||||
return `- ${agent.name} (${agent.introduction})`;
|
||||
}
|
||||
})
|
||||
.join("\n"),
|
||||
history: "...",
|
||||
},
|
||||
{},
|
||||
["{", "}"]
|
||||
)
|
||||
: "";
|
||||
|
||||
if (member.userType === "User") {
|
||||
return (
|
||||
<>
|
||||
<Label>{t("models.chat.memberConfig")}</Label>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Avatar className="w-12 h-12">
|
||||
<img src={user.avatarUrl} />
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm line-clamp-1">{user.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Checkbox checked={true} disabled id="member" />
|
||||
<Label htmlFor="member">{t("addToChat")}</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("introduction")}</Label>
|
||||
<Textarea
|
||||
value={member.config.introduction}
|
||||
onChange={(event) => {
|
||||
const introduction = event.target.value;
|
||||
onConfigChange({ ...member.config, introduction });
|
||||
}}
|
||||
placeholder={t("introduceYourself")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (chatAgent) {
|
||||
return (
|
||||
<>
|
||||
<Label>{t("models.chat.memberConfig")}</Label>
|
||||
<div className="p-4 border space-y-4 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Avatar className="w-12 h-12">
|
||||
<img src={chatAgent.avatarUrl} />
|
||||
<AvatarFallback>{chatAgent.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm line-clamp-1">{chatAgent.name}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{chatAgent.introduction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
id="member"
|
||||
/>
|
||||
<Label htmlFor="member">{t("addToChat")}</Label>
|
||||
</div>
|
||||
{checked && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("prompt")}</Label>
|
||||
<Textarea
|
||||
value={member.config.prompt}
|
||||
onChange={(event) => {
|
||||
const prompt = event.target.value;
|
||||
onConfigChange({ ...member.config, prompt });
|
||||
}}
|
||||
placeholder={t("extraPromptForChat")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("promptPreview")}</Label>
|
||||
<Textarea className="min-h-36" disabled value={fullPrompt} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
233
enjoy/src/renderer/components/chats/chat-gpt-form.tsx
Normal file
233
enjoy/src/renderer/components/chats/chat-gpt-form.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Slider,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AISettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const ChatGPTForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
const { form } = props;
|
||||
const { gptProviders } = useContext(AISettingsProviderContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.engine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.engine")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectAiEngine")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.keys(gptProviders).map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{gptProviders[key].name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{gptProviders[field.value as string]?.description}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.model")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectAiModel")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(
|
||||
gptProviders[form.watch("config.gpt.engine") as string]
|
||||
?.models || []
|
||||
).map((option: string) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.temperature"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.temperature")}</FormLabel>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
value={[field.value as number]}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
/>
|
||||
<span>{field.value as number}</span>
|
||||
</div>
|
||||
<FormDescription>{t("gpt.temperatureDescription")}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.historyBufferSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.historyBufferSize")}</FormLabel>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
value={[field.value as number]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
<span>{field.value as number}</span>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("gpt.historyBufferSizeDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.maxCompletionTokens"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.maxCompletionTokens")}</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={field.value}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return;
|
||||
field.onChange(parseInt(event.target.value));
|
||||
}}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("gpt.maxCompletionTokensDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.presencePenalty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.presencePenalty")}</FormLabel>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
value={[field.value as number]}
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<span>{field.value as number}</span>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("gpt.presencePenaltyDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.frequencyPenalty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.frequencyPenalty")}</FormLabel>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
value={[field.value as number]}
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
/>
|
||||
<span>{field.value as number}</span>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("gpt.frequencyPenaltyDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.gpt.numberOfChoices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("gpt.numberOfChoices")}</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1.0"
|
||||
value={field.value}
|
||||
onChange={(event) => {
|
||||
field.onChange(
|
||||
event.target.value ? parseInt(event.target.value) : 1.0
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("gpt.numberOfChoicesDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
77
enjoy/src/renderer/components/chats/chat-header.tsx
Normal file
77
enjoy/src/renderer/components/chats/chat-header.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
ScrollArea,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
SettingsIcon,
|
||||
SpeechIcon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
import { ChatSettings } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import { ChatBubbleIcon } from "@radix-ui/react-icons";
|
||||
import { ChatTypeEnum } from "@/types/enums";
|
||||
import { ChatSessionProviderContext } from "@/renderer/context";
|
||||
|
||||
export const ChatHeader = (props: {
|
||||
sidePanelCollapsed: boolean;
|
||||
toggleSidePanel: () => void;
|
||||
}) => {
|
||||
const { sidePanelCollapsed, toggleSidePanel } = props;
|
||||
const [displayChatForm, setDisplayChatForm] = useState(false);
|
||||
const { chat } = useContext(ChatSessionProviderContext);
|
||||
|
||||
return (
|
||||
<div className="h-10 border-b px-4 shadow flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background mb-4">
|
||||
<div className="flex items-center space-x-1 line-clamp-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
onClick={toggleSidePanel}
|
||||
>
|
||||
{sidePanelCollapsed ? (
|
||||
<ChevronsRightIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronsLeftIcon className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
{chat.type === ChatTypeEnum.CONVERSATION && (
|
||||
<ChatBubbleIcon className="w-4 h-4" />
|
||||
)}
|
||||
{chat.type === ChatTypeEnum.GROUP && (
|
||||
<UsersRoundIcon className="w-4 h-4" />
|
||||
)}
|
||||
{chat.type === ChatTypeEnum.TTS && <SpeechIcon className="w-4 h-4" />}
|
||||
<span className="text-sm">{chat.name}</span>
|
||||
</div>
|
||||
<Dialog open={displayChatForm} onOpenChange={setDisplayChatForm}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="absolute right-4">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-screen-sm max-h-[70%] overflow-y-auto">
|
||||
<DialogTitle>{t("editChat")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Edit chat settings
|
||||
</DialogDescription>
|
||||
<ScrollArea className="h-full px-4">
|
||||
<ChatSettings onFinish={() => setDisplayChatForm(false)} />
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +1,32 @@
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
LoaderIcon,
|
||||
MicIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SendIcon,
|
||||
StepForwardIcon,
|
||||
TextIcon,
|
||||
TypeIcon,
|
||||
WandIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
Textarea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from "react";
|
||||
import { Button, Textarea } from "@renderer/components/ui";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { LiveAudioVisualizer } from "react-audio-visualize";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
ChatProviderContext,
|
||||
ChatSessionProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import autosize from "autosize";
|
||||
import { LoaderSpin } from "@renderer/components";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import { md5 } from "js-md5";
|
||||
import { ChatSuggestionButton } from "@renderer/components";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { ChatTypeEnum } from "@/types/enums";
|
||||
|
||||
export const ChatInput = () => {
|
||||
const { currentChat } = useContext(ChatProviderContext);
|
||||
const {
|
||||
chat,
|
||||
submitting,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
@@ -56,7 +43,7 @@ export const ChatInput = () => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const submitRef = useRef<HTMLButtonElement>(null);
|
||||
const [inputMode, setInputMode] = useState<"text" | "audio">("audio");
|
||||
const [inputMode, setInputMode] = useState<"text" | "audio">("text");
|
||||
const [content, setContent] = useState("");
|
||||
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
|
||||
|
||||
@@ -82,7 +69,7 @@ export const ChatInput = () => {
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects
|
||||
.get(`chat-input-mode-${currentChat.id}`)
|
||||
.get(`chat-input-mode-${chat.id}`)
|
||||
.then((cachedInputMode) => {
|
||||
if (cachedInputMode) {
|
||||
setInputMode(cachedInputMode as typeof inputMode);
|
||||
@@ -91,7 +78,7 @@ export const ChatInput = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects.set(`chat-input-mode-${currentChat.id}`, inputMode);
|
||||
EnjoyApp.cacheObjects.set(`chat-input-mode-${chat.id}`, inputMode);
|
||||
}, [inputMode]);
|
||||
|
||||
useHotkeys(
|
||||
@@ -113,7 +100,7 @@ export const ChatInput = () => {
|
||||
currentHotkeys.PlayNextSegment,
|
||||
() => {
|
||||
if (shadowing) return;
|
||||
askAgent();
|
||||
askAgent({ force: true });
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
@@ -122,7 +109,7 @@ export const ChatInput = () => {
|
||||
|
||||
if (isRecording) {
|
||||
return (
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="z-10 w-full flex justify-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
@@ -140,7 +127,7 @@ export const ChatInput = () => {
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
@@ -156,14 +143,14 @@ export const ChatInput = () => {
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
@@ -171,7 +158,7 @@ export const ChatInput = () => {
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
@@ -186,9 +173,9 @@ export const ChatInput = () => {
|
||||
|
||||
if (inputMode === "text") {
|
||||
return (
|
||||
<div className="w-full flex items-end gap-2 px-2">
|
||||
<div className="z-10 w-full flex items-end gap-2 px-2 py-2 bg-muted mx-4 rounded-3xl shadow-lg">
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("audioInput")}
|
||||
disabled={submitting}
|
||||
onClick={() => setInputMode("audio")}
|
||||
@@ -205,45 +192,53 @@ export const ChatInput = () => {
|
||||
disabled={submitting}
|
||||
placeholder={t("pressEnterToSend")}
|
||||
data-testid="chat-input"
|
||||
className="leading-6 bg-muted h-9 text-muted-foreground rounded-lg text-base px-3 py-1 shadow-none focus-visible:outline-0 focus-visible:ring-0 border-none min-h-[2.25rem] max-h-[70vh] scrollbar-thin !overflow-x-hidden"
|
||||
className="flex-1 h-8 text-muted-foreground rounded-lg text-sm leading-7 px-0 py-1 shadow-none focus-visible:outline-0 focus-visible:ring-0 border-none min-h-[2.25rem] max-h-[70vh] scrollbar-thin !overflow-x-hidden"
|
||||
/>
|
||||
<Button
|
||||
ref={submitRef}
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("send")}
|
||||
onClick={() => onCreateMessage(content).then(() => setContent(""))}
|
||||
onClick={() =>
|
||||
onCreateMessage(content, { onSuccess: () => setContent("") })
|
||||
}
|
||||
disabled={submitting || !content}
|
||||
className=""
|
||||
variant="ghost"
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
variant="default"
|
||||
size="icon"
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderIcon className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<SendIcon className="w-6 h-6" />
|
||||
<ArrowUpIcon className="w-6 h-6" />
|
||||
)}
|
||||
</Button>
|
||||
<ChatSuggestionButton asChild>
|
||||
{chat.config.enableChatAssistant && (
|
||||
<ChatSuggestionButton chat={chat} asChild>
|
||||
<Button
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("suggestion")}
|
||||
className="rounded-full w-8 h-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<WandIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
</ChatSuggestionButton>
|
||||
)}
|
||||
|
||||
{chat.type === ChatTypeEnum.GROUP && (
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("suggestion")}
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("continue")}
|
||||
disabled={submitting}
|
||||
onClick={() => askAgent({ force: true })}
|
||||
className=""
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<WandIcon className="w-6 h-6" />
|
||||
<StepForwardIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
</ChatSuggestionButton>
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
disabled={submitting}
|
||||
onClick={() => askAgent()}
|
||||
className=""
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<StepForwardIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -251,22 +246,22 @@ export const ChatInput = () => {
|
||||
return (
|
||||
<div className="w-full flex items-center gap-4 justify-center relative">
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("textInput")}
|
||||
disabled={submitting}
|
||||
onClick={() => setInputMode("text")}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
className="rounded-full shadow-lg w-8 h-8"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<TextIcon className="w-4 h-4" />
|
||||
<TypeIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("record")}
|
||||
disabled={submitting}
|
||||
onClick={startRecording}
|
||||
className="rounded-full shadow w-10 h-10"
|
||||
className="rounded-full shadow-lg w-10 h-10"
|
||||
size="icon"
|
||||
>
|
||||
{submitting ? (
|
||||
@@ -275,167 +270,20 @@ export const ChatInput = () => {
|
||||
<MicIcon className="w-6 h-6" />
|
||||
)}
|
||||
</Button>
|
||||
<ChatSuggestionButton />
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
disabled={submitting}
|
||||
onClick={() => askAgent()}
|
||||
className="absolute right-4 rounded-full shadow w-8 h-8"
|
||||
variant="default"
|
||||
size="icon"
|
||||
>
|
||||
<StepForwardIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
{chat.config.enableChatAssistant && <ChatSuggestionButton chat={chat} />}
|
||||
{chat.type === ChatTypeEnum.GROUP && (
|
||||
<Button
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("continue")}
|
||||
disabled={submitting}
|
||||
onClick={() => askAgent({ force: true })}
|
||||
className="rounded-full shadow-lg w-8 h-8"
|
||||
variant="default"
|
||||
size="icon"
|
||||
>
|
||||
<StepForwardIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatSuggestionButton = (props: {
|
||||
asChild?: boolean;
|
||||
children?: ReactElement;
|
||||
}) => {
|
||||
const { currentChat } = useContext(ChatProviderContext);
|
||||
const { chatMessages, onCreateMessage } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const [suggestions, setSuggestions] = useState<
|
||||
{ text: string; explaination: string }[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const { chatSuggestion } = useAiCommand();
|
||||
|
||||
const context = `I'm ${
|
||||
currentChat.members.find((member) => member.user).user.name
|
||||
}.
|
||||
|
||||
[Chat Topic]
|
||||
${currentChat.topic}
|
||||
|
||||
[Chat Members]
|
||||
${currentChat.members.map((m) => {
|
||||
if (m.user) {
|
||||
return `- ${m.user.name} (${m.config.introduction})[It's me]`;
|
||||
} else if (m.agent) {
|
||||
return `- ${m.agent.name} (${m.agent.introduction})`;
|
||||
}
|
||||
})}
|
||||
|
||||
[Chat History]
|
||||
${chatMessages
|
||||
.filter((m) => m.state === "completed")
|
||||
.map(
|
||||
(message) =>
|
||||
`- ${(message.member.user || message.member.agent).name}: ${
|
||||
message.content
|
||||
}(${formatDateTime(message.createdAt)})`
|
||||
)
|
||||
.join("\n")}
|
||||
`;
|
||||
|
||||
const contextCacheKey = `chat-suggestion-${md5(
|
||||
chatMessages
|
||||
.filter((m) => m.state === "completed")
|
||||
.map((m) => m.content)
|
||||
.join("\n")
|
||||
)}`;
|
||||
|
||||
const suggest = async () => {
|
||||
setLoading(true);
|
||||
chatSuggestion(context, {
|
||||
cacheKey: contextCacheKey,
|
||||
})
|
||||
.then((res) => setSuggestions(res.suggestions))
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !suggestions?.length) {
|
||||
suggest();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects.get(contextCacheKey).then((result) => {
|
||||
if (result && result?.suggestions) {
|
||||
setSuggestions(result.suggestions as typeof suggestions);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
});
|
||||
}, [contextCacheKey]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{props.asChild ? (
|
||||
{ ...props.children }
|
||||
) : (
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-content={t("suggestion")}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<WandIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" className="bg-muted w-full max-w-screen-md">
|
||||
{loading || suggestions.length === 0 ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<ScrollArea className="h-72 px-3">
|
||||
<div className="select-text grid gap-6">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div key={index} className="grid gap-4">
|
||||
<div className="text-sm">{suggestion.explaination}</div>
|
||||
<div className="px-4 py-2 rounded bg-background flex items-end justify-between space-x-2">
|
||||
<div className="font-serif">{suggestion.text}</div>
|
||||
<div>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("send")}
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="rounded-full w-6 h-6"
|
||||
onClick={() =>
|
||||
onCreateMessage(suggestion.text).finally(() =>
|
||||
setOpen(false)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SendIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={loading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => suggest()}
|
||||
>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<PopoverArrow />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
190
enjoy/src/renderer/components/chats/chat-list.tsx
Normal file
190
enjoy/src/renderer/components/chats/chat-list.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { ChatCard } from "@renderer/components";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { DEFAULT_GPT_CONFIG } from "@/constants";
|
||||
import { useChat } from "@renderer/hooks";
|
||||
import { isSameTimeRange } from "@renderer/lib/utils";
|
||||
import { ChatAgentTypeEnum } from "@/types/enums";
|
||||
|
||||
export const ChatList = (props: {
|
||||
chats: ChatType[];
|
||||
chatAgent: ChatAgentType;
|
||||
currentChat: ChatType;
|
||||
setCurrentChat: (chat: ChatType) => void;
|
||||
}) => {
|
||||
const { chats, chatAgent, currentChat, setCurrentChat } = props;
|
||||
const { sttEngine, currentGptEngine, currentTtsEngine } = useContext(
|
||||
AISettingsProviderContext
|
||||
);
|
||||
const { EnjoyApp, learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
const { currentChat: copilotCurrentChat } = useContext(
|
||||
CopilotProviderContext
|
||||
);
|
||||
const [deletingChat, setDeletingChat] = useState<ChatType>(null);
|
||||
|
||||
const handleCreateChat = () => {
|
||||
if (!chatAgent) {
|
||||
return;
|
||||
}
|
||||
EnjoyApp.chats
|
||||
.create({
|
||||
name: t("newChat"),
|
||||
config: {
|
||||
sttEngine: sttEngine,
|
||||
},
|
||||
members: [buildAgentMember(chatAgent)],
|
||||
})
|
||||
.then((chat) => {
|
||||
setCurrentChat(chat);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteChat = async () => {
|
||||
if (!deletingChat) return;
|
||||
|
||||
EnjoyApp.chats
|
||||
.destroy(deletingChat.id)
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingChat(null);
|
||||
});
|
||||
};
|
||||
|
||||
const buildAgentMember = (agent: ChatAgentType): ChatMemberDtoType => {
|
||||
const config =
|
||||
agent.type === ChatAgentTypeEnum.TTS
|
||||
? {
|
||||
tts: {
|
||||
engine: currentTtsEngine.name,
|
||||
model: currentTtsEngine.model,
|
||||
voice: currentTtsEngine.voice,
|
||||
language: learningLanguage,
|
||||
...agent.config.tts,
|
||||
},
|
||||
}
|
||||
: {
|
||||
gpt: {
|
||||
...DEFAULT_GPT_CONFIG,
|
||||
engine: currentGptEngine.name,
|
||||
model: currentGptEngine.models.default,
|
||||
},
|
||||
tts: {
|
||||
engine: currentTtsEngine.name,
|
||||
model: currentTtsEngine.model,
|
||||
voice: currentTtsEngine.voice,
|
||||
language: learningLanguage,
|
||||
},
|
||||
};
|
||||
return {
|
||||
userId: agent.id,
|
||||
userType: "ChatAgent",
|
||||
config,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentAgentNotInvolved =
|
||||
currentChat?.members?.findIndex(
|
||||
(member) => member.userId === chatAgent?.id
|
||||
) === -1;
|
||||
const currentChatIsNotFound =
|
||||
chats?.findIndex((chat) => chat.id === currentChat?.id) === -1;
|
||||
|
||||
if (!currentChat || currentAgentNotInvolved || currentChatIsNotFound) {
|
||||
const chat = chats.find((chat) => chat.id !== copilotCurrentChat?.id);
|
||||
if (chat) {
|
||||
setCurrentChat(chat);
|
||||
} else {
|
||||
handleCreateChat();
|
||||
}
|
||||
}
|
||||
}, [chats, chatAgent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-y-auto h-full py-2 px-1 relative">
|
||||
<Button
|
||||
className="w-full mb-1 p-1 justify-start items-center"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!chatAgent}
|
||||
onClick={handleCreateChat}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 mr-1" />
|
||||
<span className="text-xs font-semibold capitalize">
|
||||
{t("newChat")}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="px-2 mb-1">
|
||||
<span className="text-sm font-semibold capitalize">
|
||||
{t("recents")}
|
||||
</span>
|
||||
</div>
|
||||
{chats.length === 0 && (
|
||||
<div className="text-center my-4">
|
||||
<span className="text-sm text-muted-foreground">{t("noData")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-1">
|
||||
{chats.map((chat, index) => (
|
||||
<ChatCard
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
displayDate={
|
||||
index === 0 ||
|
||||
!isSameTimeRange(chat.updatedAt, chats[index - 1].updatedAt)
|
||||
}
|
||||
selected={currentChat?.id === chat.id}
|
||||
onSelect={setCurrentChat}
|
||||
onDelete={setDeletingChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={!!deletingChat}
|
||||
onOpenChange={() => setDeletingChat(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t("deleteChat")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteChatConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeletingChat(null)}>
|
||||
{t("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={handleDeleteChat}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
233
enjoy/src/renderer/components/chats/chat-member-form.tsx
Normal file
233
enjoy/src/renderer/components/chats/chat-member-form.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Textarea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import Mustache from "mustache";
|
||||
import { ChatGPTForm, ChatTTSForm } from "@renderer/components";
|
||||
|
||||
export const ChatMemberForm = (props: {
|
||||
chat: ChatType;
|
||||
member: Partial<ChatMemberType>;
|
||||
onFinish?: () => void;
|
||||
onDelete?: () => void;
|
||||
}) => {
|
||||
const { member, onFinish, chat, onDelete } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const buildFullPrompt = (prompt: string) => {
|
||||
return Mustache.render(
|
||||
`{{{agent_prompt}}}
|
||||
{{{chat_prompt}}}
|
||||
{{{member_prompt}}}`,
|
||||
{
|
||||
agent_prompt: member.agent.prompt,
|
||||
chat_prompt: chat.config.prompt,
|
||||
member_prompt: prompt,
|
||||
}
|
||||
).trim();
|
||||
};
|
||||
|
||||
const chatMemberFormSchema = z.object({
|
||||
chatId: z.string(),
|
||||
userId: z.string(),
|
||||
userType: z.enum(["User", "ChatAgent"]).default("ChatAgent"),
|
||||
config: z.object({
|
||||
prompt: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
gpt: z.object({
|
||||
engine: z.string(),
|
||||
model: z.string(),
|
||||
temperature: z.number(),
|
||||
maxCompletionTokens: z.number().optional(),
|
||||
frequencyPenalty: z.number().optional(),
|
||||
presencePenalty: z.number().optional(),
|
||||
numberOfChoices: z.number().optional(),
|
||||
historyBufferSize: z.number().optional(),
|
||||
}),
|
||||
tts: z.object({
|
||||
engine: z.string(),
|
||||
model: z.string(),
|
||||
voice: z.string(),
|
||||
language: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof chatMemberFormSchema>>({
|
||||
resolver: zodResolver(chatMemberFormSchema),
|
||||
values: {
|
||||
chatId: chat.id,
|
||||
...member,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(
|
||||
(data: z.infer<typeof chatMemberFormSchema>) => {
|
||||
if (member?.id) {
|
||||
EnjoyApp.chatMembers
|
||||
.update(member.id, data)
|
||||
.then(() => {
|
||||
toast.success(t("chatMemberUpdated"));
|
||||
onFinish?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
} else {
|
||||
EnjoyApp.chatMembers
|
||||
.create(data)
|
||||
.then(() => {
|
||||
toast.success(t("chatMemberAdded"));
|
||||
onFinish?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleRemove = () => {
|
||||
if (!member.id) return;
|
||||
|
||||
EnjoyApp.chatMembers
|
||||
.destroy(member.id)
|
||||
.then(() => {
|
||||
toast.success(t("chatMemberRemoved"));
|
||||
onDelete?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
if (!member) return null;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Accordion
|
||||
defaultValue="gpt"
|
||||
type="single"
|
||||
collapsible
|
||||
className="mb-6"
|
||||
>
|
||||
<AccordionItem value="gpt">
|
||||
<AccordionTrigger className="text-muted-foreground">
|
||||
{t("models.chatMember.gptSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<ChatGPTForm form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="tts">
|
||||
<AccordionTrigger className="text-muted-foreground">
|
||||
{t("models.chatMember.ttsSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<ChatTTSForm form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="more">
|
||||
<AccordionTrigger className="text-muted-foreground">
|
||||
{t("models.chatMember.moreSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("models.chatMember.prompt")}</FormLabel>
|
||||
<Textarea
|
||||
placeholder={t("models.chatMember.promptPlaceholder")}
|
||||
className="max-h-48"
|
||||
{...field}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("models.chatMember.promptDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{t("promptPreview")}:
|
||||
</div>
|
||||
<div className="text-muted-foreground bg-muted px-4 py-2 rounded-md">
|
||||
<div className="font-serif select-text text-sm whitespace-pre-line">
|
||||
{buildFullPrompt(form.watch("config.prompt"))}
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex items-center justify-end space-x-4 w-full">
|
||||
{member?.id && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="text-destructive" variant="secondary">
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("removeChatMember")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("removeChatMemberConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t("remove")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onFinish?.()}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +1,47 @@
|
||||
import { ChatMessageCategoryEnum, ChatMessageRoleEnum } from "@/types/enums";
|
||||
import { ChatAgentMessage, ChatUserMessage } from "@renderer/components";
|
||||
import { useContext, useEffect } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
ChatSessionProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const ChatMessage = (props: {
|
||||
chatMessage: ChatMessageType;
|
||||
isLastMessage: boolean;
|
||||
onEditChatMember: (chatMember: ChatMemberType) => void;
|
||||
}) => {
|
||||
const { chatMessage, isLastMessage } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { dispatchChatMessages } = useContext(ChatSessionProviderContext);
|
||||
const { chatMessage, isLastMessage, onEditChatMember } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatMessage?.member) {
|
||||
EnjoyApp.chatMessages.findOne({ id: chatMessage.id }).then((message) => {
|
||||
dispatchChatMessages({
|
||||
type: "update",
|
||||
record: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [chatMessage]);
|
||||
|
||||
if (chatMessage.member?.userType === "User") {
|
||||
if (chatMessage.role === ChatMessageRoleEnum.USER) {
|
||||
return (
|
||||
<ChatUserMessage
|
||||
chatMessage={props.chatMessage}
|
||||
chatMessage={chatMessage}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
);
|
||||
} else if (props.chatMessage.member?.userType === "Agent") {
|
||||
} else if (chatMessage.role === ChatMessageRoleEnum.AGENT) {
|
||||
return (
|
||||
<ChatAgentMessage
|
||||
chatMessage={props.chatMessage}
|
||||
isLastMessage={isLastMessage}
|
||||
onEditChatMember={onEditChatMember}
|
||||
/>
|
||||
);
|
||||
} else if (chatMessage.role === ChatMessageRoleEnum.SYSTEM) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
{chatMessage.category === ChatMessageCategoryEnum.MEMBER_JOINED && (
|
||||
<span>
|
||||
{chatMessage.agent
|
||||
? t("memberJoined", { name: chatMessage.agent.name })
|
||||
: chatMessage.content}
|
||||
</span>
|
||||
)}
|
||||
{chatMessage.category === ChatMessageCategoryEnum.MEMBER_LEFT && (
|
||||
<span>
|
||||
{chatMessage.agent
|
||||
? t("memberLeft", { name: chatMessage.agent.name })
|
||||
: chatMessage.content}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,127 @@
|
||||
import { ChatSessionProviderContext } from "@renderer/context";
|
||||
import {
|
||||
ChatSessionProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { ChatMessage } from "@renderer/components";
|
||||
import { useContext } from "react";
|
||||
ChatAgentForm,
|
||||
ChatMemberForm,
|
||||
ChatMessage,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from "@renderer/components/ui";
|
||||
import { ChatAgentTypeEnum } from "@/types/enums";
|
||||
|
||||
export const ChatMessages = () => {
|
||||
const { chatMessages } = useContext(ChatSessionProviderContext);
|
||||
const lastMessage = chatMessages[chatMessages.length - 1];
|
||||
const { chatMessages, chat, asking } = useContext(ChatSessionProviderContext);
|
||||
const [editingChatMember, setEditingChatMember] =
|
||||
useState<ChatMemberType>(null);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 mb-4">
|
||||
{chatMessages.map((message) => (
|
||||
<ChatMessage key={message.id} chatMessage={message} isLastMessage={lastMessage?.id === message.id} />
|
||||
))}
|
||||
<>
|
||||
<div className="flex-1 space-y-6 px-4 mb-4">
|
||||
{chatMessages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
chatMessage={message}
|
||||
isLastMessage={
|
||||
chatMessages[chatMessages.length - 1]?.id === message.id
|
||||
}
|
||||
onEditChatMember={setEditingChatMember}
|
||||
/>
|
||||
))}
|
||||
{asking?.chatId === chat.id && (
|
||||
<ChatAgentMessageLoading
|
||||
chatMember={asking}
|
||||
onClick={() => setEditingChatMember(asking)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={!!editingChatMember}
|
||||
onOpenChange={() => setEditingChatMember(null)}
|
||||
>
|
||||
<DialogContent className="max-w-screen-sm max-h-[70%] overflow-y-auto">
|
||||
<DialogTitle>{editingChatMember?.agent?.name}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Edit chat member
|
||||
</DialogDescription>
|
||||
{editingChatMember?.agent?.type === ChatAgentTypeEnum.GPT && (
|
||||
<ChatMemberForm
|
||||
chat={chat}
|
||||
member={editingChatMember}
|
||||
onFinish={() => setEditingChatMember(null)}
|
||||
/>
|
||||
)}
|
||||
{editingChatMember?.agent?.type === ChatAgentTypeEnum.TTS && (
|
||||
<ChatAgentForm
|
||||
agent={editingChatMember.agent}
|
||||
onFinish={() => setEditingChatMember(null)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatAgentMessageLoading = (props: {
|
||||
chatMember: ChatMemberType;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const { chatMember, onClick } = props;
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mb-6">
|
||||
<div className="mb-2 flex">
|
||||
<ChatAgentAvatar chatMember={chatMember} onClick={onClick} />
|
||||
</div>
|
||||
<div className="py-2 mb-2 rounded-lg w-full">
|
||||
<LoaderSpin />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatAgentAvatar = (props: {
|
||||
chatMember: ChatMemberType;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const { chatMember, onClick } = props;
|
||||
if (!chatMember.agent) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage src={chatMember.agent.avatarUrl}></AvatarImage>
|
||||
<AvatarFallback className="bg-background">
|
||||
{chatMember.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm">{chatMember.name}</div>
|
||||
<div className="italic text-xs text-muted-foreground/50">
|
||||
{chatMember.agent.type === ChatAgentTypeEnum.GPT &&
|
||||
chatMember.config.gpt?.model}
|
||||
{chatMember.agent.type === ChatAgentTypeEnum.TTS &&
|
||||
chatMember.agent.config.tts?.voice}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
38
enjoy/src/renderer/components/chats/chat-session.tsx
Normal file
38
enjoy/src/renderer/components/chats/chat-session.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ScrollArea } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatSessionProvider } from "@renderer/context";
|
||||
import { ChatHeader, ChatInput, ChatMessages } from "@renderer/components";
|
||||
|
||||
export const ChatSession = (props: {
|
||||
chatId: string;
|
||||
sidePanelCollapsed: boolean;
|
||||
toggleSidePanel: () => void;
|
||||
}) => {
|
||||
const { chatId, sidePanelCollapsed, toggleSidePanel } = props;
|
||||
|
||||
if (!chatId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<span className="text-muted-foreground">{t("noChatSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-screen relative">
|
||||
<ChatSessionProvider chatId={chatId}>
|
||||
<ChatHeader
|
||||
sidePanelCollapsed={sidePanelCollapsed}
|
||||
toggleSidePanel={toggleSidePanel}
|
||||
/>
|
||||
<div className="w-full max-w-screen-md mx-auto">
|
||||
<ChatMessages />
|
||||
<div className="h-16" />
|
||||
<div className="absolute w-full max-w-screen-md bottom-0 min-h-16 pb-3 flex items-center">
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
</ChatSessionProvider>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
219
enjoy/src/renderer/components/chats/chat-settings.tsx
Normal file
219
enjoy/src/renderer/components/chats/chat-settings.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatMemberForm, ChatForm, ChatAgentForm } from "@renderer/components";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
ChatSessionProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { DEFAULT_GPT_CONFIG } from "@/constants";
|
||||
import { ChatAgentTypeEnum, ChatTypeEnum } from "@/types/enums";
|
||||
|
||||
export const ChatSettings = (props: { onFinish?: () => void }) => {
|
||||
const { onFinish } = props;
|
||||
const { chat, chatMembers } = useContext(ChatSessionProviderContext);
|
||||
const agentMembers = chatMembers.filter(
|
||||
(member) => member.userType === "ChatAgent"
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="chat" className="mb-6">
|
||||
<TabsList className="w-full grid grid-cols-2 mb-4">
|
||||
<TabsTrigger value="chat">{t("models.chat.chatSettings")}</TabsTrigger>
|
||||
<TabsTrigger value="members">
|
||||
{t("models.chat.memberSettings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chat">
|
||||
<ChatForm chat={chat} onFinish={onFinish} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members">
|
||||
{chat.type === ChatTypeEnum.TTS ? (
|
||||
<ChatAgentForm agent={chatMembers[0]?.agent} onFinish={onFinish} />
|
||||
) : agentMembers.length > 0 ? (
|
||||
<ChatMemberSetting
|
||||
chat={chat}
|
||||
agentMembers={agentMembers}
|
||||
onFinish={onFinish}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-4 text-center">
|
||||
{t("noData")}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatMemberSetting = (props: {
|
||||
chat: ChatType;
|
||||
agentMembers: Partial<ChatMemberType>[];
|
||||
onFinish?: () => void;
|
||||
}) => {
|
||||
const { chat, agentMembers, onFinish } = props;
|
||||
const { EnjoyApp, learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
const { currentGptEngine, currentTtsEngine } = useContext(
|
||||
AISettingsProviderContext
|
||||
);
|
||||
const [memberTab, setMemberTab] = useState(agentMembers[0]?.userId);
|
||||
const [query, setQuery] = useState("");
|
||||
const [chatAgents, setChatAgents] = useState<ChatAgentType[]>([]);
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const handleAddAgentMember = (chatAgent: ChatAgentType) => {
|
||||
EnjoyApp.chatMembers
|
||||
.create({
|
||||
chatId: chat.id,
|
||||
userId: chatAgent.id,
|
||||
userType: "ChatAgent",
|
||||
config: {
|
||||
gpt: {
|
||||
...DEFAULT_GPT_CONFIG,
|
||||
engine: currentGptEngine.name,
|
||||
model: currentGptEngine.models.default,
|
||||
},
|
||||
tts: {
|
||||
engine: currentTtsEngine.name,
|
||||
model: currentTtsEngine.model,
|
||||
voice: currentTtsEngine.voice,
|
||||
language: learningLanguage,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("chatMemberAdded"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchChatAgents = async (query?: string) => {
|
||||
EnjoyApp.chatAgents
|
||||
.findAll({ query })
|
||||
.then((data) => {
|
||||
setChatAgents(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatAgents(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
if (agentMembers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Tabs value={memberTab} onValueChange={setMemberTab}>
|
||||
<TabsList>
|
||||
{agentMembers.map((member) => (
|
||||
<TabsTrigger key={member.userId} value={member.userId}>
|
||||
{member.agent.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="new">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="capitalize">{t("addMember")}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{agentMembers.map((member) => (
|
||||
<TabsContent key={member.userId} value={member.userId}>
|
||||
<ChatMemberForm
|
||||
chat={chat}
|
||||
member={member}
|
||||
onDelete={() => {
|
||||
setMemberTab("new");
|
||||
}}
|
||||
onFinish={onFinish}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
<TabsContent value="new">
|
||||
<div className="overflow-hidden h-full relative py-2 px-1">
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded h-8 text-xs"
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
</div>
|
||||
{chatAgents.length === 0 && (
|
||||
<div className="text-center my-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("noData")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
{chatAgents.map((chatAgent) => (
|
||||
<div
|
||||
key={chatAgent.id}
|
||||
className="flex items-center justify-between space-x-4"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<img src={chatAgent.avatarUrl} alt={chatAgent.name} />
|
||||
<AvatarFallback>{chatAgent.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 line-clamp-1 w-full">
|
||||
<div className="text-sm line-clamp-1">
|
||||
{chatAgent.name}
|
||||
</div>
|
||||
<Badge className="text-xs px-1" variant="secondary">
|
||||
{chatAgent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{chatAgent.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleAddAgentMember(chatAgent);
|
||||
}}
|
||||
disabled={
|
||||
chatAgent.type === ChatAgentTypeEnum.TTS ||
|
||||
agentMembers.findIndex(
|
||||
(member) => member.userId === chatAgent.id
|
||||
) > -1
|
||||
}
|
||||
>
|
||||
{agentMembers.findIndex(
|
||||
(member) => member.userId === chatAgent.id
|
||||
) > -1
|
||||
? t("added")
|
||||
: t("addToChat")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -1,176 +0,0 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
Input,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
ChatProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { ChatCard, ChatForm, ChatAgents } from "@renderer/components";
|
||||
import { AGENT_FIXTURE_ANDREW, AGENT_FIXTURE_AVA } from "@/constants";
|
||||
|
||||
export const ChatSidebar = () => {
|
||||
const {
|
||||
chats,
|
||||
fetchChats,
|
||||
currentChat,
|
||||
setCurrentChat,
|
||||
chatAgents,
|
||||
createChat,
|
||||
createChatAgent,
|
||||
} = useContext(ChatProviderContext);
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [displayChatForm, setDisplayChatForm] = useState(false);
|
||||
const [displayAgentForm, setDisplayAgentForm] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
// generate chat agents and a chat for example
|
||||
const quickStart = async () => {
|
||||
try {
|
||||
let ava = await EnjoyApp.chatAgents.findOne({
|
||||
where: {
|
||||
name: AGENT_FIXTURE_AVA.name,
|
||||
introduction: AGENT_FIXTURE_AVA.introduction,
|
||||
},
|
||||
});
|
||||
if (!ava) {
|
||||
ava = (await createChatAgent(
|
||||
AGENT_FIXTURE_AVA as any
|
||||
)) as ChatAgentType;
|
||||
}
|
||||
|
||||
let andrew = await EnjoyApp.chatAgents.findOne({
|
||||
where: {
|
||||
name: AGENT_FIXTURE_ANDREW.name,
|
||||
introduction: AGENT_FIXTURE_ANDREW.introduction,
|
||||
},
|
||||
});
|
||||
if (!andrew) {
|
||||
andrew = (await createChatAgent(
|
||||
AGENT_FIXTURE_ANDREW as any
|
||||
)) as ChatAgentType;
|
||||
}
|
||||
if (!ava || !andrew) return;
|
||||
|
||||
await createChat({
|
||||
name: "Making Friends",
|
||||
language: "en-US",
|
||||
topic: "Improving speaking skills and American culture.",
|
||||
members: [
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
userType: "User",
|
||||
config: {
|
||||
introduction:
|
||||
"I'm studying English and want to make friends with native speakers.",
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: ava.id,
|
||||
userType: "Agent",
|
||||
},
|
||||
{
|
||||
userId: andrew.id,
|
||||
userType: "Agent",
|
||||
},
|
||||
],
|
||||
config: {
|
||||
sttEngine: "azure",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-screen w-64 bg-muted border-r">
|
||||
<div className="flex items-center justify-around px-2 py-4">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded-full"
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setDisplayChatForm(true)}>
|
||||
{t("addChat")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDisplayAgentForm(true)}>
|
||||
{t("agentsManagement")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{chats.length === 0 && (
|
||||
<>
|
||||
<div className="text-center my-4">
|
||||
<span className="text-sm text-muted-foreground">{t("noData")}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<Button onClick={() => quickStart()} variant="default" size="sm">
|
||||
{t("quickStart")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col space-y-2 px-2">
|
||||
{chats.map((chat) => (
|
||||
<ChatCard
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
selected={currentChat?.id === chat.id}
|
||||
onSelect={setCurrentChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={displayChatForm} onOpenChange={setDisplayChatForm}>
|
||||
<DialogContent className="max-w-screen-md h-5/6">
|
||||
<DialogTitle className="sr-only"></DialogTitle>
|
||||
<ScrollArea className="h-full px-4">
|
||||
<ChatForm
|
||||
chatAgents={chatAgents}
|
||||
onSave={(data) =>
|
||||
createChat(data).then(() => setDisplayChatForm(false))
|
||||
}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={displayAgentForm} onOpenChange={setDisplayAgentForm}>
|
||||
<DialogContent className="max-w-screen-md h-5/6 p-0">
|
||||
<DialogTitle className="sr-only"></DialogTitle>
|
||||
<ChatAgents />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
170
enjoy/src/renderer/components/chats/chat-suggestion-button.tsx
Normal file
170
enjoy/src/renderer/components/chats/chat-suggestion-button.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { ArrowUpIcon, WandIcon } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ReactElement, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
ChatSessionProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { LoaderSpin } from "@renderer/components";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { md5 } from "js-md5";
|
||||
import dayjs from "@renderer/lib/dayjs";
|
||||
import { ChatMessageRoleEnum, ChatMessageStateEnum } from "@/types/enums";
|
||||
|
||||
export const ChatSuggestionButton = (props: {
|
||||
chat: ChatType;
|
||||
asChild?: boolean;
|
||||
children?: ReactElement;
|
||||
}) => {
|
||||
const { chat } = props;
|
||||
const { chatMessages, onCreateMessage } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const [suggestions, setSuggestions] = useState<
|
||||
{ text: string; explaination: string }[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { EnjoyApp, user } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const { chatSuggestion } = useAiCommand();
|
||||
|
||||
const context = `I'm ${user.name}.
|
||||
|
||||
[Chat History]
|
||||
${chatMessages
|
||||
.filter(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.AGENT ||
|
||||
m.role === ChatMessageRoleEnum.USER
|
||||
)
|
||||
.slice(-10)
|
||||
.map((message) => {
|
||||
const timestamp = dayjs(message.createdAt).fromNow();
|
||||
switch (message.role) {
|
||||
case ChatMessageRoleEnum.AGENT:
|
||||
return `${message.member.agent.name}: ${message.content} (${timestamp})`;
|
||||
case ChatMessageRoleEnum.USER:
|
||||
return `${user.name}: ${message.content} (${timestamp})`;
|
||||
case ChatMessageRoleEnum.SYSTEM:
|
||||
return `(${message.content}, ${timestamp})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n")}
|
||||
`;
|
||||
|
||||
const contextCacheKey = `chat-suggestion-${md5(
|
||||
chatMessages
|
||||
.filter((m) => m.state === ChatMessageStateEnum.COMPLETED)
|
||||
.map((m) => m.content)
|
||||
.join("\n")
|
||||
)}`;
|
||||
|
||||
const suggest = async () => {
|
||||
setLoading(true);
|
||||
chatSuggestion(context, {
|
||||
cacheKey: contextCacheKey,
|
||||
})
|
||||
.then((res) => setSuggestions(res.suggestions))
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !suggestions?.length) {
|
||||
suggest();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.cacheObjects.get(contextCacheKey).then((result) => {
|
||||
if (result && result?.suggestions) {
|
||||
setSuggestions(result.suggestions as typeof suggestions);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
}
|
||||
});
|
||||
}, [contextCacheKey]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{props.asChild ? (
|
||||
{ ...props.children }
|
||||
) : (
|
||||
<Button
|
||||
data-tooltip-id={`${chat.id}-tooltip`}
|
||||
data-tooltip-content={t("suggestion")}
|
||||
className="rounded-full shadow-lg w-8 h-8"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<WandIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" className="bg-muted w-full max-w-screen-md">
|
||||
{loading || suggestions.length === 0 ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<ScrollArea className="h-72 px-3">
|
||||
<div className="select-text grid gap-6">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div key={index} className="grid gap-4">
|
||||
<div className="text-sm">{suggestion.explaination}</div>
|
||||
<div className="px-4 py-2 rounded bg-background flex items-end justify-between space-x-2">
|
||||
<div className="font-serif">{suggestion.text}</div>
|
||||
<div>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("send")}
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="rounded-full w-6 h-6"
|
||||
onClick={() =>
|
||||
onCreateMessage(suggestion.text, {
|
||||
onSuccess: () => setOpen(false),
|
||||
})
|
||||
}
|
||||
>
|
||||
<ArrowUpIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={loading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => suggest()}
|
||||
>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<PopoverArrow />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
167
enjoy/src/renderer/components/chats/chat-tts-form.tsx
Normal file
167
enjoy/src/renderer/components/chats/chat-tts-form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@renderer/components/ui";
|
||||
import { LANGUAGES } from "@/constants";
|
||||
import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AISettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const ChatTTSForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
const { form } = props;
|
||||
const { ttsProviders } = useContext(AISettingsProviderContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.tts.engine"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tts.engine")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectTtsEngine")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.keys(ttsProviders).map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{ttsProviders[key].name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.tts.model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tts.model")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectTtsModel")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(
|
||||
ttsProviders[form.watch("config.tts.engine") as string]
|
||||
?.models || []
|
||||
).map((model: string) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.tts.language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tts.language")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder={t("selectTtsLanguage")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem
|
||||
className="text-xs"
|
||||
value={lang.code}
|
||||
key={lang.code}
|
||||
>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.tts.voice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tts.voice")}</FormLabel>
|
||||
<Select
|
||||
required
|
||||
onValueChange={field.onChange}
|
||||
value={field.value as string}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectTtsVoice")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{(
|
||||
(form.watch("config.tts.engine") === "enjoyai"
|
||||
? ttsProviders.enjoyai.voices[
|
||||
(form.watch("config.tts.model") as string)?.split(
|
||||
"/"
|
||||
)?.[0]
|
||||
]
|
||||
: ttsProviders[form.watch("config.tts.engine") as string]
|
||||
?.voices) || []
|
||||
).map((voice: any) => {
|
||||
if (typeof voice === "string") {
|
||||
return (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
<span className="capitalize">{voice}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
} else if (
|
||||
voice.language === form.watch("config.tts.language")
|
||||
) {
|
||||
return (
|
||||
<SelectItem key={voice.value} value={voice.value}>
|
||||
<span className="capitalize">{voice.label}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -14,7 +11,7 @@ import {
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
ConversationShortcuts,
|
||||
CopilotForwarder,
|
||||
MarkdownWrapper,
|
||||
PronunciationAssessmentScoreDetail,
|
||||
WavesurferPlayer,
|
||||
@@ -30,21 +27,22 @@ import {
|
||||
EditIcon,
|
||||
ForwardIcon,
|
||||
GaugeCircleIcon,
|
||||
InfoIcon,
|
||||
LoaderIcon,
|
||||
MicIcon,
|
||||
MoreVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
SparklesIcon,
|
||||
Volume2Icon,
|
||||
} from "lucide-react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
ChatProviderContext,
|
||||
ChatSessionProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { md5 } from "js-md5";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { ChatMessageRoleEnum, ChatMessageStateEnum } from "@/types/enums";
|
||||
|
||||
export const ChatUserMessage = (props: {
|
||||
chatMessage: ChatMessageType;
|
||||
@@ -55,8 +53,10 @@ export const ChatUserMessage = (props: {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>(chatMessage.content);
|
||||
const { onUpdateMessage } = useContext(ChatSessionProviderContext);
|
||||
const [displayPlayer, setDisplayPlayer] = useState(isLastMessage);
|
||||
const { onUpdateMessage, askAgent, submitting, asking } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const [displayPlayer, setDisplayPlayer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
@@ -64,90 +64,119 @@ export const ChatUserMessage = (props: {
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLastMessage) return;
|
||||
// If the message is from recording, wait for user to confirm before asking agent
|
||||
if (
|
||||
chatMessage.recording &&
|
||||
chatMessage.state !== ChatMessageStateEnum.COMPLETED
|
||||
)
|
||||
return;
|
||||
|
||||
askAgent();
|
||||
}, [chatMessage]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mb-6">
|
||||
<div className="flex items-center space-x-2 justify-end mb-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{chatMessage.member.user.name}
|
||||
</div>
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage src={chatMessage.member.user.avatarUrl}></AvatarImage>
|
||||
<AvatarFallback className="bg-background">
|
||||
{chatMessage.member.user.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div ref={ref}>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-col gap-2 p-4 mb-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full max-w-prose">
|
||||
{recording &&
|
||||
(displayPlayer ? (
|
||||
<>
|
||||
<WavesurferPlayer
|
||||
id={recording.id}
|
||||
src={recording.src}
|
||||
autoplay={true}
|
||||
<div className="w-full max-w-prose">
|
||||
<div
|
||||
className={`flex flex-col gap-2 px-3 py-2 mb-2 rounded-lg shadow-sm w-full ${
|
||||
chatMessage.state === ChatMessageStateEnum.PENDING
|
||||
? "bg-sky-500/30 border-sky-500"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{recording &&
|
||||
(displayPlayer ? (
|
||||
<>
|
||||
<WavesurferPlayer
|
||||
id={recording.id}
|
||||
src={recording.src}
|
||||
autoplay={true}
|
||||
/>
|
||||
{recording?.pronunciationAssessment && (
|
||||
<div className="flex justify-end">
|
||||
<PronunciationAssessmentScoreDetail
|
||||
assessment={recording.pronunciationAssessment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setDisplayPlayer(true)}
|
||||
className="w-8 h-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Volume2Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
))}
|
||||
{editing ? (
|
||||
<div className="">
|
||||
<Textarea
|
||||
className="bg-background mb-2"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
{recording?.pronunciationAssessment && (
|
||||
<div className="flex justify-end">
|
||||
<PronunciationAssessmentScoreDetail
|
||||
assessment={recording.pronunciationAssessment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setDisplayPlayer(true)}
|
||||
className="w-8 h-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Volume2Icon className="w-5 h-5" />
|
||||
</Button>
|
||||
))}
|
||||
{editing ? (
|
||||
<div className="">
|
||||
<Textarea
|
||||
className="bg-background mb-2"
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
onClick={() => setEditing(false)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onUpdateMessage(chatMessage.id, { content }).finally(() =>
|
||||
setEditing(false)
|
||||
)
|
||||
}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
onClick={() => setEditing(false)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onUpdateMessage(chatMessage.id, { content }).finally(() =>
|
||||
setEditing(false)
|
||||
)
|
||||
}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{chatMessage.content}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
<ChatUserMessageActions
|
||||
chatMessage={chatMessage}
|
||||
setContent={setContent}
|
||||
setEditing={setEditing}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{chatMessage.content}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
|
||||
<ChatUserMessageActions
|
||||
chatMessage={chatMessage}
|
||||
setContent={setContent}
|
||||
setEditing={setEditing}
|
||||
/>
|
||||
{chatMessage.state === ChatMessageStateEnum.PENDING &&
|
||||
!submitting &&
|
||||
!asking && (
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
<InfoIcon
|
||||
data-tooltip-id={`${chatMessage.chatId}-tooltip`}
|
||||
data-tooltip-content={t("confirmBeforeSending")}
|
||||
className="w-4 h-4 text-yellow-600"
|
||||
/>
|
||||
<Button
|
||||
disabled={submitting || Boolean(asking)}
|
||||
onClick={() => askAgent()}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{t("send")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(chatMessage.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(chatMessage.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -165,7 +194,9 @@ const ChatUserMessageActions = (props: {
|
||||
const { refine } = useAiCommand();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp, learningLanguage, user } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const {
|
||||
chatMessages,
|
||||
startRecording,
|
||||
@@ -176,7 +207,6 @@ const ChatUserMessageActions = (props: {
|
||||
onDeleteMessage,
|
||||
submitting,
|
||||
} = useContext(ChatSessionProviderContext);
|
||||
const { currentChat } = useContext(ChatProviderContext);
|
||||
|
||||
const handleRefine = async (params?: { reload?: boolean }) => {
|
||||
if (refining) return;
|
||||
@@ -194,7 +224,7 @@ const ChatUserMessageActions = (props: {
|
||||
|
||||
const context = `I'm chatting in a chatroom. The previous messages are as follows:\n\n${buildChatHistory()}`;
|
||||
const result = await refine(chatMessage.content, {
|
||||
learningLanguage: currentChat.language,
|
||||
learningLanguage,
|
||||
context,
|
||||
});
|
||||
EnjoyApp.cacheObjects.set(cacheKey, result);
|
||||
@@ -212,11 +242,13 @@ const ChatUserMessageActions = (props: {
|
||||
(m) => new Date(m.createdAt) < new Date(chatMessage.createdAt)
|
||||
);
|
||||
return messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${(message.member.user || message.member.agent).name}: ${
|
||||
message.content
|
||||
}`
|
||||
.filter((m) =>
|
||||
[ChatMessageRoleEnum.USER, ChatMessageRoleEnum.AGENT].includes(m.role)
|
||||
)
|
||||
.map((message) =>
|
||||
message.role === ChatMessageRoleEnum.USER
|
||||
? `${user.name}: ${message.content}`
|
||||
: `${message.member.agent.name}: ${message.content}`
|
||||
)
|
||||
.join("\n");
|
||||
};
|
||||
@@ -259,31 +291,30 @@ const ChatUserMessageActions = (props: {
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
{chatMessage.state === "pending" && (
|
||||
{chatMessage.state === ChatMessageStateEnum.PENDING && (
|
||||
<>
|
||||
{submitting ? (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<EditIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("edit")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
setContent(chatMessage.content);
|
||||
setEditing(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isPaused || isRecording || submitting ? (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<MicIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("reRecord")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={startRecording}
|
||||
/>
|
||||
)}
|
||||
<EditIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("edit")}
|
||||
className={`w-4 h-4 ${
|
||||
submitting ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (submitting) return;
|
||||
setContent(chatMessage.content);
|
||||
setEditing(true);
|
||||
}}
|
||||
/>
|
||||
<MicIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("reRecord")}
|
||||
className={`w-4 h-4 ${
|
||||
submitting || isPaused || isRecording
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
onClick={startRecording}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{chatMessage.recording &&
|
||||
@@ -307,44 +338,47 @@ const ChatUserMessageActions = (props: {
|
||||
onClick={() => handleRefine()}
|
||||
/>
|
||||
)}
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(chatMessage.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConversationShortcuts
|
||||
prompt={chatMessage.content}
|
||||
excludedIds={[]}
|
||||
trigger={
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
{chatMessage.state === ChatMessageStateEnum.COMPLETED && (
|
||||
<>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(chatMessage.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CopilotForwarder
|
||||
prompt={chatMessage.content}
|
||||
trigger={
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Boolean(chatMessage.recording) && (
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="chat-message-download-recording"
|
||||
onClick={handleDownload}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
{Boolean(chatMessage.recording) && (
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="chat-message-download-recording"
|
||||
onClick={handleDownload}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuTrigger>
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
@@ -377,9 +411,9 @@ const ChatUserMessageActions = (props: {
|
||||
className="w-6 h-6"
|
||||
>
|
||||
{refinementVisible ? (
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
ScrollArea,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatProviderContext, ChatSessionProvider } from "@renderer/context";
|
||||
import { useContext, useState } from "react";
|
||||
import { ChatForm, ChatInput, ChatMessages } from "@renderer/components";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
export const Chat = () => {
|
||||
const { currentChat, chatAgents, updateChat, destroyChat } =
|
||||
useContext(ChatProviderContext);
|
||||
const [displayChatForm, setDisplayChatForm] = useState(false);
|
||||
|
||||
if (!currentChat) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<span className="text-muted-foreground">{t("noChatSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-screen relative pb-16">
|
||||
<div className="h-12 border-b px-4 shadow flex items-center justify-center sticky top-0 z-10 bg-background mb-4">
|
||||
<span>
|
||||
{currentChat.name}({currentChat.membersCount})
|
||||
</span>
|
||||
<Dialog open={displayChatForm} onOpenChange={setDisplayChatForm}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="absolute right-4">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-screen-md h-5/6">
|
||||
<DialogTitle className="sr-only"></DialogTitle>
|
||||
<ScrollArea className="h-full px-4">
|
||||
<ChatForm
|
||||
chat={currentChat}
|
||||
chatAgents={chatAgents}
|
||||
onSave={(data) =>
|
||||
updateChat(currentChat.id, data).then(() =>
|
||||
setDisplayChatForm(false)
|
||||
)
|
||||
}
|
||||
onDestroy={() =>
|
||||
destroyChat(currentChat.id).then(() =>
|
||||
setDisplayChatForm(false)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<ChatSessionProvider chat={currentChat}>
|
||||
<ChatMessages />
|
||||
<div className="absolute bottom-0 w-full min-h-16 py-3 z-10 bg-background flex items-center border-t shadow-lg">
|
||||
<ChatInput />
|
||||
<Tooltip id="chat-input-tooltip" />
|
||||
</div>
|
||||
</ChatSessionProvider>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./chat";
|
||||
export * from "./chat-session";
|
||||
export * from "./chat-agents";
|
||||
export * from "./chat-agent-form";
|
||||
export * from "./chat-card";
|
||||
@@ -8,4 +8,11 @@ export * from "./chat-message";
|
||||
export * from "./chat-agent-message";
|
||||
export * from "./chat-user-message";
|
||||
export * from "./chat-messages";
|
||||
export * from "./chat-sidebar";
|
||||
export * from "./chat-list";
|
||||
export * from "./chat-agent-card";
|
||||
export * from "./chat-settings";
|
||||
export * from "./chat-member-form";
|
||||
export * from "./chat-header";
|
||||
export * from "./chat-tts-form";
|
||||
export * from "./chat-gpt-form";
|
||||
export * from "./chat-suggestion-button";
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { MessageCircleIcon, SpeechIcon } from "lucide-react";
|
||||
import { EllipsisIcon, MessageCircleIcon, SpeechIcon } from "lucide-react";
|
||||
import dayjs from "@renderer/lib/dayjs";
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const ConversationCard = (props: { conversation: ConversationType }) => {
|
||||
const { conversation } = props;
|
||||
const { learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp, learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const handleDelete = () => {
|
||||
EnjoyApp.conversations.destroy(conversation.id).then(() => {
|
||||
toast.success(t("conversationDeleted"));
|
||||
});
|
||||
};
|
||||
|
||||
const handleMigrate = () => {
|
||||
EnjoyApp.conversations
|
||||
.migrate(conversation.id)
|
||||
.then(() => {
|
||||
toast.success(t("conversationMigrated"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,9 +57,36 @@ export const ConversationCard = (props: { conversation: ConversationType }) => {
|
||||
| {conversation.language || learningLanguage}
|
||||
</div>
|
||||
</div>
|
||||
<span className="min-w-fit text-sm text-muted-foreground">
|
||||
{dayjs(conversation.createdAt).format("HH:mm l")}
|
||||
</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="min-w-fit text-sm text-muted-foreground">
|
||||
{dayjs(conversation.createdAt).format("HH:mm l")}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleMigrate();
|
||||
}}
|
||||
>
|
||||
<span>{t("migrateToChat")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
>
|
||||
<span className="text-destructive">{t("delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { ChatAgentCard } from "@renderer/components";
|
||||
import { Input } from "@renderer/components/ui";
|
||||
|
||||
export const CopilotChatAgents = (props: {
|
||||
onSelect: (agent: ChatAgentType) => void;
|
||||
}) => {
|
||||
const { onSelect } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [chatAgents, setChatAgents] = useState<ChatAgentType[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const fetchChatAgents = async () => {
|
||||
EnjoyApp.chatAgents
|
||||
.findAll({
|
||||
query: debouncedQuery,
|
||||
})
|
||||
.then((agents) => {
|
||||
setChatAgents(agents);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatAgents();
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="sticky py-2 px-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded h-8 text-xs"
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
</div>
|
||||
{chatAgents.length === 0 && (
|
||||
<div className="text-center my-4">
|
||||
<span className="text-sm text-muted-foreground">{t("noData")}</span>
|
||||
</div>
|
||||
)}
|
||||
{chatAgents.map((agent) => (
|
||||
<ChatAgentCard
|
||||
key={agent.id}
|
||||
chatAgent={agent}
|
||||
selected={false}
|
||||
onSelect={() => onSelect(agent)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
enjoy/src/renderer/components/copilots/copilot-chats.tsx
Normal file
112
enjoy/src/renderer/components/copilots/copilot-chats.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
Input,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatCard } from "@renderer/components";
|
||||
import { isSameTimeRange } from "@renderer/lib/utils";
|
||||
import { useChat } from "@renderer/hooks";
|
||||
|
||||
export const CopilotChats = (props: { onSelect: (chat: ChatType) => void }) => {
|
||||
const { onSelect } = props;
|
||||
const { chats, fetchChats } = useChat();
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { currentChat, occupiedChat, setCurrentChat } = useContext(
|
||||
CopilotProviderContext
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
const [deletingChat, setDeletingChat] = useState<ChatType>(null);
|
||||
|
||||
const handleDeleteChat = async () => {
|
||||
if (!deletingChat) return;
|
||||
|
||||
EnjoyApp.chats
|
||||
.destroy(deletingChat.id)
|
||||
.then(() => {
|
||||
toast.success(t("models.chat.deleted"));
|
||||
if (deletingChat.id === currentChat?.id) {
|
||||
setCurrentChat(null);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingChat(null);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="py-2 px-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="rounded h-8 text-xs"
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
</div>
|
||||
{chats.length === 0 && (
|
||||
<div className="text-center my-4">
|
||||
<span className="text-sm text-muted-foreground">{t("noData")}</span>
|
||||
</div>
|
||||
)}
|
||||
{chats.map((chat, index) => (
|
||||
<ChatCard
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
displayDate={
|
||||
index === 0 ||
|
||||
!isSameTimeRange(chat.updatedAt, chats[index - 1].updatedAt)
|
||||
}
|
||||
selected={currentChat?.id === chat.id}
|
||||
disabled={occupiedChat?.id === chat.id}
|
||||
onSelect={onSelect}
|
||||
onDelete={setDeletingChat}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={!!deletingChat}
|
||||
onOpenChange={() => setDeletingChat(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>{t("deleteChat")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteChatConfirmation")}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeletingChat(null)}>
|
||||
{t("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive hover:bg-destructive-hover"
|
||||
onClick={handleDeleteChat}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
111
enjoy/src/renderer/components/copilots/copilot-forwarder.tsx
Normal file
111
enjoy/src/renderer/components/copilots/copilot-forwarder.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
toast,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { ForwardIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { CopilotChatAgents, CopilotChats } from "@renderer/components";
|
||||
import { ChatMessageRoleEnum, ChatMessageStateEnum } from "@/types/enums";
|
||||
|
||||
export const CopilotForwarder = (props: {
|
||||
prompt: string;
|
||||
trigger?: React.ReactNode;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { prompt, trigger } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>
|
||||
{trigger || (
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
{open && (
|
||||
<CopilotForwarderContent
|
||||
prompt={prompt}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CopilotForwarderContent = (props: {
|
||||
prompt: string;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { onClose, prompt } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { sttEngine } = useContext(AISettingsProviderContext);
|
||||
const { buildAgentMember } = useContext(CopilotProviderContext);
|
||||
const { setActive, setCurrentChat } = useContext(CopilotProviderContext);
|
||||
|
||||
const handleSelectChatAgent = async (agent: ChatAgentType) => {
|
||||
EnjoyApp.chats
|
||||
.create({
|
||||
name: t("newChat"),
|
||||
config: {
|
||||
sttEngine,
|
||||
},
|
||||
members: [buildAgentMember(agent)],
|
||||
})
|
||||
.then(handleSelectChat)
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectChat = async (chat: ChatType) => {
|
||||
console.log("handleSelectChat", chat);
|
||||
EnjoyApp.chatMessages
|
||||
.create({
|
||||
chatId: chat.id,
|
||||
content: prompt,
|
||||
role: ChatMessageRoleEnum.USER,
|
||||
state: ChatMessageStateEnum.PENDING,
|
||||
})
|
||||
.then(() => {
|
||||
setCurrentChat(chat);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
onClose();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="chats">
|
||||
<TabsList className="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="chats">{t("recents")}</TabsTrigger>
|
||||
<TabsTrigger value="agents">{t("agents")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="chats">
|
||||
<CopilotChats onSelect={handleSelectChat} />
|
||||
</TabsContent>
|
||||
<TabsContent value="agents">
|
||||
<CopilotChatAgents onSelect={handleSelectChatAgent} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
144
enjoy/src/renderer/components/copilots/copilot-header.tsx
Normal file
144
enjoy/src/renderer/components/copilots/copilot-header.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
ScrollArea,
|
||||
toast,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronsRightIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
SpeechIcon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
import {
|
||||
ChatSettings,
|
||||
CopilotChatAgents,
|
||||
CopilotChats,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { ChatBubbleIcon } from "@radix-ui/react-icons";
|
||||
import { ChatTypeEnum } from "@/types/enums";
|
||||
|
||||
export const CopilotHeader = () => {
|
||||
const [displayChatForm, setDisplayChatForm] = useState(false);
|
||||
const [displayChats, setDisplayChats] = useState(false);
|
||||
const [displayChatAgents, setDisplayChatAgents] = useState(false);
|
||||
const {
|
||||
currentChat,
|
||||
active,
|
||||
setActive,
|
||||
occupiedChat,
|
||||
setCurrentChat,
|
||||
buildAgentMember,
|
||||
} = useContext(CopilotProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<div className="h-10 border-b px-3 shadow flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background mb-4">
|
||||
<div className="flex items-center space-x-1 line-clamp-1">
|
||||
<Popover open={displayChats} onOpenChange={setDisplayChats}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="overflow-y-auto max-h-96">
|
||||
<CopilotChats
|
||||
onSelect={(chat) => {
|
||||
if (occupiedChat?.id !== chat.id) {
|
||||
setCurrentChat(chat);
|
||||
}
|
||||
setDisplayChats(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{currentChat?.type === ChatTypeEnum.CONVERSATION && (
|
||||
<ChatBubbleIcon className="w-4 h-4" />
|
||||
)}
|
||||
{currentChat?.type === ChatTypeEnum.GROUP && (
|
||||
<UsersRoundIcon className="w-4 h-4" />
|
||||
)}
|
||||
{currentChat?.type === ChatTypeEnum.TTS && (
|
||||
<SpeechIcon className="w-4 h-4" />
|
||||
)}
|
||||
<span className="text-sm line-clamp-1">{currentChat?.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Popover open={displayChatAgents} onOpenChange={setDisplayChatAgents}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="overflow-y-auto max-h-96">
|
||||
<CopilotChatAgents
|
||||
onSelect={(agent) => {
|
||||
EnjoyApp.chats
|
||||
.create({
|
||||
name: t("newChat"),
|
||||
config: {
|
||||
sttEngine: currentChat?.config.sttEngine,
|
||||
},
|
||||
members: [buildAgentMember(agent)],
|
||||
})
|
||||
.then((newChat) => {
|
||||
setCurrentChat(newChat);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setDisplayChatAgents(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{currentChat && (
|
||||
<Dialog open={displayChatForm} onOpenChange={setDisplayChatForm}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-screen-sm max-h-[70%] overflow-y-auto">
|
||||
<DialogTitle>{t("editChat")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Edit chat settings
|
||||
</DialogDescription>
|
||||
<ScrollArea className="h-full px-4">
|
||||
<ChatSettings onFinish={() => setDisplayChatForm(false)} />
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
onClick={() => setActive(!active)}
|
||||
>
|
||||
<ChevronsRightIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
34
enjoy/src/renderer/components/copilots/copilot-session.tsx
Normal file
34
enjoy/src/renderer/components/copilots/copilot-session.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ChatSessionProvider, CopilotProviderContext } from "@renderer/context";
|
||||
import { useContext } from "react";
|
||||
import { Button, ScrollArea } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatMessages, ChatInput, CopilotHeader } from "@renderer/components";
|
||||
|
||||
export const CopilotSession = () => {
|
||||
const { currentChat } = useContext(CopilotProviderContext);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-screen relative">
|
||||
{currentChat?.id ? (
|
||||
<ChatSessionProvider chatId={currentChat.id}>
|
||||
<CopilotHeader />
|
||||
<div className="w-full max-w-screen-md mx-auto">
|
||||
<ChatMessages />
|
||||
<div className="h-16" />
|
||||
<div className="absolute w-full max-w-screen-md bottom-0 min-h-16 pb-3 flex items-center">
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
</ChatSessionProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center py-6">
|
||||
<div className="text-muted-foreground">
|
||||
<Button variant="default" size="sm">
|
||||
{t("newChat")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
5
enjoy/src/renderer/components/copilots/index.ts
Normal file
5
enjoy/src/renderer/components/copilots/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./copilot-session";
|
||||
export * from "./copilot-forwarder";
|
||||
export * from "./copilot-header";
|
||||
export * from "./copilot-chats";
|
||||
export * from "./copilot-chat-agents";
|
||||
@@ -9,9 +9,14 @@ export const EnrollmentSegment = () => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [enrollments, setEnrollments] = useState<EnrollmentType[]>([]);
|
||||
const fetchEnrollments = async () => {
|
||||
webApi.enrollments().then(({ enrollments }) => {
|
||||
setEnrollments(enrollments);
|
||||
});
|
||||
webApi
|
||||
.enrollments()
|
||||
.then(({ enrollments }) => {
|
||||
setEnrollments(enrollments);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./audios";
|
||||
export * from "./chats";
|
||||
export * from "./conversations";
|
||||
export * from "./copilots";
|
||||
export * from "./courses";
|
||||
export * from "./llm-chats";
|
||||
export * from "./meanings";
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SpeechIcon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useAiCommand, useConversation } from "@renderer/hooks";
|
||||
import { useAiCommand, useSpeech } from "@renderer/hooks";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CourseProviderContext,
|
||||
@@ -39,7 +39,7 @@ export const LlmMessage = (props: { llmMessage: LlmMessageType }) => {
|
||||
const [speech, setSpeech] = useState<Partial<SpeechType>>();
|
||||
const [speeching, setSpeeching] = useState<boolean>(false);
|
||||
const [resourcing, setResourcing] = useState<boolean>(false);
|
||||
const { tts } = useConversation();
|
||||
const { tts } = useSpeech();
|
||||
const { summarizeTopic, translate } = useAiCommand();
|
||||
const [translation, setTranslation] = useState<string>();
|
||||
const [translating, setTranslating] = useState<boolean>(false);
|
||||
|
||||
@@ -566,7 +566,7 @@ export const MediaCurrentRecording = () => {
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
@@ -582,14 +582,14 @@ export const MediaCurrentRecording = () => {
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
@@ -598,7 +598,7 @@ export const MediaCurrentRecording = () => {
|
||||
</Button>
|
||||
<Button
|
||||
id="media-record-button"
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
|
||||
@@ -339,14 +339,14 @@ const RecorderButton = () => {
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
@@ -354,7 +354,7 @@ const RecorderButton = () => {
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { t } from "i18next";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useConversation, useAiCommand } from "@renderer/hooks";
|
||||
import { useSpeech, useAiCommand } from "@renderer/hooks";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
|
||||
export const AssistantMessageComponent = (props: {
|
||||
@@ -53,7 +53,7 @@ export const AssistantMessageComponent = (props: {
|
||||
const [resourcing, setResourcing] = useState<boolean>(false);
|
||||
const [shadowing, setShadowing] = useState<boolean>(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { tts } = useConversation();
|
||||
const { tts } = useSpeech();
|
||||
const { summarizeTopic } = useAiCommand();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,14 +8,6 @@ import { t } from "i18next";
|
||||
export const DbState = () => {
|
||||
const db = useContext(DbProviderContext);
|
||||
|
||||
if (db.state === "connecting") {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<LoaderIcon className="animate-spin w-6 h-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (db.state === "connected") {
|
||||
return (
|
||||
<div className="">
|
||||
@@ -42,4 +34,10 @@ export const DbState = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<LoaderIcon className="animate-spin w-6 h-6" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,23 @@ import { Sidebar } from "./sidebar";
|
||||
import { Outlet, Link } from "react-router-dom";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useContext } from "react";
|
||||
import { Button } from "@renderer/components/ui/button";
|
||||
import { DbState } from "@renderer/components";
|
||||
import { DbState, CopilotSession } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@renderer/components/ui";
|
||||
|
||||
export const Layout = () => {
|
||||
const { initialized } = useContext(AppSettingsProviderContext);
|
||||
const db = useContext(DbProviderContext);
|
||||
const { active, setActive } = useContext(CopilotProviderContext);
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
@@ -34,14 +41,38 @@ export const Layout = () => {
|
||||
);
|
||||
} else if (db.state === "connected") {
|
||||
return (
|
||||
<div className="min-h-screen" data-testid="layout-home">
|
||||
<div className="flex flex-start">
|
||||
<Sidebar />
|
||||
<div className="flex-1 border-l overflow-x-hidden">
|
||||
<Outlet />
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-screen w-full"
|
||||
data-testid="layout-home"
|
||||
>
|
||||
<ResizablePanel id="main-panel" order={1} minSize={50}>
|
||||
<div className="flex flex-start">
|
||||
<Sidebar />
|
||||
<div className="flex-1 border-l overflow-x-hidden overflow-y-auto h-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{active && (
|
||||
<>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel
|
||||
id="copilot-panel"
|
||||
order={2}
|
||||
collapsible={true}
|
||||
defaultSize={30}
|
||||
maxSize={50}
|
||||
minSize={15}
|
||||
onCollapse={() => setActive(false)}
|
||||
>
|
||||
<div className="h-screen">
|
||||
<CopilotSession />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
|
||||
@@ -33,13 +33,16 @@ import {
|
||||
SpeechIcon,
|
||||
GraduationCapIcon,
|
||||
MessagesSquareIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PanelLeftCloseIcon,
|
||||
} from "lucide-react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { Preferences } from "@renderer/components";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { NoticiationsChannel } from "@renderer/cables";
|
||||
import { useState } from "react";
|
||||
@@ -48,6 +51,7 @@ export const Sidebar = () => {
|
||||
const location = useLocation();
|
||||
const activeTab = location.pathname;
|
||||
const { EnjoyApp, cable } = useContext(AppSettingsProviderContext);
|
||||
const { active, setActive } = useContext(CopilotProviderContext);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +93,11 @@ export const Sidebar = () => {
|
||||
>
|
||||
<ScrollArea className="w-full h-full pb-12">
|
||||
<div className="py-4 mb-4 flex items-center space-x-1 justify-center">
|
||||
<img src="./assets/logo-light.svg" className="w-8 h-8" />
|
||||
<img
|
||||
src="./assets/logo-light.svg"
|
||||
className="w-8 h-8 cursor-pointer hover:animate-spin"
|
||||
onClick={() => setActive(!active)}
|
||||
/>
|
||||
<span
|
||||
className={`text-xl font-semibold text-[#4797F5] ${
|
||||
isOpen ? "" : "hidden"
|
||||
@@ -327,9 +335,9 @@ export const Sidebar = () => {
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<PanelRightOpenIcon className="h-5 w-5" />
|
||||
<PanelLeftCloseIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelRightCloseIcon className="h-5 w-5" />
|
||||
<PanelLeftOpenIcon className="h-5 w-5" />
|
||||
)}
|
||||
{isOpen && <span className="ml-2"> {t("sidebar.collapse")} </span>}
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useContext, useEffect, useState } from "react";
|
||||
import { GPT_PROVIDERS } from "@renderer/components";
|
||||
|
||||
export const DefaultEngineSettings = () => {
|
||||
const { currentEngine, setGptEngine, openai } = useContext(
|
||||
const { currentGptEngine, setGptEngine, openai } = useContext(
|
||||
AISettingsProviderContext
|
||||
);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
@@ -47,8 +47,8 @@ export const DefaultEngineSettings = () => {
|
||||
const form = useForm<z.infer<typeof gptEngineSchema>>({
|
||||
resolver: zodResolver(gptEngineSchema),
|
||||
values: {
|
||||
name: currentEngine.name as "enjoyai" | "openai",
|
||||
models: currentEngine.models || {},
|
||||
name: currentGptEngine.name as "enjoyai" | "openai",
|
||||
models: currentGptEngine.models || {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ export const Hotkeys = () => {
|
||||
} | null>(null);
|
||||
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
|
||||
|
||||
const commandOrCtrl = navigator.platform.includes("Mac") ? "Cmd" : "Ctrl";
|
||||
const isMac = /Mac/.test(navigator.userAgent);
|
||||
const commandOrCtrl = isMac ? "Cmd" : "Ctrl";
|
||||
|
||||
const handleItemSelected = (item: { name: string; keyName: Hotkey }) => {
|
||||
setOpen(true);
|
||||
@@ -50,6 +51,24 @@ export const Hotkeys = () => {
|
||||
{currentHotkeys.OpenPreferences}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center space-x-2">{t("openCopilot")}</div>
|
||||
<kbd
|
||||
onClick={() =>
|
||||
handleItemSelected({
|
||||
name: t("openCopilot"),
|
||||
keyName: "OpenCopilot",
|
||||
})
|
||||
}
|
||||
className="bg-muted px-2 py-1 rounded-md text-sm text-muted-foreground cursor-pointer capitalize"
|
||||
>
|
||||
{currentHotkeys.OpenCopilot}
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -326,14 +326,14 @@ const RecorderButton = (props: {
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
@@ -341,7 +341,7 @@ const RecorderButton = (props: {
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
@@ -357,7 +357,7 @@ const RecorderButton = (props: {
|
||||
return (
|
||||
<div className="w-full flex items-center gap-4 justify-center">
|
||||
<Button
|
||||
data-tooltip-id="chat-input-tooltip"
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("record")}
|
||||
disabled={submitting}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -10,11 +10,16 @@ export const StoriesSegment = () => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchStorys = async () => {
|
||||
webApi.mineStories().then((response) => {
|
||||
if (response?.stories) {
|
||||
setStorys(response.stories);
|
||||
}
|
||||
});
|
||||
webApi
|
||||
.mineStories()
|
||||
.then((response) => {
|
||||
if (response?.stories) {
|
||||
setStorys(response.stories);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { SttEngineOptionEnum, UserSettingKeyEnum } from "@/types/enums";
|
||||
import { GPT_PROVIDERS, TTS_PROVIDERS } from "@renderer/components";
|
||||
|
||||
type AISettingsProviderState = {
|
||||
setWhisperModel?: (name: string) => Promise<void>;
|
||||
@@ -14,7 +15,10 @@ type AISettingsProviderState = {
|
||||
openai?: LlmProviderType;
|
||||
setOpenai?: (config: LlmProviderType) => void;
|
||||
setGptEngine?: (engine: GptEngineSettingType) => void;
|
||||
currentEngine?: GptEngineSettingType;
|
||||
currentGptEngine?: GptEngineSettingType;
|
||||
currentTtsEngine?: TtsEngineSettingType;
|
||||
gptProviders?: typeof GPT_PROVIDERS;
|
||||
ttsProviders?: typeof TTS_PROVIDERS;
|
||||
};
|
||||
|
||||
const initialState: AISettingsProviderState = {};
|
||||
@@ -38,11 +42,56 @@ export const AISettingsProvider = ({
|
||||
const [sttEngine, setSttEngine] = useState<SttEngineOptionEnum>(
|
||||
SttEngineOptionEnum.ENJOY_AZURE
|
||||
);
|
||||
const { EnjoyApp, libraryPath, user, apiUrl } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { EnjoyApp, libraryPath, user, apiUrl, webApi, learningLanguage } =
|
||||
useContext(AppSettingsProviderContext);
|
||||
const [gptProviders, setGptProviders] = useState<any>(GPT_PROVIDERS);
|
||||
const [ttsProviders, setTtsProviders] = useState<any>(TTS_PROVIDERS);
|
||||
const db = useContext(DbProviderContext);
|
||||
|
||||
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();
|
||||
}, [openai, gptEngine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (db.state !== "connected") return;
|
||||
|
||||
@@ -133,7 +182,7 @@ export const AISettingsProvider = ({
|
||||
setGptEngine(engine);
|
||||
});
|
||||
},
|
||||
currentEngine:
|
||||
currentGptEngine:
|
||||
gptEngine.name === "openai"
|
||||
? Object.assign(gptEngine, {
|
||||
key: openai.key,
|
||||
@@ -143,6 +192,20 @@ export const AISettingsProvider = ({
|
||||
key: user?.accessToken,
|
||||
baseUrl: `${apiUrl}/api/ai`,
|
||||
}),
|
||||
currentTtsEngine:
|
||||
gptEngine.name === "openai"
|
||||
? {
|
||||
name: "openai",
|
||||
model: "tts-1",
|
||||
voice: "alloy",
|
||||
language: learningLanguage,
|
||||
}
|
||||
: {
|
||||
name: "enjoyai",
|
||||
model: "openai/tts-1",
|
||||
voice: "alloy",
|
||||
language: learningLanguage,
|
||||
},
|
||||
openai,
|
||||
setOpenai: (config: LlmProviderType) => handleSetOpenai(config),
|
||||
whisperConfig,
|
||||
@@ -150,6 +213,8 @@ export const AISettingsProvider = ({
|
||||
setWhisperModel,
|
||||
sttEngine,
|
||||
setSttEngine: (name: SttEngineOptionEnum) => handleSetSttEngine(name),
|
||||
gptProviders,
|
||||
ttsProviders,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -46,6 +46,8 @@ const initialState: AppSettingsProviderState = {
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
const EnjoyApp = window.__ENJOY_APP__;
|
||||
|
||||
export const AppSettingsProviderContext =
|
||||
createContext<AppSettingsProviderState>(initialState);
|
||||
|
||||
@@ -67,7 +69,6 @@ export const AppSettingsProvider = ({
|
||||
const [vocabularyConfig, setVocabularyConfig] =
|
||||
useState<VocabularyConfigType>(null);
|
||||
const [proxy, setProxy] = useState<ProxyConfigType>();
|
||||
const EnjoyApp = window.__ENJOY_APP__;
|
||||
const [recorderConfig, setRecorderConfig] = useState<RecorderConfigType>();
|
||||
const [ipaMappings, setIpaMappings] = useState<{ [key: string]: string }>(
|
||||
IPA_MAPPINGS
|
||||
@@ -236,12 +237,12 @@ export const AppSettingsProvider = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (db.state !== "connected") return;
|
||||
|
||||
fetchLanguages();
|
||||
fetchVocabularyConfig();
|
||||
initSentry();
|
||||
fetchRecorderConfig();
|
||||
if (db.state === "connected") {
|
||||
fetchLanguages();
|
||||
fetchVocabularyConfig();
|
||||
initSentry();
|
||||
fetchRecorderConfig();
|
||||
}
|
||||
}, [db.state]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -267,7 +268,7 @@ export const AppSettingsProvider = ({
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [user, apiUrl, language]);
|
||||
}, [user?.accessToken, apiUrl, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
@@ -279,6 +280,7 @@ export const AppSettingsProvider = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApi) return;
|
||||
if (ipaMappings && latestVersion) return;
|
||||
|
||||
webApi.config("ipa_mappings").then((mappings) => {
|
||||
if (mappings) setIpaMappings(mappings);
|
||||
@@ -308,6 +310,7 @@ export const AppSettingsProvider = ({
|
||||
});
|
||||
return () => {
|
||||
db.disconnect();
|
||||
setUser(null);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
@@ -335,7 +338,7 @@ export const AppSettingsProvider = ({
|
||||
setProxy: setProxyConfigHandler,
|
||||
vocabularyConfig,
|
||||
setVocabularyConfig: setVocabularyConfigHandler,
|
||||
initialized: Boolean(user && libraryPath),
|
||||
initialized: Boolean(db.state === "connected" && libraryPath),
|
||||
ahoy,
|
||||
cable,
|
||||
recorderConfig,
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { useChat, useChatAgent } from "@renderer/hooks";
|
||||
|
||||
type ChatProviderState = {
|
||||
chats: ChatType[];
|
||||
currentChat: ChatType;
|
||||
setCurrentChat: (chat: ChatType) => void;
|
||||
fetchChats: (query?: string) => Promise<void>;
|
||||
createChat: (data: {
|
||||
name: string;
|
||||
language: string;
|
||||
topic: string;
|
||||
members: Array<{
|
||||
userId?: string;
|
||||
userType?: "User" | "Agent";
|
||||
config?: {
|
||||
prompt?: string;
|
||||
introduction?: string;
|
||||
};
|
||||
}>;
|
||||
config: {
|
||||
sttEngine: string;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
updateChat: (
|
||||
id: string,
|
||||
data: {
|
||||
name: string;
|
||||
language: string;
|
||||
topic: string;
|
||||
members: Array<{
|
||||
userId?: string;
|
||||
userType?: "User" | "Agent";
|
||||
config?: {
|
||||
prompt?: string;
|
||||
introduction?: string;
|
||||
};
|
||||
}>;
|
||||
config: {
|
||||
sttEngine: string;
|
||||
};
|
||||
}
|
||||
) => Promise<void>;
|
||||
destroyChat: (id: string) => Promise<void>;
|
||||
chatAgents: ChatAgentType[];
|
||||
fetchChatAgents: (query?: string) => Promise<void>;
|
||||
updateChatAgent: (
|
||||
id: string,
|
||||
data: Partial<ChatAgentType>
|
||||
) => Promise<void | ChatAgentType>;
|
||||
createChatAgent: (
|
||||
data: Partial<ChatAgentType>
|
||||
) => Promise<void | ChatAgentType>;
|
||||
destroyChatAgent: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const initialState: ChatProviderState = {
|
||||
chats: [],
|
||||
currentChat: null,
|
||||
setCurrentChat: () => null,
|
||||
fetchChats: () => null,
|
||||
createChat: () => null,
|
||||
updateChat: () => null,
|
||||
destroyChat: () => null,
|
||||
chatAgents: [],
|
||||
fetchChatAgents: () => null,
|
||||
updateChatAgent: () => null,
|
||||
createChatAgent: () => null,
|
||||
destroyChatAgent: () => null,
|
||||
};
|
||||
|
||||
export const ChatProviderContext =
|
||||
createContext<ChatProviderState>(initialState);
|
||||
|
||||
export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [currentChat, setCurrentChat] = useState<ChatType>(null);
|
||||
const { chats, fetchChats, createChat, updateChat, destroyChat } = useChat();
|
||||
const {
|
||||
chatAgents,
|
||||
fetchChatAgents,
|
||||
updateChatAgent,
|
||||
createChatAgent,
|
||||
destroyChatAgent,
|
||||
} = useChatAgent();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentChat(chats[0]);
|
||||
}, [chats]);
|
||||
|
||||
return (
|
||||
<ChatProviderContext.Provider
|
||||
value={{
|
||||
chats,
|
||||
fetchChats,
|
||||
currentChat,
|
||||
setCurrentChat,
|
||||
chatAgents,
|
||||
createChat,
|
||||
updateChat,
|
||||
destroyChat,
|
||||
fetchChatAgents,
|
||||
updateChatAgent,
|
||||
createChatAgent,
|
||||
destroyChatAgent,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useChatMessage, useTranscribe } from "@renderer/hooks";
|
||||
import { useAiCommand, useChatSession, useTranscribe } from "@renderer/hooks";
|
||||
import { useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProvider,
|
||||
} from "@renderer/context";
|
||||
@@ -24,20 +23,19 @@ import {
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { AudioPlayer, RecordingDetail } from "@renderer/components";
|
||||
import { CHAT_SYSTEM_PROMPT_TEMPLATE } from "@/constants";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { ChatMessageRoleEnum, ChatMessageStateEnum } from "@/types/enums";
|
||||
|
||||
type ChatSessionProviderState = {
|
||||
chat: ChatType;
|
||||
chatMessages: ChatMessageType[];
|
||||
chatMembers: ChatMemberType[];
|
||||
chatAgents: ChatAgentType[];
|
||||
dispatchChatMessages: React.Dispatch<any>;
|
||||
submitting: boolean;
|
||||
asking: ChatMemberType;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
@@ -47,7 +45,10 @@ type ChatSessionProviderState = {
|
||||
recordingTime: number;
|
||||
mediaRecorder: MediaRecorder;
|
||||
recordingBlob: Blob;
|
||||
askAgent: () => Promise<any>;
|
||||
askAgent: (options?: {
|
||||
member?: ChatMemberType;
|
||||
force?: boolean;
|
||||
}) => Promise<any>;
|
||||
shadowing: AudioType;
|
||||
setShadowing: (audio: AudioType) => void;
|
||||
assessing: RecordingType;
|
||||
@@ -55,7 +56,10 @@ type ChatSessionProviderState = {
|
||||
onDeleteMessage?: (id: string) => void;
|
||||
onCreateMessage?: (
|
||||
content: string,
|
||||
recordingUrl?: string
|
||||
options: {
|
||||
onSuccess?: (message: ChatMessageType) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
) => Promise<ChatMessageType | void>;
|
||||
onUpdateMessage?: (
|
||||
id: string,
|
||||
@@ -64,9 +68,13 @@ type ChatSessionProviderState = {
|
||||
};
|
||||
|
||||
const initialState: ChatSessionProviderState = {
|
||||
chat: null,
|
||||
chatMessages: [],
|
||||
chatMembers: [],
|
||||
chatAgents: [],
|
||||
dispatchChatMessages: () => null,
|
||||
submitting: false,
|
||||
asking: null,
|
||||
startRecording: () => null,
|
||||
stopRecording: () => null,
|
||||
cancelRecording: () => null,
|
||||
@@ -90,25 +98,30 @@ export const ChatSessionProviderContext =
|
||||
|
||||
export const ChatSessionProvider = ({
|
||||
children,
|
||||
chat,
|
||||
chatId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
chat: ChatType;
|
||||
chatId: string;
|
||||
}) => {
|
||||
const { EnjoyApp, user, apiUrl, recorderConfig } = useContext(
|
||||
const { EnjoyApp, recorderConfig, learningLanguage } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { openai } = useContext(AISettingsProviderContext);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [shadowing, setShadowing] = useState<AudioType>(null);
|
||||
const [asking, setAsking] = useState<ChatMemberType>(null);
|
||||
const [assessing, setAssessing] = useState<RecordingType>(null);
|
||||
const {
|
||||
chat,
|
||||
chatAgents,
|
||||
chatMembers,
|
||||
chatMessages,
|
||||
dispatchChatMessages,
|
||||
onCreateUserMessage,
|
||||
onUpdateMessage,
|
||||
onDeleteMessage,
|
||||
} = useChatMessage(chat);
|
||||
invokeAgent,
|
||||
} = useChatSession(chatId);
|
||||
|
||||
const [deletingMessage, setDeletingMessage] = useState<string>(null);
|
||||
const [cancelingRecording, setCancelingRecording] = useState(false);
|
||||
|
||||
@@ -126,6 +139,7 @@ export const ChatSessionProvider = ({
|
||||
});
|
||||
|
||||
const { transcribe } = useTranscribe();
|
||||
const { summarizeTopic } = useAiCommand();
|
||||
|
||||
const cancelRecording = () => {
|
||||
setCancelingRecording(true);
|
||||
@@ -139,13 +153,30 @@ export const ChatSessionProvider = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateMessage = async (content: string, recordingUrl?: string) => {
|
||||
const onCreateMessage = async (
|
||||
content: string,
|
||||
options: {
|
||||
onSuccess?: (message: ChatMessageType) => void;
|
||||
onError?: (error: Error) => void;
|
||||
} = {}
|
||||
) => {
|
||||
const { onSuccess, onError } = options;
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
return onCreateUserMessage(content, recordingUrl).finally(() =>
|
||||
setSubmitting(false)
|
||||
);
|
||||
onCreateUserMessage(content)
|
||||
.then((message) => {
|
||||
if (message) {
|
||||
onSuccess?.(message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
onError?.(error);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onRecorded = async (blob: Blob) => {
|
||||
@@ -155,135 +186,103 @@ export const ChatSessionProvider = ({
|
||||
}
|
||||
if (submitting) return;
|
||||
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const { transcript, url } = await transcribe(blob, {
|
||||
language: chat.language,
|
||||
language: learningLanguage,
|
||||
service: chat.config.sttEngine,
|
||||
align: false,
|
||||
});
|
||||
return onCreateMessage(transcript, url).finally(() =>
|
||||
setSubmitting(false)
|
||||
);
|
||||
|
||||
if (pendingMessage) {
|
||||
await onUpdateMessage(pendingMessage.id, {
|
||||
content: transcript,
|
||||
recordingUrl: url,
|
||||
});
|
||||
} else {
|
||||
await onCreateUserMessage(transcript, url);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const askAgent = async (member?: ChatMemberType) => {
|
||||
// check if there is a pending message
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) => m.member.user && m.state === "pending"
|
||||
);
|
||||
if (pendingMessage) {
|
||||
onUpdateMessage(pendingMessage.id, { state: "completed" });
|
||||
const askAgent = async (options?: {
|
||||
member?: ChatMemberType;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
if (asking) return;
|
||||
|
||||
let { member, force = false } = options || {};
|
||||
|
||||
if (!member) {
|
||||
member = pickNextAgentMember();
|
||||
}
|
||||
|
||||
// pick an random agent
|
||||
if (!member) {
|
||||
const members = chat.members.filter(
|
||||
(member) =>
|
||||
member.userType === "Agent" &&
|
||||
member.id !== chatMessages[chatMessages.length - 1]?.member?.id
|
||||
// In a group chat, agents may talk to each other.
|
||||
if (!member && force && chat.members.length > 1) {
|
||||
member = chat.members.find(
|
||||
(m) =>
|
||||
m.userType === "ChatAgent" &&
|
||||
m.id !== chatMessages[chatMessages.length - 1]?.member?.id
|
||||
);
|
||||
member = members[Math.floor(Math.random() * members.length)];
|
||||
}
|
||||
|
||||
if (!member) {
|
||||
return toast.warning(t("itsYourTurn"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setAsking(member);
|
||||
try {
|
||||
const llm = buildLlm(member.agent);
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system", CHAT_SYSTEM_PROMPT_TEMPLATE],
|
||||
["user", "{input}"],
|
||||
]);
|
||||
const chain = prompt.pipe(llm);
|
||||
|
||||
setSubmitting(true);
|
||||
const lastChatMessage = chatMessages[chatMessages.length - 1];
|
||||
const reply = await chain.invoke({
|
||||
name: member.agent.name,
|
||||
agent_prompt: member.agent.config.prompt || "",
|
||||
agent_chat_prompt: member.config.prompt || "",
|
||||
language: chat.language,
|
||||
topic: chat.topic,
|
||||
members: chat.members
|
||||
.map((m) => {
|
||||
if (m.user) {
|
||||
return `- ${m.user.name} (${m.config.introduction})`;
|
||||
} else if (m.agent) {
|
||||
return `- ${m.agent.name} (${m.agent.introduction})`;
|
||||
}
|
||||
})
|
||||
.join("\n"),
|
||||
history: chatMessages
|
||||
.slice(0, chatMessages.length - 1)
|
||||
.map(
|
||||
(message) =>
|
||||
`- ${(message.member.user || message.member.agent).name}: ${
|
||||
message.content
|
||||
}(${dayjs(message.createdAt).fromNow()})`
|
||||
)
|
||||
.join("\n"),
|
||||
input:
|
||||
(lastChatMessage
|
||||
? `${lastChatMessage.member.name}: ${lastChatMessage.content}\n`
|
||||
: "") + `${member.agent.name}:`,
|
||||
});
|
||||
|
||||
// the reply may contain the member's name like "Agent: xxx". We need to remove it.
|
||||
const content = reply.content
|
||||
.toString()
|
||||
.replace(new RegExp(`^(${member.agent.name}):`), "")
|
||||
.trim();
|
||||
|
||||
return EnjoyApp.chatMessages
|
||||
.create({
|
||||
chatId: chat.id,
|
||||
memberId: member.id,
|
||||
content,
|
||||
state: "completed",
|
||||
})
|
||||
.then((message) =>
|
||||
dispatchChatMessages({ type: "append", record: message })
|
||||
)
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => setSubmitting(false));
|
||||
} catch (err) {
|
||||
await invokeAgent(member.id);
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
toast.error(err.message);
|
||||
setAsking(null);
|
||||
}
|
||||
};
|
||||
|
||||
const buildLlm = (agent: ChatAgentType) => {
|
||||
const { engine, model, temperature } = agent.config;
|
||||
const pickNextAgentMember = () => {
|
||||
const members = chat.members;
|
||||
const messages = chatMessages.filter(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.AGENT ||
|
||||
m.role === ChatMessageRoleEnum.USER
|
||||
);
|
||||
let currentIndex = messages.length - 1;
|
||||
const spokeMembers = new Set();
|
||||
|
||||
if (engine === "enjoyai") {
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: user.accessToken,
|
||||
configuration: {
|
||||
baseURL: `${apiUrl}/api/ai`,
|
||||
},
|
||||
maxRetries: 0,
|
||||
modelName: model,
|
||||
temperature,
|
||||
});
|
||||
} else if (engine === "openai") {
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: openai.key,
|
||||
configuration: {
|
||||
baseURL: openai.baseUrl,
|
||||
},
|
||||
maxRetries: 0,
|
||||
modelName: model,
|
||||
temperature,
|
||||
});
|
||||
while (currentIndex >= 0) {
|
||||
const message = messages[currentIndex];
|
||||
if (
|
||||
message.role === ChatMessageRoleEnum.AGENT &&
|
||||
spokeMembers.has(message.member?.id)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (message.role === ChatMessageRoleEnum.USER) {
|
||||
break;
|
||||
}
|
||||
if (!message.member) break;
|
||||
|
||||
spokeMembers.add(message.member.id);
|
||||
currentIndex--;
|
||||
}
|
||||
|
||||
// pick a member that has not spoken yet
|
||||
const nextMember = members.find((member) => !spokeMembers.has(member.id));
|
||||
|
||||
return nextMember;
|
||||
};
|
||||
|
||||
const onAssess = (assessment: PronunciationAssessmentType) => {
|
||||
@@ -304,6 +303,32 @@ export const ChatSessionProvider = ({
|
||||
});
|
||||
};
|
||||
|
||||
const updateChatName = async () => {
|
||||
if (
|
||||
chatMessages.filter((m) => m.role === ChatMessageRoleEnum.AGENT).length <
|
||||
1
|
||||
)
|
||||
return;
|
||||
|
||||
const content = chatMessages
|
||||
.filter(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.AGENT ||
|
||||
m.role === ChatMessageRoleEnum.USER
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((m) => m.content)
|
||||
.join("\n");
|
||||
try {
|
||||
const topic = await summarizeTopic(content);
|
||||
if (topic) {
|
||||
EnjoyApp.chats.update(chat.id, { name: topic });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
askForMediaAccess();
|
||||
}, []);
|
||||
@@ -328,12 +353,29 @@ export const ChatSessionProvider = ({
|
||||
}
|
||||
}, [recordingTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
|
||||
// Automatically update the chat name
|
||||
if (
|
||||
chat.name === t("newChat") &&
|
||||
chatMessages.filter((m) => m.role === ChatMessageRoleEnum.AGENT).length >
|
||||
0
|
||||
) {
|
||||
updateChatName();
|
||||
}
|
||||
}, [chatMessages]);
|
||||
|
||||
return (
|
||||
<ChatSessionProviderContext.Provider
|
||||
value={{
|
||||
chat,
|
||||
chatMessages,
|
||||
chatMembers,
|
||||
chatAgents,
|
||||
dispatchChatMessages,
|
||||
submitting,
|
||||
asking,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
@@ -354,7 +396,7 @@ export const ChatSessionProvider = ({
|
||||
}}
|
||||
>
|
||||
<MediaShadowProvider>
|
||||
{children}
|
||||
{chat && children}
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(deletingMessage)}
|
||||
@@ -434,6 +476,7 @@ export const ChatSessionProvider = ({
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</MediaShadowProvider>
|
||||
<Tooltip id={`${chatId}-tooltip`} />
|
||||
</ChatSessionProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
167
enjoy/src/renderer/context/copilot-provider.tsx
Normal file
167
enjoy/src/renderer/context/copilot-provider.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { createContext, useEffect, useState, useContext } from "react";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
HotKeysSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { DEFAULT_GPT_CONFIG } from "@/constants";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { ChatAgentTypeEnum } from "@/types/enums";
|
||||
|
||||
type CopilotProviderState = {
|
||||
active: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
currentChat: ChatType;
|
||||
setCurrentChat: (chat: ChatType) => void;
|
||||
occupiedChat: ChatType | null;
|
||||
setOccupiedChat: (chat: ChatType | null) => void;
|
||||
buildAgentMember: (agent: ChatAgentType) => ChatMemberDtoType;
|
||||
};
|
||||
|
||||
const initialState: CopilotProviderState = {
|
||||
active: false,
|
||||
setActive: () => null,
|
||||
currentChat: null,
|
||||
setCurrentChat: () => null,
|
||||
occupiedChat: null,
|
||||
setOccupiedChat: () => null,
|
||||
buildAgentMember: () => null,
|
||||
};
|
||||
|
||||
export const CopilotProviderContext =
|
||||
createContext<CopilotProviderState>(initialState);
|
||||
|
||||
const CACHE_KEY = "copilot-cached-chat";
|
||||
|
||||
export const CopilotProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [currentChat, setCurrentChat] = useState<ChatType>(null);
|
||||
const [occupiedChat, setOccupiedChat] = useState<ChatType | null>(null);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
const { sttEngine, currentGptEngine, currentTtsEngine } = useContext(
|
||||
AISettingsProviderContext
|
||||
);
|
||||
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
|
||||
|
||||
const findOrCreateChat = async () => {
|
||||
if (currentChat) return;
|
||||
|
||||
const cachedChatId = await EnjoyApp.cacheObjects.get(CACHE_KEY);
|
||||
let chat: ChatType;
|
||||
if (cachedChatId && cachedChatId !== occupiedChat?.id) {
|
||||
chat = await EnjoyApp.chats.findOne({
|
||||
where: { id: cachedChatId },
|
||||
});
|
||||
} else if (occupiedChat) {
|
||||
chat = await EnjoyApp.chats.findOne({
|
||||
not: {
|
||||
id: occupiedChat.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
chat = await EnjoyApp.chats.findOne({});
|
||||
}
|
||||
|
||||
if (chat && chat.id !== occupiedChat?.id) {
|
||||
setCurrentChat(chat);
|
||||
} else {
|
||||
const agent = await findOrCreateChatAgent();
|
||||
const chat = await EnjoyApp.chats.create({
|
||||
name: t("newChat"),
|
||||
config: {
|
||||
sttEngine,
|
||||
},
|
||||
members: [buildAgentMember(agent)],
|
||||
});
|
||||
setCurrentChat(chat);
|
||||
}
|
||||
};
|
||||
|
||||
const findOrCreateChatAgent = async () => {
|
||||
let agent = await EnjoyApp.chatAgents.findOne({});
|
||||
if (agent) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
return await EnjoyApp.chatAgents.create({
|
||||
name: t("models.chatAgent.namePlaceholder"),
|
||||
description: t("models.chatAgent.descriptionPlaceholder"),
|
||||
prompt: t("models.chatAgent.promptPlaceholder"),
|
||||
});
|
||||
};
|
||||
|
||||
const buildAgentMember = (agent: ChatAgentType): ChatMemberDtoType => {
|
||||
const config =
|
||||
agent.type === ChatAgentTypeEnum.TTS
|
||||
? {
|
||||
tts: {
|
||||
engine: currentTtsEngine.name,
|
||||
model: currentTtsEngine.model,
|
||||
voice: currentTtsEngine.voice,
|
||||
language: learningLanguage,
|
||||
...agent.config.tts,
|
||||
},
|
||||
}
|
||||
: {
|
||||
gpt: {
|
||||
...DEFAULT_GPT_CONFIG,
|
||||
engine: currentGptEngine.name,
|
||||
model: currentGptEngine.models.default,
|
||||
},
|
||||
tts: {
|
||||
engine: currentTtsEngine.name,
|
||||
model: currentTtsEngine.model,
|
||||
voice: currentTtsEngine.voice,
|
||||
language: learningLanguage,
|
||||
},
|
||||
};
|
||||
return {
|
||||
userId: agent.id,
|
||||
userType: "ChatAgent",
|
||||
config,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
findOrCreateChat();
|
||||
} else {
|
||||
setCurrentChat(null);
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentChat) return;
|
||||
|
||||
EnjoyApp.cacheObjects.set(CACHE_KEY, currentChat.id);
|
||||
if (!active) {
|
||||
setActive(true);
|
||||
}
|
||||
}, [currentChat]);
|
||||
|
||||
useHotkeys(currentHotkeys.OpenCopilot, () => {
|
||||
setActive(!active);
|
||||
});
|
||||
|
||||
return (
|
||||
<CopilotProviderContext.Provider
|
||||
value={{
|
||||
active,
|
||||
setActive,
|
||||
currentChat,
|
||||
setCurrentChat,
|
||||
occupiedChat,
|
||||
setOccupiedChat,
|
||||
buildAgentMember,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CopilotProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -49,6 +49,7 @@ const defaultKeyMap = {
|
||||
// system
|
||||
QuitApp: `${ControlOrCommand}+Q`,
|
||||
OpenPreferences: `${ControlOrCommand}+Comma`,
|
||||
OpenCopilot: `${ControlOrCommand}+L`,
|
||||
// player
|
||||
PlayOrPause: "Space",
|
||||
StartOrStopRecording: "R",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from "./ai-settings-provider";
|
||||
export * from "./app-settings-provider";
|
||||
export * from "./chat-provider";
|
||||
export * from "./chat-session-provider";
|
||||
export * from "./copilot-provider";
|
||||
export * from "./course-provider";
|
||||
export * from "./db-provider";
|
||||
export * from "./hotkeys-settings-provider";
|
||||
|
||||
@@ -15,7 +15,6 @@ import Chart from "chart.js/auto";
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { debounce } from "lodash";
|
||||
import { useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import { t } from "i18next";
|
||||
import { SttEngineOptionEnum } from "@/types/enums";
|
||||
|
||||
@@ -3,7 +3,6 @@ export * from "./use-audio";
|
||||
export * from "./use-camdict";
|
||||
export * from "./use-chat";
|
||||
export * from "./use-chat-agent";
|
||||
export * from "./use-chat-message";
|
||||
export * from "./use-conversation";
|
||||
export * from "./use-notes";
|
||||
export * from "./use-recordings";
|
||||
@@ -12,3 +11,6 @@ export * from "./use-segments";
|
||||
export * from "./use-transcribe";
|
||||
export * from "./use-transcriptions";
|
||||
export * from "./use-video";
|
||||
export * from "./use-chat-member";
|
||||
export * from "./use-speech";
|
||||
export * from "./use-chat-session";
|
||||
|
||||
@@ -18,7 +18,7 @@ export const useAiCommand = () => {
|
||||
const { EnjoyApp, webApi, nativeLanguage, learningLanguage } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { currentEngine } = useContext(AISettingsProviderContext);
|
||||
const { currentGptEngine } = useContext(AISettingsProviderContext);
|
||||
|
||||
const lookupWord = async (params: {
|
||||
word: string;
|
||||
@@ -46,7 +46,7 @@ export const useAiCommand = () => {
|
||||
}
|
||||
|
||||
const modelName =
|
||||
currentEngine.models.lookup || currentEngine.models.default;
|
||||
currentGptEngine.models.lookup || currentGptEngine.models.default;
|
||||
|
||||
const res = await lookupCommand(
|
||||
{
|
||||
@@ -57,9 +57,9 @@ export const useAiCommand = () => {
|
||||
learningLanguage,
|
||||
},
|
||||
{
|
||||
key: currentEngine.key,
|
||||
key: currentGptEngine.key,
|
||||
modelName,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -82,10 +82,10 @@ export const useAiCommand = () => {
|
||||
|
||||
const extractStory = async (story: StoryType) => {
|
||||
const res = await extractStoryCommand(story.content, learningLanguage, {
|
||||
key: currentEngine.key,
|
||||
key: currentGptEngine.key,
|
||||
modelName:
|
||||
currentEngine.models.extractStory || currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
currentGptEngine.models.extractStory || currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
});
|
||||
const { words = [], idioms = [] } = res;
|
||||
|
||||
@@ -100,9 +100,10 @@ export const useAiCommand = () => {
|
||||
cacheKey?: string
|
||||
): Promise<string> => {
|
||||
return translateCommand(text, nativeLanguage, {
|
||||
key: currentEngine.key,
|
||||
modelName: currentEngine.models.translate || currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
key: currentGptEngine.key,
|
||||
modelName:
|
||||
currentGptEngine.models.translate || currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}).then((res) => {
|
||||
if (cacheKey) {
|
||||
EnjoyApp.cacheObjects.set(cacheKey, res);
|
||||
@@ -119,9 +120,10 @@ export const useAiCommand = () => {
|
||||
nativeLanguage,
|
||||
},
|
||||
{
|
||||
key: currentEngine.key,
|
||||
modelName: currentEngine.models.analyze || currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
key: currentGptEngine.key,
|
||||
modelName:
|
||||
currentGptEngine.models.analyze || currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -133,17 +135,17 @@ export const useAiCommand = () => {
|
||||
|
||||
const punctuateText = async (text: string) => {
|
||||
return punctuateCommand(text, {
|
||||
key: currentEngine.key,
|
||||
modelName: currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
key: currentGptEngine.key,
|
||||
modelName: currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
const summarizeTopic = async (text: string) => {
|
||||
return summarizeTopicCommand(text, learningLanguage, {
|
||||
key: currentEngine.key,
|
||||
modelName: currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
key: currentGptEngine.key,
|
||||
modelName: currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -164,9 +166,9 @@ export const useAiCommand = () => {
|
||||
context,
|
||||
},
|
||||
{
|
||||
key: currentEngine.key,
|
||||
modelName: currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
key: currentGptEngine.key,
|
||||
modelName: currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -186,9 +188,9 @@ export const useAiCommand = () => {
|
||||
nativeLanguage: options?.nativeLanguage || nativeLanguage,
|
||||
},
|
||||
{
|
||||
key: currentEngine.key,
|
||||
modelName: currentEngine.models.default,
|
||||
baseUrl: currentEngine.baseUrl,
|
||||
key: currentGptEngine.key,
|
||||
modelName: currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -23,12 +23,7 @@ export const useChatAgent = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createChatAgent = (data: {
|
||||
name: string;
|
||||
language: string;
|
||||
introduction: string;
|
||||
config: any;
|
||||
}) => {
|
||||
const createChatAgent = (data: ChatAgentDtoType) => {
|
||||
return EnjoyApp.chatAgents
|
||||
.create(data)
|
||||
.then((agent) => {
|
||||
@@ -40,15 +35,7 @@ export const useChatAgent = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateChatAgent = (
|
||||
id: string,
|
||||
data: {
|
||||
name: string;
|
||||
language: string;
|
||||
introduction: string;
|
||||
config: any;
|
||||
}
|
||||
) => {
|
||||
const updateChatAgent = (id: string, data: ChatAgentDtoType) => {
|
||||
return EnjoyApp.chatAgents
|
||||
.update(id, data)
|
||||
.then((agent) => {
|
||||
|
||||
59
enjoy/src/renderer/hooks/use-chat-member.tsx
Normal file
59
enjoy/src/renderer/hooks/use-chat-member.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useContext, useEffect, useReducer } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { chatMembersReducer } from "@renderer/reducers";
|
||||
|
||||
export const useChatMember = (chatId: string) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const [chatMembers, dispatchChatMembers] = useReducer(chatMembersReducer, []);
|
||||
|
||||
const fetchChatMembers = async () => {
|
||||
return EnjoyApp.chatMembers.findAll({ where: { chatId } }).then((data) => {
|
||||
dispatchChatMembers({ type: "set", records: data });
|
||||
});
|
||||
};
|
||||
|
||||
const onChatMemberRecordUpdate = (event: CustomEvent) => {
|
||||
const { model, id, action, record } = event.detail || {};
|
||||
if (model !== "ChatMember") return;
|
||||
if (record.chatId !== chatId) return;
|
||||
|
||||
switch (action) {
|
||||
case "update": {
|
||||
dispatchChatMembers({ type: "update", record });
|
||||
break;
|
||||
}
|
||||
case "destroy": {
|
||||
dispatchChatMembers({
|
||||
type: "remove",
|
||||
record: { id } as ChatMemberType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "create": {
|
||||
dispatchChatMembers({ type: "append", record });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
|
||||
fetchChatMembers();
|
||||
addDblistener(onChatMemberRecordUpdate);
|
||||
|
||||
return () => {
|
||||
dispatchChatMembers({ type: "set", records: [] });
|
||||
removeDbListener(onChatMemberRecordUpdate);
|
||||
};
|
||||
}, [chatId]);
|
||||
|
||||
return {
|
||||
chatMembers,
|
||||
fetchChatMembers,
|
||||
};
|
||||
};
|
||||
@@ -1,26 +1,55 @@
|
||||
import { useEffect, useContext, useReducer } from "react";
|
||||
import { useEffect, useContext, useReducer, useState } from "react";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { chatMessagesReducer } from "@renderer/reducers";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
MessagesPlaceholder,
|
||||
} from "@langchain/core/prompts";
|
||||
import { BufferMemory, ChatMessageHistory } from "langchain/memory";
|
||||
import { ConversationChain } from "langchain/chains";
|
||||
import { LLMResult } from "@langchain/core/outputs";
|
||||
import { CHAT_GROUP_PROMPT_TEMPLATE } from "@/constants";
|
||||
import dayjs from "@renderer/lib/dayjs";
|
||||
import Mustache from "mustache";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
ChatMessageRoleEnum,
|
||||
ChatMessageStateEnum,
|
||||
ChatTypeEnum,
|
||||
} from "@/types/enums";
|
||||
|
||||
export const useChatMessage = (chat: ChatType) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
export const useChatMessage = (chatId: string) => {
|
||||
const { EnjoyApp, user, apiUrl } = useContext(AppSettingsProviderContext);
|
||||
const { openai } = useContext(AISettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const [chatMessages, dispatchChatMessages] = useReducer(
|
||||
chatMessagesReducer,
|
||||
[]
|
||||
);
|
||||
const [chat, setChat] = useState<ChatType>(null);
|
||||
|
||||
const fetchChat = async () => {
|
||||
if (!chatId) return;
|
||||
|
||||
EnjoyApp.chats.findOne({ where: { id: chatId } }).then((c) => {
|
||||
setChat(c);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchChatMessages = async (query?: string) => {
|
||||
if (!chat?.id) return;
|
||||
if (!chatId) return;
|
||||
|
||||
EnjoyApp.chatMessages
|
||||
.findAll({ where: { chatId: chat.id }, query })
|
||||
return EnjoyApp.chatMessages
|
||||
.findAll({ where: { chatId }, query })
|
||||
.then((data) => {
|
||||
dispatchChatMessages({ type: "set", records: data });
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
@@ -30,34 +59,20 @@ export const useChatMessage = (chat: ChatType) => {
|
||||
const onCreateUserMessage = (content: string, recordingUrl?: string) => {
|
||||
if (!content) return;
|
||||
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) => m.member.userType === "User" && m.state === "pending"
|
||||
);
|
||||
|
||||
if (pendingMessage) {
|
||||
return EnjoyApp.chatMessages.update(pendingMessage.id, {
|
||||
return EnjoyApp.chatMessages
|
||||
.create({
|
||||
chatId,
|
||||
content,
|
||||
role: ChatMessageRoleEnum.USER,
|
||||
state: ChatMessageStateEnum.PENDING,
|
||||
recordingUrl,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
} else {
|
||||
return EnjoyApp.chatMessages
|
||||
.create({
|
||||
chatId: chat.id,
|
||||
memberId: chat.members.find((m) => m.userType === "User").id,
|
||||
content,
|
||||
state: "pending",
|
||||
recordingUrl,
|
||||
})
|
||||
.then((message) =>
|
||||
dispatchChatMessages({ type: "append", record: message })
|
||||
)
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateMessage = (id: string, data: Partial<ChatMessageType>) => {
|
||||
const onUpdateMessage = (id: string, data: ChatMessageDtoType) => {
|
||||
return EnjoyApp.chatMessages.update(id, data);
|
||||
};
|
||||
|
||||
@@ -78,7 +93,12 @@ export const useChatMessage = (chat: ChatType) => {
|
||||
const onChatMessageRecordUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail;
|
||||
if (model === "ChatMessage") {
|
||||
if (record.chatId !== chatId) return;
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
dispatchChatMessages({ type: "append", record });
|
||||
break;
|
||||
case "update":
|
||||
dispatchChatMessages({ type: "update", record });
|
||||
break;
|
||||
@@ -98,18 +118,261 @@ export const useChatMessage = (chat: ChatType) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else if (model === "Speech") {
|
||||
switch (action) {
|
||||
case "create":
|
||||
if (record.sourceType !== "ChatMessage") return;
|
||||
|
||||
dispatchChatMessages({
|
||||
type: "update",
|
||||
record: {
|
||||
id: record.sourceId,
|
||||
speech: record,
|
||||
} as ChatMessageType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const invokeAgent = async (memberId: string) => {
|
||||
if (!chat) {
|
||||
await fetchChat();
|
||||
}
|
||||
|
||||
const member = await EnjoyApp.chatMembers.findOne({
|
||||
where: { id: memberId },
|
||||
});
|
||||
if (chat.type === ChatTypeEnum.CONVERSATION) {
|
||||
return askAgentInConversation(member);
|
||||
} else if (chat.type === ChatTypeEnum.GROUP) {
|
||||
return askAgentInGroup(member);
|
||||
} else if (chat.type === ChatTypeEnum.TTS) {
|
||||
return askAgentInTts(member);
|
||||
}
|
||||
};
|
||||
|
||||
const askAgentInConversation = async (member: ChatMemberType) => {
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
if (!pendingMessage) return;
|
||||
|
||||
const llm = buildLlm(member);
|
||||
const historyBufferSize = member.config.gpt.historyBufferSize || 10;
|
||||
const messages = chatMessages
|
||||
.filter((m) => m.state === ChatMessageStateEnum.COMPLETED)
|
||||
.slice(-historyBufferSize);
|
||||
const chatHistory = new ChatMessageHistory();
|
||||
messages.forEach((message) => {
|
||||
if (message.role === ChatMessageRoleEnum.USER) {
|
||||
chatHistory.addUserMessage(message.content);
|
||||
} else if (message.role === ChatMessageRoleEnum.AGENT) {
|
||||
chatHistory.addAIMessage(message.content);
|
||||
}
|
||||
});
|
||||
|
||||
const memory = new BufferMemory({
|
||||
chatHistory,
|
||||
memoryKey: "history",
|
||||
returnMessages: true,
|
||||
});
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system" as MessageRoleEnum, buildSystemPrompt(member)],
|
||||
new MessagesPlaceholder("history"),
|
||||
["human", "{input}"],
|
||||
]);
|
||||
const chain = new ConversationChain({
|
||||
llm: llm as any,
|
||||
memory,
|
||||
prompt: prompt as any,
|
||||
verbose: true,
|
||||
});
|
||||
let response: LLMResult["generations"][0] = [];
|
||||
await chain.call({ input: pendingMessage.content }, [
|
||||
{
|
||||
handleLLMEnd: async (output) => {
|
||||
response = output.generations[0];
|
||||
},
|
||||
},
|
||||
]);
|
||||
for (const r of response) {
|
||||
await EnjoyApp.chatMessages.create({
|
||||
chatId,
|
||||
memberId: member.id,
|
||||
content: r.text,
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
}
|
||||
onUpdateMessage(pendingMessage.id, {
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
};
|
||||
|
||||
const askAgentInGroup = async (member: ChatMemberType) => {
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
|
||||
const llm = buildLlm(member);
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system", buildSystemPrompt(member)],
|
||||
["user", CHAT_GROUP_PROMPT_TEMPLATE],
|
||||
]);
|
||||
const chain = prompt.pipe(llm);
|
||||
const historyBufferSize = member.config.gpt.historyBufferSize || 10;
|
||||
const history = chatMessages
|
||||
.filter(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.AGENT ||
|
||||
m.role === ChatMessageRoleEnum.USER
|
||||
)
|
||||
.slice(-historyBufferSize)
|
||||
.map((message) => {
|
||||
const timestamp = dayjs(message.createdAt).fromNow();
|
||||
switch (message.role) {
|
||||
case ChatMessageRoleEnum.AGENT:
|
||||
return `${message.member.agent.name}: ${message.content} (${timestamp})`;
|
||||
case ChatMessageRoleEnum.USER:
|
||||
return `${user.name}: ${message.content} (${timestamp})`;
|
||||
case ChatMessageRoleEnum.SYSTEM:
|
||||
return `(${message.content}, ${timestamp})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const reply = await chain.invoke({
|
||||
name: member.agent.name,
|
||||
history,
|
||||
});
|
||||
|
||||
// the reply may contain the member's name like "ChatAgent: xxx". We need to remove it.
|
||||
const content = reply.content
|
||||
.toString()
|
||||
.replace(new RegExp(`^(${member.agent.name}):`), "")
|
||||
.trim();
|
||||
|
||||
const message = await EnjoyApp.chatMessages.create({
|
||||
chatId,
|
||||
memberId: member.id,
|
||||
content,
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
if (pendingMessage) {
|
||||
onUpdateMessage(pendingMessage.id, {
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const askAgentInTts = async (member: ChatMemberType) => {
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
if (!pendingMessage) return;
|
||||
|
||||
const message = await EnjoyApp.chatMessages.create({
|
||||
chatId,
|
||||
memberId: member.id,
|
||||
content: pendingMessage.content,
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
onUpdateMessage(pendingMessage.id, {
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const buildLlm = (member: ChatMemberType) => {
|
||||
const {
|
||||
engine = "enjoyai",
|
||||
model = "gpt-4o",
|
||||
temperature,
|
||||
maxCompletionTokens,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
numberOfChoices,
|
||||
} = member.config.gpt;
|
||||
|
||||
if (engine === "enjoyai") {
|
||||
if (!user.accessToken) {
|
||||
throw new Error(t("authorizationExpired"));
|
||||
}
|
||||
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: user.accessToken,
|
||||
configuration: {
|
||||
baseURL: `${apiUrl}/api/ai`,
|
||||
},
|
||||
maxRetries: 0,
|
||||
modelName: model,
|
||||
temperature,
|
||||
maxTokens: maxCompletionTokens,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
n: numberOfChoices,
|
||||
});
|
||||
} else if (engine === "openai") {
|
||||
if (!openai.key) {
|
||||
throw new Error(t("openaiKeyRequired"));
|
||||
}
|
||||
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: openai.key,
|
||||
configuration: {
|
||||
baseURL: openai.baseUrl,
|
||||
},
|
||||
maxRetries: 0,
|
||||
modelName: model,
|
||||
temperature,
|
||||
maxTokens: maxCompletionTokens,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
n: numberOfChoices,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("aiEngineNotSupported"));
|
||||
}
|
||||
};
|
||||
|
||||
const buildSystemPrompt = (member: ChatMemberType) => {
|
||||
return Mustache.render(
|
||||
`{{{agent_prompt}}}
|
||||
{{{chat_prompt}}}
|
||||
{{{member_prompt}}}`,
|
||||
{
|
||||
agent_prompt: member.agent.prompt,
|
||||
chat_prompt: chat.config.prompt,
|
||||
member_prompt: member.config.prompt,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
fetchChat();
|
||||
}, [chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
|
||||
addDblistener(onChatMessageRecordUpdate);
|
||||
fetchChatMessages();
|
||||
return () => {
|
||||
removeDbListener(onChatMessageRecordUpdate);
|
||||
dispatchChatMessages({ type: "set", records: [] });
|
||||
};
|
||||
}, [chat]);
|
||||
}, [chatId]);
|
||||
|
||||
return {
|
||||
chatMessages,
|
||||
@@ -118,5 +381,6 @@ export const useChatMessage = (chat: ChatType) => {
|
||||
onCreateUserMessage,
|
||||
onUpdateMessage,
|
||||
onDeleteMessage,
|
||||
invokeAgent,
|
||||
};
|
||||
};
|
||||
|
||||
408
enjoy/src/renderer/hooks/use-chat-session.tsx
Normal file
408
enjoy/src/renderer/hooks/use-chat-session.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useContext, useReducer, useState } from "react";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { chatMessagesReducer } from "@renderer/reducers";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
MessagesPlaceholder,
|
||||
} from "@langchain/core/prompts";
|
||||
import { BufferMemory, ChatMessageHistory } from "langchain/memory";
|
||||
import { ConversationChain } from "langchain/chains";
|
||||
import { LLMResult } from "@langchain/core/outputs";
|
||||
import { CHAT_GROUP_PROMPT_TEMPLATE } from "@/constants";
|
||||
import dayjs from "@renderer/lib/dayjs";
|
||||
import Mustache from "mustache";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
ChatMessageRoleEnum,
|
||||
ChatMessageStateEnum,
|
||||
ChatTypeEnum,
|
||||
} from "@/types/enums";
|
||||
|
||||
export const useChatSession = (chatId: string) => {
|
||||
const { EnjoyApp, user, apiUrl } = useContext(AppSettingsProviderContext);
|
||||
const { openai } = useContext(AISettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const [chatMessages, dispatchChatMessages] = useReducer(
|
||||
chatMessagesReducer,
|
||||
[]
|
||||
);
|
||||
const [chat, setChat] = useState<ChatType>(null);
|
||||
|
||||
const fetchChat = async () => {
|
||||
if (!chatId) return;
|
||||
|
||||
EnjoyApp.chats.findOne({ where: { id: chatId } }).then((c) => {
|
||||
setChat(c);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchChatMessages = async (query?: string) => {
|
||||
if (!chatId) return;
|
||||
|
||||
return EnjoyApp.chatMessages
|
||||
.findAll({ where: { chatId }, query })
|
||||
.then((data) => {
|
||||
dispatchChatMessages({ type: "set", records: data });
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateUserMessage = (content: string, recordingUrl?: string) => {
|
||||
if (!content) return;
|
||||
|
||||
return EnjoyApp.chatMessages
|
||||
.create({
|
||||
chatId,
|
||||
content,
|
||||
role: ChatMessageRoleEnum.USER,
|
||||
state: ChatMessageStateEnum.PENDING,
|
||||
recordingUrl,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateMessage = (id: string, data: ChatMessageDtoType) => {
|
||||
return EnjoyApp.chatMessages.update(id, data);
|
||||
};
|
||||
|
||||
const onDeleteMessage = async (chatMessageId: string) => {
|
||||
return EnjoyApp.chatMessages
|
||||
.destroy(chatMessageId)
|
||||
.then(() =>
|
||||
dispatchChatMessages({
|
||||
type: "remove",
|
||||
record: { id: chatMessageId } as ChatMessageType,
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const onChatSessionUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail;
|
||||
if (model === "Chat") {
|
||||
if (record.id !== chatId) return;
|
||||
|
||||
switch (action) {
|
||||
case "update":
|
||||
setChat(record);
|
||||
break;
|
||||
case "destroy":
|
||||
setChat(null);
|
||||
dispatchChatMessages({ type: "set", records: [] });
|
||||
break;
|
||||
}
|
||||
} else if (model === "ChatMember") {
|
||||
if (record.chatId !== chatId) return;
|
||||
|
||||
fetchChat();
|
||||
} else if (model === "ChatAgent") {
|
||||
if ((chat?.members || []).findIndex((m) => m.userId === record.id) === -1)
|
||||
return;
|
||||
|
||||
fetchChat();
|
||||
} else if (model === "ChatMessage") {
|
||||
if (record.chatId !== chatId) return;
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
dispatchChatMessages({ type: "append", record });
|
||||
break;
|
||||
case "update":
|
||||
dispatchChatMessages({ type: "update", record });
|
||||
break;
|
||||
case "destroy":
|
||||
dispatchChatMessages({ type: "remove", record });
|
||||
break;
|
||||
}
|
||||
} else if (model === "Recording") {
|
||||
switch (action) {
|
||||
case "create":
|
||||
dispatchChatMessages({
|
||||
type: "update",
|
||||
record: {
|
||||
id: record.targetId,
|
||||
recording: record,
|
||||
} as ChatMessageType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else if (model === "Speech") {
|
||||
switch (action) {
|
||||
case "create":
|
||||
if (record.sourceType !== "ChatMessage") return;
|
||||
|
||||
dispatchChatMessages({
|
||||
type: "update",
|
||||
record: {
|
||||
id: record.sourceId,
|
||||
speech: record,
|
||||
} as ChatMessageType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const invokeAgent = async (memberId: string) => {
|
||||
if (!chat) {
|
||||
await fetchChat();
|
||||
}
|
||||
if (!chat) return;
|
||||
|
||||
const member = await EnjoyApp.chatMembers.findOne({
|
||||
where: { id: memberId },
|
||||
});
|
||||
if (chat.type === ChatTypeEnum.CONVERSATION) {
|
||||
return askAgentInConversation(member);
|
||||
} else if (chat.type === ChatTypeEnum.GROUP) {
|
||||
return askAgentInGroup(member);
|
||||
} else if (chat.type === ChatTypeEnum.TTS) {
|
||||
return askAgentInTts(member);
|
||||
}
|
||||
};
|
||||
|
||||
const askAgentInConversation = async (member: ChatMemberType) => {
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
if (!pendingMessage) return;
|
||||
|
||||
const llm = buildLlm(member);
|
||||
const historyBufferSize = member.config.gpt.historyBufferSize || 10;
|
||||
const messages = chatMessages
|
||||
.filter((m) => m.state === ChatMessageStateEnum.COMPLETED)
|
||||
.slice(-historyBufferSize);
|
||||
const chatHistory = new ChatMessageHistory();
|
||||
messages.forEach((message) => {
|
||||
if (message.role === ChatMessageRoleEnum.USER) {
|
||||
chatHistory.addUserMessage(message.content);
|
||||
} else if (message.role === ChatMessageRoleEnum.AGENT) {
|
||||
chatHistory.addAIMessage(message.content);
|
||||
}
|
||||
});
|
||||
|
||||
const memory = new BufferMemory({
|
||||
chatHistory,
|
||||
memoryKey: "history",
|
||||
returnMessages: true,
|
||||
});
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system" as MessageRoleEnum, buildSystemPrompt(member)],
|
||||
new MessagesPlaceholder("history"),
|
||||
["human", "{input}"],
|
||||
]);
|
||||
const chain = new ConversationChain({
|
||||
llm: llm as any,
|
||||
memory,
|
||||
prompt: prompt as any,
|
||||
verbose: true,
|
||||
});
|
||||
let response: LLMResult["generations"][0] = [];
|
||||
await chain.call({ input: pendingMessage.content }, [
|
||||
{
|
||||
handleLLMEnd: async (output) => {
|
||||
response = output.generations[0];
|
||||
},
|
||||
},
|
||||
]);
|
||||
for (const r of response) {
|
||||
await EnjoyApp.chatMessages.create({
|
||||
chatId,
|
||||
memberId: member.id,
|
||||
content: r.text,
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
}
|
||||
onUpdateMessage(pendingMessage.id, {
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
};
|
||||
|
||||
const askAgentInGroup = async (member: ChatMemberType) => {
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
|
||||
const llm = buildLlm(member);
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
["system", buildSystemPrompt(member)],
|
||||
["user", CHAT_GROUP_PROMPT_TEMPLATE],
|
||||
]);
|
||||
const chain = prompt.pipe(llm);
|
||||
const historyBufferSize = member.config.gpt.historyBufferSize || 10;
|
||||
const history = chatMessages
|
||||
.filter(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.AGENT ||
|
||||
m.role === ChatMessageRoleEnum.USER
|
||||
)
|
||||
.slice(-historyBufferSize)
|
||||
.map((message) => {
|
||||
const timestamp = dayjs(message.createdAt).fromNow();
|
||||
switch (message.role) {
|
||||
case ChatMessageRoleEnum.AGENT:
|
||||
return `${message.member.agent.name}: ${message.content} (${timestamp})`;
|
||||
case ChatMessageRoleEnum.USER:
|
||||
return `${user.name}: ${message.content} (${timestamp})`;
|
||||
case ChatMessageRoleEnum.SYSTEM:
|
||||
return `(${message.content}, ${timestamp})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const reply = await chain.invoke({
|
||||
name: member.agent.name,
|
||||
history,
|
||||
});
|
||||
|
||||
// the reply may contain the member's name like "ChatAgent: xxx". We need to remove it.
|
||||
const content = reply.content
|
||||
.toString()
|
||||
.replace(new RegExp(`^(${member.agent.name}):`), "")
|
||||
.trim();
|
||||
|
||||
const message = await EnjoyApp.chatMessages.create({
|
||||
chatId,
|
||||
memberId: member.id,
|
||||
content,
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
if (pendingMessage) {
|
||||
onUpdateMessage(pendingMessage.id, {
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const askAgentInTts = async (member: ChatMemberType) => {
|
||||
const pendingMessage = chatMessages.find(
|
||||
(m) =>
|
||||
m.role === ChatMessageRoleEnum.USER &&
|
||||
m.state === ChatMessageStateEnum.PENDING
|
||||
);
|
||||
if (!pendingMessage) return;
|
||||
|
||||
const message = await EnjoyApp.chatMessages.create({
|
||||
chatId,
|
||||
memberId: member.id,
|
||||
content: pendingMessage.content,
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
onUpdateMessage(pendingMessage.id, {
|
||||
state: ChatMessageStateEnum.COMPLETED,
|
||||
});
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const buildLlm = (member: ChatMemberType) => {
|
||||
const {
|
||||
engine = "enjoyai",
|
||||
model = "gpt-4o",
|
||||
temperature,
|
||||
maxCompletionTokens,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
numberOfChoices,
|
||||
} = member.config.gpt;
|
||||
|
||||
if (engine === "enjoyai") {
|
||||
if (!user.accessToken) {
|
||||
throw new Error(t("authorizationExpired"));
|
||||
}
|
||||
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: user.accessToken,
|
||||
configuration: {
|
||||
baseURL: `${apiUrl}/api/ai`,
|
||||
},
|
||||
maxRetries: 0,
|
||||
modelName: model,
|
||||
temperature,
|
||||
maxTokens: maxCompletionTokens,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
n: numberOfChoices,
|
||||
});
|
||||
} else if (engine === "openai") {
|
||||
if (!openai.key) {
|
||||
throw new Error(t("openaiKeyRequired"));
|
||||
}
|
||||
|
||||
return new ChatOpenAI({
|
||||
openAIApiKey: openai.key,
|
||||
configuration: {
|
||||
baseURL: openai.baseUrl,
|
||||
},
|
||||
maxRetries: 0,
|
||||
modelName: model,
|
||||
temperature,
|
||||
maxTokens: maxCompletionTokens,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
n: numberOfChoices,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("aiEngineNotSupported"));
|
||||
}
|
||||
};
|
||||
|
||||
const buildSystemPrompt = (member: ChatMemberType) => {
|
||||
return Mustache.render(
|
||||
`{{{agent_prompt}}}
|
||||
{{{chat_prompt}}}
|
||||
{{{member_prompt}}}`,
|
||||
{
|
||||
agent_prompt: member.agent.prompt,
|
||||
chat_prompt: chat.config.prompt,
|
||||
member_prompt: member.config.prompt,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
|
||||
fetchChat();
|
||||
addDblistener(onChatSessionUpdate);
|
||||
fetchChatMessages();
|
||||
return () => {
|
||||
removeDbListener(onChatSessionUpdate);
|
||||
dispatchChatMessages({ type: "set", records: [] });
|
||||
};
|
||||
}, [chatId]);
|
||||
|
||||
return {
|
||||
chat,
|
||||
chatMembers: chat?.members,
|
||||
chatAgents: chat?.members?.filter((m) => m.agent)?.map((m) => m.agent),
|
||||
chatMessages,
|
||||
fetchChatMessages,
|
||||
dispatchChatMessages,
|
||||
onCreateUserMessage,
|
||||
onUpdateMessage,
|
||||
onDeleteMessage,
|
||||
invokeAgent,
|
||||
};
|
||||
};
|
||||
@@ -2,19 +2,24 @@ import { useEffect, useContext, useReducer } from "react";
|
||||
import {
|
||||
DbProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
CopilotProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { chatsReducer } from "@renderer/reducers";
|
||||
|
||||
export const useChat = () => {
|
||||
export const useChat = (chatAgentId?: string) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const {
|
||||
currentChat: copilotCurrentChat,
|
||||
setCurrentChat: setCopilotCurrentChat,
|
||||
} = useContext(CopilotProviderContext);
|
||||
const [chats, dispatchChats] = useReducer(chatsReducer, []);
|
||||
|
||||
const fetchChats = async (query?: string) => {
|
||||
EnjoyApp.chats
|
||||
.findAll({ query })
|
||||
.findAll({ query, chatAgentId })
|
||||
.then((data) => {
|
||||
dispatchChats({ type: "set", records: data });
|
||||
})
|
||||
@@ -23,54 +28,10 @@ export const useChat = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createChat = (data: {
|
||||
name: string;
|
||||
language: string;
|
||||
topic: string;
|
||||
members: Array<{
|
||||
userId?: string;
|
||||
userType?: "User" | "Agent";
|
||||
config?: {
|
||||
prompt?: string;
|
||||
introduction?: string;
|
||||
};
|
||||
}>;
|
||||
config: {
|
||||
sttEngine: string;
|
||||
};
|
||||
}) => {
|
||||
return EnjoyApp.chats
|
||||
.create(data)
|
||||
.then((chat) => {
|
||||
toast.success(t("models.chat.created"));
|
||||
dispatchChats({ type: "append", record: chat });
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const updateChat = (
|
||||
id: string,
|
||||
data: {
|
||||
name: string;
|
||||
language: string;
|
||||
topic: string;
|
||||
members: Array<{
|
||||
userId?: string;
|
||||
userType?: "User" | "Agent";
|
||||
prompt?: string;
|
||||
introduction?: string;
|
||||
}>;
|
||||
config: {
|
||||
sttEngine: string;
|
||||
};
|
||||
}
|
||||
) => {
|
||||
const updateChat = (id: string, data: ChatDtoType) => {
|
||||
return EnjoyApp.chats
|
||||
.update(id, data)
|
||||
.then((chat) => {
|
||||
console.log(chat);
|
||||
dispatchChats({ type: "update", record: chat });
|
||||
toast.success(t("models.chat.updated"));
|
||||
})
|
||||
@@ -79,26 +40,25 @@ export const useChat = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const destroyChat = (id: string) => {
|
||||
return EnjoyApp.chats
|
||||
.destroy(id)
|
||||
.then(() => {
|
||||
toast.success(t("models.chat.deleted"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const onChatUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail || {};
|
||||
if (model !== "Chat") return;
|
||||
switch (action) {
|
||||
case "create": {
|
||||
toast.success(t("models.chat.created"));
|
||||
dispatchChats({ type: "prepend", record });
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
toast.success(t("models.chat.updated"));
|
||||
dispatchChats({ type: "update", record });
|
||||
break;
|
||||
}
|
||||
case "destroy": {
|
||||
toast.success(t("models.chat.deleted"));
|
||||
if (record.id === copilotCurrentChat?.id) {
|
||||
setCopilotCurrentChat(null);
|
||||
}
|
||||
dispatchChats({ type: "remove", record });
|
||||
break;
|
||||
}
|
||||
@@ -106,7 +66,6 @@ export const useChat = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats();
|
||||
addDblistener(onChatUpdate);
|
||||
|
||||
return () => {
|
||||
@@ -114,11 +73,13 @@ export const useChat = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats();
|
||||
}, [chatAgentId]);
|
||||
|
||||
return {
|
||||
chats,
|
||||
fetchChats,
|
||||
createChat,
|
||||
updateChat,
|
||||
destroyChat,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,17 +11,14 @@ import {
|
||||
ChatPromptTemplate,
|
||||
MessagesPlaceholder,
|
||||
} from "@langchain/core/prompts";
|
||||
import OpenAI from "openai";
|
||||
import { type LLMResult } from "@langchain/core/outputs";
|
||||
import { v4 } from "uuid";
|
||||
import * as sdk from "microsoft-cognitiveservices-speech-sdk";
|
||||
import { t } from "i18next";
|
||||
import { useSpeech } from "./use-speech";
|
||||
|
||||
export const useConversation = () => {
|
||||
const { EnjoyApp, webApi, user, apiUrl, learningLanguage } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { openai, currentEngine } = useContext(AISettingsProviderContext);
|
||||
const { EnjoyApp, user, apiUrl } = useContext(AppSettingsProviderContext);
|
||||
const { openai } = useContext(AISettingsProviderContext);
|
||||
const { tts } = useSpeech();
|
||||
|
||||
const pickLlm = (conversation: ConversationType) => {
|
||||
const {
|
||||
@@ -98,7 +95,7 @@ export const useConversation = () => {
|
||||
if (message.role === "user") {
|
||||
chatMessageHistory.addUserMessage(message.content);
|
||||
} else if (message.role === "assistant") {
|
||||
chatMessageHistory.addAIChatMessage(message.content);
|
||||
chatMessageHistory.addAIMessage(message.content);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -215,120 +212,7 @@ export const useConversation = () => {
|
||||
return [reply];
|
||||
};
|
||||
|
||||
const tts = async (params: Partial<SpeechType>) => {
|
||||
const { configuration } = params;
|
||||
const { engine, model = "tts-1", voice } = configuration || {};
|
||||
|
||||
let buffer;
|
||||
if (model.match(/^(openai|tts-)/)) {
|
||||
buffer = await openaiTTS(params);
|
||||
} else if (model.startsWith("azure")) {
|
||||
buffer = await azureTTS(params);
|
||||
}
|
||||
|
||||
return EnjoyApp.speeches.create(
|
||||
{
|
||||
text: params.text,
|
||||
sourceType: params.sourceType,
|
||||
sourceId: params.sourceId,
|
||||
configuration: {
|
||||
engine,
|
||||
model,
|
||||
voice,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "audio/mp3",
|
||||
arrayBuffer: buffer,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const openaiTTS = async (params: Partial<SpeechType>) => {
|
||||
const { configuration } = params;
|
||||
const {
|
||||
engine = currentEngine.name,
|
||||
model = "tts-1",
|
||||
voice = "alloy",
|
||||
baseUrl,
|
||||
} = configuration || {};
|
||||
|
||||
let client: OpenAI;
|
||||
|
||||
if (engine === "enjoyai") {
|
||||
client = new OpenAI({
|
||||
apiKey: user.accessToken,
|
||||
baseURL: `${apiUrl}/api/ai`,
|
||||
dangerouslyAllowBrowser: true,
|
||||
maxRetries: 1,
|
||||
});
|
||||
} else if (openai) {
|
||||
client = new OpenAI({
|
||||
apiKey: openai.key,
|
||||
baseURL: baseUrl || openai.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
maxRetries: 1,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("openaiKeyRequired"));
|
||||
}
|
||||
|
||||
const file = await client.audio.speech.create({
|
||||
input: params.text,
|
||||
model: model.replace("openai/", ""),
|
||||
voice,
|
||||
});
|
||||
|
||||
return file.arrayBuffer();
|
||||
};
|
||||
|
||||
const azureTTS = async (
|
||||
params: Partial<SpeechType>
|
||||
): Promise<ArrayBuffer> => {
|
||||
const { configuration, text } = params;
|
||||
const { model, voice } = configuration || {};
|
||||
|
||||
if (model !== "azure/speech") return;
|
||||
|
||||
const { id, token, region } = await webApi.generateSpeechToken({
|
||||
purpose: "tts",
|
||||
input: text,
|
||||
});
|
||||
const speechConfig = sdk.SpeechConfig.fromAuthorizationToken(token, region);
|
||||
const audioConfig = sdk.AudioConfig.fromDefaultSpeakerOutput();
|
||||
speechConfig.speechRecognitionLanguage = learningLanguage;
|
||||
speechConfig.speechSynthesisVoiceName = voice;
|
||||
|
||||
const speechSynthesizer = new sdk.SpeechSynthesizer(
|
||||
speechConfig,
|
||||
audioConfig
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
speechSynthesizer.speakTextAsync(
|
||||
text,
|
||||
(result) => {
|
||||
speechSynthesizer.close();
|
||||
|
||||
if (result && result.audioData) {
|
||||
webApi.consumeSpeechToken(id);
|
||||
resolve(result.audioData);
|
||||
} else {
|
||||
webApi.revokeSpeechToken(id);
|
||||
reject(result);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
speechSynthesizer.close();
|
||||
webApi.revokeSpeechToken(id);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
chat,
|
||||
tts,
|
||||
};
|
||||
};
|
||||
|
||||
129
enjoy/src/renderer/hooks/use-speech.tsx
Normal file
129
enjoy/src/renderer/hooks/use-speech.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
AISettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useContext } from "react";
|
||||
import OpenAI from "openai";
|
||||
import * as sdk from "microsoft-cognitiveservices-speech-sdk";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const useSpeech = () => {
|
||||
const { EnjoyApp, webApi, user, apiUrl, learningLanguage } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { openai, currentGptEngine } = useContext(AISettingsProviderContext);
|
||||
|
||||
const tts = async (params: Partial<SpeechType>) => {
|
||||
const { configuration } = params;
|
||||
const { engine, model = "tts-1", voice } = configuration || {};
|
||||
|
||||
let buffer;
|
||||
if (model.match(/^(openai|tts-)/)) {
|
||||
buffer = await openaiTTS(params);
|
||||
} else if (model.startsWith("azure")) {
|
||||
buffer = await azureTTS(params);
|
||||
}
|
||||
|
||||
return EnjoyApp.speeches.create(
|
||||
{
|
||||
text: params.text,
|
||||
sourceType: params.sourceType,
|
||||
sourceId: params.sourceId,
|
||||
configuration: {
|
||||
engine,
|
||||
model,
|
||||
voice,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "audio/mp3",
|
||||
arrayBuffer: buffer,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const openaiTTS = async (params: Partial<SpeechType>) => {
|
||||
const { configuration } = params;
|
||||
const {
|
||||
engine = currentGptEngine.name,
|
||||
model = "tts-1",
|
||||
voice = "alloy",
|
||||
baseUrl,
|
||||
} = configuration || {};
|
||||
|
||||
let client: OpenAI;
|
||||
|
||||
if (engine === "enjoyai") {
|
||||
client = new OpenAI({
|
||||
apiKey: user.accessToken,
|
||||
baseURL: `${apiUrl}/api/ai`,
|
||||
dangerouslyAllowBrowser: true,
|
||||
maxRetries: 1,
|
||||
});
|
||||
} else if (openai) {
|
||||
client = new OpenAI({
|
||||
apiKey: openai.key,
|
||||
baseURL: baseUrl || openai.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
maxRetries: 1,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("openaiKeyRequired"));
|
||||
}
|
||||
|
||||
const file = await client.audio.speech.create({
|
||||
input: params.text,
|
||||
model: model.replace("openai/", ""),
|
||||
voice,
|
||||
});
|
||||
|
||||
return file.arrayBuffer();
|
||||
};
|
||||
|
||||
const azureTTS = async (
|
||||
params: Partial<SpeechType>
|
||||
): Promise<ArrayBuffer> => {
|
||||
const { configuration, text } = params;
|
||||
const { model, voice } = configuration || {};
|
||||
|
||||
if (model !== "azure/speech") return;
|
||||
|
||||
const { id, token, region } = await webApi.generateSpeechToken({
|
||||
purpose: "tts",
|
||||
input: text,
|
||||
});
|
||||
const speechConfig = sdk.SpeechConfig.fromAuthorizationToken(token, region);
|
||||
speechConfig.speechRecognitionLanguage = learningLanguage;
|
||||
speechConfig.speechSynthesisVoiceName = voice;
|
||||
|
||||
// const speechSynthesizer = new sdk.SpeechSynthesizer(speechConfig, sdk.AudioConfig.fromDefaultSpeakerOutput());
|
||||
// Do not playback audio when transcribed
|
||||
const speechSynthesizer = new sdk.SpeechSynthesizer(speechConfig, null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
speechSynthesizer.speakTextAsync(
|
||||
text,
|
||||
(result) => {
|
||||
speechSynthesizer.close();
|
||||
|
||||
if (result && result.audioData) {
|
||||
webApi.consumeSpeechToken(id);
|
||||
resolve(result.audioData);
|
||||
} else {
|
||||
webApi.revokeSpeechToken(id);
|
||||
reject(result);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
speechSynthesizer.close();
|
||||
webApi.revokeSpeechToken(id);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
tts,
|
||||
};
|
||||
};
|
||||
@@ -135,6 +135,20 @@ export function renderPitchContour(options: {
|
||||
});
|
||||
}
|
||||
|
||||
export function isSameTimeRange(time1: Date | string, time2: Date | string) {
|
||||
if (dayjs(time1).isSame(time2, "day")) {
|
||||
return dayjs(time1).isSame(time2, "hour");
|
||||
} else if (dayjs(time1).diff(time2, "week") < 1) {
|
||||
return dayjs(time1).isSame(time2, "day");
|
||||
} else if (dayjs(time1).diff(time2, "month") < 1) {
|
||||
return dayjs(time1).isSame(time2, "week");
|
||||
} else if (dayjs(time1).diff(time2, "year") < 1) {
|
||||
return dayjs(time1).isSame(time2, "month");
|
||||
} else {
|
||||
return dayjs(time1).isSame(time2, "year");
|
||||
}
|
||||
}
|
||||
|
||||
export function imgErrorToDefalut(
|
||||
e: React.SyntheticEvent<HTMLImageElement, Event>
|
||||
) {
|
||||
|
||||
@@ -1,15 +1,86 @@
|
||||
import { Chat, ChatSidebar } from "@renderer/components";
|
||||
import { ChatProvider } from "@renderer/context";
|
||||
import { ChatSession, ChatAgents, ChatList } from "@renderer/components";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@renderer/components/ui";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { CopilotProviderContext } from "@renderer/context";
|
||||
import { useChat, useChatAgent } from "@renderer/hooks";
|
||||
|
||||
export default function Chats() {
|
||||
const [currentChat, setCurrentChat] = useState<ChatType | null>(null);
|
||||
const [currentChatAgent, setCurrentChatAgent] =
|
||||
useState<ChatAgentType | null>(null);
|
||||
const { currentChat: copilotCurrentChat, setOccupiedChat } = useContext(
|
||||
CopilotProviderContext
|
||||
);
|
||||
const [sidePanelCollapsed, setSidePanelCollapsed] = useState(false);
|
||||
|
||||
const { chats } = useChat(currentChatAgent?.id);
|
||||
const { chatAgents, fetchChatAgents } = useChatAgent();
|
||||
|
||||
// Do not open the same chat in copilot and main window
|
||||
const handleSelectChat = (chat: ChatType) => {
|
||||
if (chat && copilotCurrentChat?.id === chat.id) return;
|
||||
setCurrentChat(chat);
|
||||
};
|
||||
|
||||
// set occupied chat when current chat changes
|
||||
useEffect(() => {
|
||||
if (currentChat) {
|
||||
setOccupiedChat(currentChat);
|
||||
}
|
||||
|
||||
return () => {
|
||||
setOccupiedChat(null);
|
||||
};
|
||||
}, [currentChat]);
|
||||
|
||||
return (
|
||||
<ChatProvider>
|
||||
<div className="flex items-start w-full">
|
||||
<ChatSidebar />
|
||||
<div className="flex-1">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
</ChatProvider>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-screen">
|
||||
{!sidePanelCollapsed && (
|
||||
<>
|
||||
<ResizablePanel
|
||||
order={1}
|
||||
id="chat-side-panel"
|
||||
className="bg-muted/30"
|
||||
collapsible={true}
|
||||
defaultSize={20}
|
||||
minSize={15}
|
||||
maxSize={50}
|
||||
onCollapse={() => setSidePanelCollapsed(true)}
|
||||
>
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<ChatAgents
|
||||
chatAgents={chatAgents}
|
||||
fetchChatAgents={fetchChatAgents}
|
||||
currentChatAgent={currentChatAgent}
|
||||
setCurrentChatAgent={setCurrentChatAgent}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={30}>
|
||||
<ChatList
|
||||
chats={chats}
|
||||
chatAgent={currentChatAgent}
|
||||
currentChat={currentChat}
|
||||
setCurrentChat={handleSelectChat}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
</>
|
||||
)}
|
||||
<ResizablePanel id="chat-session-panel" order={2} minSize={50}>
|
||||
<ChatSession
|
||||
chatId={currentChat?.id}
|
||||
sidePanelCollapsed={sidePanelCollapsed}
|
||||
toggleSidePanel={() => setSidePanelCollapsed(!sidePanelCollapsed)}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
const { currentEngine } = useContext(AISettingsProviderContext);
|
||||
const { currentGptEngine } = useContext(AISettingsProviderContext);
|
||||
const [conversations, dispatchConversations] = useReducer(
|
||||
conversationsReducer,
|
||||
[]
|
||||
@@ -42,12 +42,13 @@ export default () => {
|
||||
ttsPreset: {
|
||||
key: "tts",
|
||||
name: "TTS",
|
||||
engine: currentEngine?.name,
|
||||
engine: currentGptEngine?.name,
|
||||
configuration: {
|
||||
type: "tts",
|
||||
tts: {
|
||||
engine: currentEngine?.name,
|
||||
model: currentEngine?.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
engine: currentGptEngine?.name,
|
||||
model:
|
||||
currentGptEngine?.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
@@ -133,27 +134,27 @@ export default () => {
|
||||
let presets = GPT_PRESETS;
|
||||
let defaultGptPreset = {
|
||||
key: "custom",
|
||||
engine: currentEngine.name,
|
||||
engine: currentGptEngine.name,
|
||||
name: t("custom"),
|
||||
configuration: {
|
||||
type: "gpt",
|
||||
engine: currentEngine.name,
|
||||
model: currentEngine.models.default,
|
||||
engine: currentGptEngine.name,
|
||||
model: currentGptEngine.models.default,
|
||||
tts: {
|
||||
engine: currentEngine.name,
|
||||
model: currentEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
engine: currentGptEngine.name,
|
||||
model: currentGptEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
let defaultTtsPreset = {
|
||||
key: "tts",
|
||||
name: "TTS",
|
||||
engine: currentEngine.name,
|
||||
engine: currentGptEngine.name,
|
||||
configuration: {
|
||||
type: "tts",
|
||||
tts: {
|
||||
engine: currentEngine.name,
|
||||
model: currentEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
engine: currentGptEngine.name,
|
||||
model: currentGptEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
@@ -168,16 +169,16 @@ export default () => {
|
||||
presets = [...gptPresets];
|
||||
}
|
||||
|
||||
if (defaultGpt.engine === currentEngine.name) {
|
||||
if (defaultGpt.engine === currentGptEngine.name) {
|
||||
defaultGpt.key = "custom";
|
||||
defaultGpt.name = t("custom");
|
||||
defaultGpt.configuration.model = currentEngine.models.default;
|
||||
defaultGpt.configuration.tts.engine = currentEngine.name;
|
||||
defaultGpt.configuration.model = currentGptEngine.models.default;
|
||||
defaultGpt.configuration.tts.engine = currentGptEngine.name;
|
||||
|
||||
defaultGptPreset = defaultGpt;
|
||||
}
|
||||
|
||||
if (defaultTts.engine === currentEngine.name) {
|
||||
if (defaultTts.engine === currentGptEngine.name) {
|
||||
defaultTtsPreset = defaultTts;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -186,14 +187,15 @@ export default () => {
|
||||
|
||||
const gptPresets = presets.map((preset) =>
|
||||
Object.assign({}, preset, {
|
||||
engine: currentEngine?.name,
|
||||
engine: currentGptEngine?.name,
|
||||
configuration: {
|
||||
...preset.configuration,
|
||||
model: currentEngine.models.default,
|
||||
model: currentGptEngine.models.default,
|
||||
tts: {
|
||||
...preset.configuration.tts,
|
||||
engine: currentEngine.name,
|
||||
model: currentEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
engine: currentGptEngine.name,
|
||||
model:
|
||||
currentGptEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -208,7 +210,7 @@ export default () => {
|
||||
|
||||
useEffect(() => {
|
||||
preparePresets();
|
||||
}, [currentEngine]);
|
||||
}, [currentGptEngine]);
|
||||
|
||||
return (
|
||||
<div className="h-full px-4 py-6 lg:px-8 flex flex-col">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default () => {
|
||||
<div className="h-screen px-4 py-6 lg:px-8">
|
||||
<PagePlaceholder
|
||||
placeholder={t("somethingWentWrong")}
|
||||
extra={error ? JSON.stringify(error) : ""}
|
||||
extra={error ? (error as Error).message : ""}
|
||||
showBackButton
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import semver from "semver";
|
||||
import { DOWNLOAD_URL } from "@/constants";
|
||||
|
||||
export default () => {
|
||||
const [channels, setChannels] = useState<string[]>([
|
||||
@@ -32,7 +33,7 @@ export default () => {
|
||||
}, [webApi]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="w-full relative">
|
||||
<AuthorizationStatusBar />
|
||||
<UpgradeNotice />
|
||||
<div className="max-w-5xl mx-auto px-4 py-6 lg:px-8">
|
||||
@@ -57,7 +58,7 @@ const AuthorizationStatusBar = () => {
|
||||
|
||||
if (!user.accessToken) {
|
||||
return (
|
||||
<div className="bg-destructive text-white py-2 px-4 h-10 flex items-center sticky top-0">
|
||||
<div className="bg-destructive text-white py-2 px-4 h-10 flex items-center sticky top-0 z-10">
|
||||
<span className="text-sm">{t("authorizationExpired")}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -93,9 +94,7 @@ const UpgradeNotice = () => {
|
||||
size="sm"
|
||||
className="ml-2 py-1 px-2 text-xs h-auto w-auto"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openExternal(
|
||||
"https://1000h.org/enjoy-app/install.html"
|
||||
);
|
||||
EnjoyApp.shell.openExternal(DOWNLOAD_URL);
|
||||
}}
|
||||
>
|
||||
{t("upgrade")}
|
||||
|
||||
43
enjoy/src/renderer/reducers/chat-members-reducer.ts
Normal file
43
enjoy/src/renderer/reducers/chat-members-reducer.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const chatMembersReducer = (
|
||||
chatMembers: ChatMemberType[],
|
||||
action: {
|
||||
type: "append" | "prepend" | "update" | "remove" | "set";
|
||||
record?: ChatMemberType;
|
||||
records?: ChatMemberType[];
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "append": {
|
||||
if (action.record) {
|
||||
return [...chatMembers, action.record];
|
||||
} else if (action.records) {
|
||||
return [...chatMembers, ...action.records];
|
||||
} else {
|
||||
return chatMembers;
|
||||
}
|
||||
}
|
||||
case "prepend": {
|
||||
return [action.record, ...chatMembers];
|
||||
}
|
||||
case "update": {
|
||||
return chatMembers.map((chatMember) => {
|
||||
if (chatMember.id === action.record.id) {
|
||||
return Object.assign(chatMember, action.record);
|
||||
} else {
|
||||
return chatMember;
|
||||
}
|
||||
});
|
||||
}
|
||||
case "remove": {
|
||||
return chatMembers.filter(
|
||||
(chatMember) => chatMember.id !== action.record.id
|
||||
);
|
||||
}
|
||||
case "set": {
|
||||
return action.records || [];
|
||||
}
|
||||
default: {
|
||||
throw Error(`Unknown action: ${action.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -9,6 +9,9 @@ export const chatMessagesReducer = (
|
||||
switch (action.type) {
|
||||
case "append": {
|
||||
if (action.record) {
|
||||
if (chatMessages.find((m) => m.id === action.record.id)) {
|
||||
return chatMessages;
|
||||
}
|
||||
return [...chatMessages, action.record];
|
||||
} else if (action.records) {
|
||||
return [...chatMessages, ...action.records];
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./messages-reducer";
|
||||
export * from "./llm-messages-reducer";
|
||||
export * from "./recordings-reducer";
|
||||
export * from "./videos-reducer";
|
||||
export * from "./chat-members-reducer";
|
||||
|
||||
103
enjoy/src/types/chat.d.ts
vendored
103
enjoy/src/types/chat.d.ts
vendored
@@ -1,34 +1,30 @@
|
||||
type ChatType = {
|
||||
id: string;
|
||||
type: ChatTypeEnum;
|
||||
name: string;
|
||||
topic: string;
|
||||
language: string;
|
||||
config: {
|
||||
sttEngine: WhisperConfigType["service"];
|
||||
sttEngine: SttEngineOptionEnum;
|
||||
prompt?: string;
|
||||
enableChatAssistant?: boolean;
|
||||
enableAutoTts?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
digest?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
membersCount: number;
|
||||
sessions: ChatSessionType[];
|
||||
members: ChatMemberType[];
|
||||
};
|
||||
|
||||
type ChatAgentType = {
|
||||
id: string;
|
||||
type: ChatAgentTypeEnum;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
introduction: string;
|
||||
language: string;
|
||||
description: string;
|
||||
source?: string;
|
||||
prompt?: string;
|
||||
config: {
|
||||
engine: "enjoyai" | "openai";
|
||||
model: string;
|
||||
prompt: string;
|
||||
temperature?: number;
|
||||
ttsEngine: "enjoyai" | "openai";
|
||||
ttsModel: string;
|
||||
ttsVoice: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
@@ -37,26 +33,93 @@ type ChatMemberType = {
|
||||
id: string;
|
||||
chatId: string;
|
||||
userId: string;
|
||||
userType: "Agent" | "User";
|
||||
name: string;
|
||||
userType: "ChatAgent";
|
||||
config: {
|
||||
prompt?: string;
|
||||
introduction?: string;
|
||||
description?: string;
|
||||
gpt?: GptConfigType;
|
||||
tts?: TtsConfigType;
|
||||
[key: string]: any;
|
||||
};
|
||||
name: string;
|
||||
agent?: ChatAgentType;
|
||||
user?: UserType;
|
||||
agent: ChatAgentType;
|
||||
};
|
||||
|
||||
type ChatMessageType = {
|
||||
id: string;
|
||||
memberId: string;
|
||||
role: ChatMessageRoleEnum;
|
||||
category: ChatMessageCategoryEnum;
|
||||
memberId: string | null;
|
||||
chatId: string;
|
||||
content: string;
|
||||
state: "pending" | "completed";
|
||||
state: ChatMessageStateEnum;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
member?: ChatMemberType;
|
||||
agent?: ChatAgentType;
|
||||
recording?: RecordingType;
|
||||
speech?: SpeechType;
|
||||
};
|
||||
|
||||
type GptConfigType = {
|
||||
engine: string;
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxCompletionTokens?: number;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
numberOfChoices?: number;
|
||||
historyBufferSize?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type TtsConfigType = {
|
||||
engine: string;
|
||||
model: string;
|
||||
language: string;
|
||||
voice: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type ChatDtoType = {
|
||||
type?: ChatTypeEnum;
|
||||
name: string;
|
||||
config: {
|
||||
sttEngine: string;
|
||||
prompt?: string;
|
||||
enableChatAssistant?: boolean;
|
||||
enableAutoTts?: boolean;
|
||||
};
|
||||
members?: Array<ChatMemberDtoType>;
|
||||
};
|
||||
|
||||
type ChatMemberDtoType = {
|
||||
chatId?: string;
|
||||
userId: string;
|
||||
userType: "ChatAgent";
|
||||
config: {
|
||||
prompt?: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
gpt?: GptConfigType;
|
||||
tts?: TtsConfigType;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
type ChatMessageDtoType = {
|
||||
state?: ChatMessageStateEnum;
|
||||
content?: string;
|
||||
recordingUrl?: string;
|
||||
};
|
||||
|
||||
type ChatAgentDtoType = {
|
||||
type: ChatAgentTypeEnum;
|
||||
avatarUrl?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
config: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
|
||||
8
enjoy/src/types/enjoy-app.d.ts
vendored
8
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -236,6 +236,7 @@ type EnjoyAppType = {
|
||||
create: (params: any) => Promise<ConversationType>;
|
||||
update: (id: string, params: any) => Promise<ConversationType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
migrate: (id: string) => Promise<void>;
|
||||
};
|
||||
messages: {
|
||||
findAll: (params: any) => Promise<MessageType[]>;
|
||||
@@ -387,6 +388,13 @@ type EnjoyAppType = {
|
||||
update: (id: string, params: any) => Promise<ChatAgentType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
};
|
||||
chatMembers: {
|
||||
findAll: (params: any) => Promise<ChatMemberType[]>;
|
||||
findOne: (params: any) => Promise<ChatMemberType>;
|
||||
create: (params: any) => Promise<ChatMemberType>;
|
||||
update: (id: string, params: any) => Promise<ChatMemberType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
};
|
||||
chatMessages: {
|
||||
findAll: (params: any) => Promise<ChatMessageType[]>;
|
||||
findOne: (params: any) => Promise<ChatMessageType>;
|
||||
|
||||
@@ -25,3 +25,32 @@ export enum AppSettingsKeyEnum {
|
||||
USER = "user",
|
||||
API_URL = "api_url",
|
||||
}
|
||||
|
||||
export enum ChatTypeEnum {
|
||||
CONVERSATION = "CONVERSATION",
|
||||
GROUP = "GROUP",
|
||||
TTS = "TTS",
|
||||
}
|
||||
|
||||
export enum ChatAgentTypeEnum {
|
||||
GPT = "GPT",
|
||||
TTS = "TTS",
|
||||
}
|
||||
|
||||
export enum ChatMessageRoleEnum {
|
||||
USER = "USER",
|
||||
AGENT = "AGENT",
|
||||
SYSTEM = "SYSTEM",
|
||||
}
|
||||
|
||||
export enum ChatMessageCategoryEnum {
|
||||
DEFAULT = "DEFAULT",
|
||||
MEMBER_JOINED = "MEMBER_JOINED",
|
||||
MEMBER_LEFT = "MEMBER_LEFT",
|
||||
CONTEXT_BREAK = "CONTEXT_BREAK",
|
||||
}
|
||||
|
||||
export enum ChatMessageStateEnum {
|
||||
PENDING = "pending",
|
||||
COMPLETED = "completed",
|
||||
}
|
||||
|
||||
9
enjoy/src/types/index.d.ts
vendored
9
enjoy/src/types/index.d.ts
vendored
@@ -193,6 +193,15 @@ type GptEngineSettingType = {
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type TtsEngineSettingType = {
|
||||
name: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
language?: string;
|
||||
baseUrl?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type PlatformInfo = {
|
||||
platform: string;
|
||||
arch: string;
|
||||
|
||||
@@ -13,6 +13,10 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontSize: {
|
||||
xxs: "0.625rem",
|
||||
xxxs: "0.5rem",
|
||||
},
|
||||
fontFamily: {
|
||||
code: ["CharisSIL", ...defaultTheme.fontFamily.mono],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user