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:
an-lee
2024-10-09 16:57:32 +08:00
committed by GitHub
parent 245a1ea461
commit d96c9ff773
96 changed files with 8387 additions and 4889 deletions

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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}`;

View File

@@ -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.";

View File

@@ -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",

View File

@@ -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."
}

View File

@@ -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}} 已离开聊天"
}

View File

@@ -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() {

View File

@@ -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");
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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");
}
}

View File

@@ -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() {

View File

@@ -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;

View 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 };

View File

@@ -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() });
}
});
}
}

View File

@@ -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(),
});
}
}

View File

@@ -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,

View File

@@ -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 } });
}

View File

@@ -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") {

View File

@@ -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;

View File

@@ -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 />

View 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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
);

View File

@@ -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>
</>
);
}
};

View 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>
)}
/>
</>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
</>
);
};

View 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>
);
};

View File

@@ -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>
);
}
};

View File

@@ -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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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>
)}
/>
</>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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>
);

View File

@@ -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>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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";

View File

@@ -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(() => {

View File

@@ -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";

View File

@@ -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);

View File

@@ -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"

View File

@@ -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"

View File

@@ -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(() => {

View File

@@ -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>
);
};

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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 || {},
},
});

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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(() => {

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -49,6 +49,7 @@ const defaultKeyMap = {
// system
QuitApp: `${ControlOrCommand}+Q`,
OpenPreferences: `${ControlOrCommand}+Comma`,
OpenCopilot: `${ControlOrCommand}+L`,
// player
PlayOrPause: "Space",
StartOrStopRecording: "R",

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,
}
);

View File

@@ -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) => {

View 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,
};
};

View File

@@ -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,
};
};

View 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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View 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,
};
};

View File

@@ -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>
) {

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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")}

View 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}`);
}
}
};

View File

@@ -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];

View File

@@ -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";

View File

@@ -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;
};
};

View File

@@ -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>;

View File

@@ -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",
}

View File

@@ -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;

View File

@@ -13,6 +13,10 @@ module.exports = {
},
},
extend: {
fontSize: {
xxs: "0.625rem",
xxxs: "0.5rem",
},
fontFamily: {
code: ["CharisSIL", ...defaultTheme.fontFamily.mono],
},

4142
yarn.lock

File diff suppressed because it is too large Load Diff