From 838ed1e601ea4fdb069a1f4a4718bcfa51e2c53d Mon Sep 17 00:00:00 2001 From: an-lee Date: Tue, 14 May 2024 23:40:18 +0800 Subject: [PATCH] Improve conversation (#609) * fix conversation form * refactor conversation list --- enjoy/src/i18n/en.json | 3 + enjoy/src/i18n/zh-CN.json | 6 +- .../conversations/conversation-card.tsx | 36 +++++ .../conversations/conversation-form.tsx | 138 +++++++++++------- .../conversations/conversation-shortcuts.tsx | 53 ++----- .../conversations/conversations-list.tsx | 115 --------------- .../components/conversations/index.ts | 1 + enjoy/src/renderer/pages/conversations.tsx | 81 ++++++---- .../reducers/conversations-reducer.ts | 9 +- 9 files changed, 204 insertions(+), 238 deletions(-) create mode 100644 enjoy/src/renderer/components/conversations/conversation-card.tsx delete mode 100644 enjoy/src/renderer/components/conversations/conversations-list.tsx diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 36d5f560..1ca078c1 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -401,6 +401,9 @@ "selectScenario": "Select scenario", "selectAiEngine": "Select AI engine", "selectAiModel": "Select AI model", + "selectTtsEngine": "Select TTS engine", + "selectTtsModel": "Select TTS model", + "selectTtsVoice": "Select TTS voice", "youNeedToSetupApiKeyBeforeUsingOpenAI": "You need to setup API key before using OpenAI", "ensureYouHaveOllamaRunningLocallyAndHasAtLeastOneModel": "Ensure you have Ollama running locally and has at least one model", "creatingSpeech": "Speech is creating", diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index ef6c2120..23f89ef9 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -188,6 +188,7 @@ "confirm": "确认", "continue": "继续", "save": "保存", + "delete": "删除", "edit": "修改", "retry": "重试", "failedToLogin": "登录失败", @@ -200,7 +201,6 @@ "inputMixinId": "请输入您的 Mixin ID", "dontHaveMixinAccount": "没有 Mixin 账号?", "youCanAlsoLoginWith": "您也可以使用以下方式登录", - "delete": "删除", "transcribe": "语音转文本", "stillTranscribing": "语音转文本仍在进行中,请耐心等候。", "unableToSetLibraryPath": "无法设置资源库保存路径 {{path}}", @@ -332,6 +332,7 @@ "accountSettings": "账户设置", "advancedSettingsShort": "高级设置", "advancedSettings": "高级设置", + "advanced": "高级设置", "language": "语言", "editEmail": "修改邮箱地址", "editUserName": "修改用户名", @@ -400,6 +401,9 @@ "selectScenario": "选择场景", "selectAiEngine": "选择 AI 引擎", "selectAiModel": "选择 AI 模型", + "selectTtsEngine": "选择 TTS 引擎", + "selectTtsModel": "选择 TTS 模型", + "selectTtsVoice": "选择 TTS 角色", "youNeedToSetupApiKeyBeforeUsingOpenAI": "在使用 OpenAI 之前您需要设置 API 密钥", "ensureYouHaveOllamaRunningLocallyAndHasAtLeastOneModel": "确保您已经在本地运行 Ollama 并且至少有一个模型", "creatingSpeech": "正在生成语音", diff --git a/enjoy/src/renderer/components/conversations/conversation-card.tsx b/enjoy/src/renderer/components/conversations/conversation-card.tsx new file mode 100644 index 00000000..ec61d81b --- /dev/null +++ b/enjoy/src/renderer/components/conversations/conversation-card.tsx @@ -0,0 +1,36 @@ +import { MessageCircleIcon, SpeechIcon } from "lucide-react"; +import dayjs from "dayjs"; + +export const ConversationCard = (props: { conversation: ConversationType }) => { + const { conversation } = props; + + return ( +
+
+ {conversation.type === "gpt" && } + + {conversation.type === "tts" && } +
+
+
+
{conversation.name}
+
+ {conversation.engine} /{" "} + {conversation.type === "tts" + ? conversation.configuration?.tts?.model + : conversation.model} +
+
+ + {dayjs(conversation.createdAt).format("HH:mm l")} + +
+
+ ); +}; diff --git a/enjoy/src/renderer/components/conversations/conversation-form.tsx b/enjoy/src/renderer/components/conversations/conversation-form.tsx index f92b7768..5af528ae 100644 --- a/enjoy/src/renderer/components/conversations/conversation-form.tsx +++ b/enjoy/src/renderer/components/conversations/conversation-form.tsx @@ -28,6 +28,7 @@ import { SelectContent, SelectItem, Textarea, + toast, } from "@renderer/components/ui"; import { useState, useEffect, useContext } from "react"; import { @@ -47,28 +48,24 @@ const conversationFormSchema = z.object({ engine: z .enum(["enjoyai", "openai", "ollama", "googleGenerativeAi"]) .default("openai"), - configuration: z - .object({ - type: z.enum(["gpt", "tts"]), - model: z.string().optional(), + configuration: z.object({ + type: z.enum(["gpt", "tts"]), + model: z.string().optional(), + baseUrl: z.string().optional(), + roleDefinition: z.string().optional(), + temperature: z.number().min(0).max(1).default(0.2), + numberOfChoices: z.number().min(1).default(1), + maxTokens: z.number().min(-1).default(2000), + presencePenalty: z.number().min(-2).max(2).default(0), + frequencyPenalty: z.number().min(-2).max(2).default(0), + historyBufferSize: z.number().min(0).default(10), + tts: z.object({ + engine: z.enum(["openai", "enjoyai"]).default("enjoyai"), + model: z.string().default("tts-1"), + voice: z.string(), baseUrl: z.string().optional(), - roleDefinition: z.string().optional(), - temperature: z.number().min(0).max(1).default(0.2), - numberOfChoices: z.number().min(1).default(1), - maxTokens: z.number().min(-1).default(2000), - presencePenalty: z.number().min(-2).max(2).default(0), - frequencyPenalty: z.number().min(-2).max(2).default(0), - historyBufferSize: z.number().min(0).default(10), - tts: z - .object({ - engine: z.enum(["openai", "enjoyai"]).default("openai"), - model: z.string().default("tts-1"), - voice: z.string().optional(), - baseUrl: z.string().optional(), - }) - .optional(), - }) - .optional(), + }), + }), }); export const ConversationForm = (props: { @@ -77,8 +74,8 @@ export const ConversationForm = (props: { }) => { const { conversation, onFinish } = props; const [submitting, setSubmitting] = useState(false); - const [gptProviders, setGptProviders] = useState(GPT_PROVIDERS); - const [ttsProviders, setTtsProviders] = useState(TTS_PROVIDERS); + const [gptProviders, setGptProviders] = useState([]); + const [ttsProviders, setTtsProviders] = useState([]); const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext); const { openai } = useContext(AISettingsProviderContext); const navigate = useNavigate(); @@ -132,6 +129,7 @@ export const ConversationForm = (props: { }, []); const defaultConfig = JSON.parse(JSON.stringify(conversation || {})); + if (defaultConfig.engine === "openai" && openai) { if (!defaultConfig.configuration) { defaultConfig.configuration = {}; @@ -172,31 +170,15 @@ export const ConversationForm = (props: { }); const onSubmit = async (data: z.infer) => { - const { name, engine, configuration } = data; + let { name, engine, configuration } = data; setSubmitting(true); - Object.keys(configuration).forEach((key) => { - if (key === "type") return; - - if (!GPT_PROVIDERS[engine]?.configurable.includes(key)) { - // @ts-ignore - delete configuration[key]; - } - }); - - if (configuration.type === "tts") { - conversation.model = configuration.tts.model; - } - - // use default base url if not set - if (!configuration.baseUrl) { - configuration.baseUrl = GPT_PROVIDERS[engine]?.baseUrl; - } - - // use default base url if not set - if (!configuration?.tts?.baseUrl) { - configuration.tts ||= {}; - configuration.tts.baseUrl = GPT_PROVIDERS[engine]?.baseUrl; + try { + configuration = validateConfiguration(data); + } catch (e) { + toast.error(e.message); + setSubmitting(false); + return; } if (conversation?.id) { @@ -227,6 +209,54 @@ export const ConversationForm = (props: { } }; + const validateConfiguration = ( + data: z.infer + ) => { + const { engine, configuration } = data; + + Object.keys(configuration).forEach((key) => { + if (key === "type") return; + + if ( + configuration.type === "gpt" && + !gptProviders[engine]?.configurable.includes(key) + ) { + // @ts-ignore + delete configuration[key]; + } + + if ( + configuration.type === "tts" && + !ttsProviders[engine]?.configurable.includes(key) + ) { + // @ts-ignore + delete configuration.tts[key]; + } + }); + + if (configuration.type === "tts") { + if (!configuration.tts?.engine) { + throw new Error(t("models.conversation.ttsEngineRequired")); + } + if (!configuration.tts?.model) { + throw new Error(t("models.conversation.ttsModelRequired")); + } + } + + // use default base url if not set + if (!configuration.baseUrl) { + configuration.baseUrl = gptProviders[engine]?.baseUrl; + } + + // use default base url if not set + if (!configuration?.tts?.baseUrl) { + configuration.tts ||= {}; + configuration.tts.baseUrl = gptProviders[engine]?.baseUrl; + } + + return configuration; + }; + return (
- {GPT_PROVIDERS[form.watch("engine")]?.configurable.includes( + {gptProviders[form.watch("engine")]?.configurable.includes( "temperature" ) && ( )} - {GPT_PROVIDERS[form.watch("engine")]?.configurable.includes( + {gptProviders[form.watch("engine")]?.configurable.includes( "maxTokens" ) && ( )} - {GPT_PROVIDERS[form.watch("engine")]?.configurable.includes( + {gptProviders[form.watch("engine")]?.configurable.includes( "presencePenalty" ) && ( )} - {GPT_PROVIDERS[form.watch("engine")]?.configurable.includes( + {gptProviders[form.watch("engine")]?.configurable.includes( "frequencyPenalty" ) && ( )} - {GPT_PROVIDERS[form.watch("engine")]?.configurable.includes( + {gptProviders[form.watch("engine")]?.configurable.includes( "numberOfChoices" ) && ( - {GPT_PROVIDERS[form.watch("engine")]?.configurable.includes( + {gptProviders[form.watch("engine")]?.configurable.includes( "baseUrl" ) && ( ([]); const [loading, setLoading] = useState(false); - const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); const { chat } = useConversation(); const [replies, setReplies] = useState[]>([]); const navigate = useNavigate(); const fetchConversations = () => { - if (offset === -1) return; - const limit = 5; + setLoading(true); EnjoyApp.conversations .findAll({ order: [["updatedAt", "DESC"]], limit, - offset, + offset: conversations.length, }) .then((_conversations) => { if (_conversations.length === 0) { - setOffset(-1); + setHasMore(false); return; } if (_conversations.length < limit) { - setOffset(-1); + setHasMore(false); } else { - setOffset(offset + _conversations.length); + setHasMore(true); } - if (offset === 0) { + if (conversations.length === 0) { setConversations(_conversations); } else { setConversations([...conversations, ..._conversations]); @@ -99,7 +93,7 @@ export const ConversationShortcuts = (props: { useEffect(() => { fetchConversations(); - }, [excludedIds]); + }, []); const dialogContent = () => { if (loading) { @@ -151,37 +145,18 @@ export const ConversationShortcuts = (props: { .filter((c) => !excludedIds.includes(c.id)) .map((conversation) => { return ( -
ask(conversation)} - className="bg-background text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-muted hover:text-muted-foreground cursor-pointer flex items-center border" - style={{ - borderLeftColor: `#${conversation.id - .replaceAll("-", "") - .substr(0, 6)}`, - borderLeftWidth: 3, - }} - > -
- {conversation.type === "gpt" && ( - - )} - - {conversation.type === "tts" && ( - - )} -
-
{conversation.name}
+
ask(conversation)}> +
); })} - {offset > -1 && ( + {hasMore && (
-
- )} - - ); -}; diff --git a/enjoy/src/renderer/components/conversations/index.ts b/enjoy/src/renderer/components/conversations/index.ts index 45eb79b6..0c4576f4 100644 --- a/enjoy/src/renderer/components/conversations/index.ts +++ b/enjoy/src/renderer/components/conversations/index.ts @@ -1,3 +1,4 @@ +export * from "./conversation-card"; export * from "./conversation-form"; export * from "./conversation-shortcuts"; diff --git a/enjoy/src/renderer/pages/conversations.tsx b/enjoy/src/renderer/pages/conversations.tsx index 687f0872..4a189da7 100644 --- a/enjoy/src/renderer/pages/conversations.tsx +++ b/enjoy/src/renderer/pages/conversations.tsx @@ -9,10 +9,11 @@ import { Sheet, SheetContent, ScrollArea, + toast, } from "@renderer/components/ui"; -import { ConversationForm } from "@renderer/components"; +import { ConversationCard, ConversationForm } from "@renderer/components"; import { useState, useEffect, useContext, useReducer } from "react"; -import { ChevronLeftIcon, MessageCircleIcon, SpeechIcon } from "lucide-react"; +import { ChevronLeftIcon, LoaderIcon } from "lucide-react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { DbProviderContext, @@ -20,8 +21,8 @@ import { AISettingsProviderContext, } from "@renderer/context"; import { conversationsReducer } from "@renderer/reducers"; -import dayjs from "dayjs"; import { CONVERSATION_PRESETS } from "@/constants"; +import { set } from "lodash"; export default () => { const [searchParams] = useSearchParams(); @@ -39,6 +40,8 @@ export default () => { conversationsReducer, [] ); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); useEffect(() => { @@ -66,9 +69,39 @@ export default () => { }, [searchParams.get("postId")]); const fetchConversations = async () => { - const _conversations = await EnjoyApp.conversations.findAll({}); + const limit = 10; - dispatchConversations({ type: "set", records: _conversations }); + setLoading(true); + EnjoyApp.conversations + .findAll({ + order: [["updatedAt", "DESC"]], + limit, + offset: conversations.length, + }) + .then((_conversations) => { + if (_conversations.length === 0) { + setHasMore(false); + return; + } + + if (_conversations.length < limit) { + setHasMore(false); + } else { + setHasMore(true); + } + + if (conversations.length === 0) { + dispatchConversations({ type: "set", records: _conversations }); + } else { + dispatchConversations({ type: "append", records: _conversations }); + } + }) + .catch((error) => { + toast.error(error.message); + }) + .finally(() => { + setLoading(false); + }); }; const onConversationsUpdate = (event: CustomEvent) => { @@ -253,31 +286,23 @@ export default () => { {conversations.map((conversation) => ( -
-
- {conversation.type === "gpt" && ( - - )} - - {conversation.type === "tts" && } -
-
- {conversation.name} - - {dayjs(conversation.createdAt).format("HH:mm L")} - -
-
+ ))} + + {hasMore && ( +
+ +
+ )}
); diff --git a/enjoy/src/renderer/reducers/conversations-reducer.ts b/enjoy/src/renderer/reducers/conversations-reducer.ts index 22100576..40df75f0 100644 --- a/enjoy/src/renderer/reducers/conversations-reducer.ts +++ b/enjoy/src/renderer/reducers/conversations-reducer.ts @@ -1,12 +1,19 @@ export const conversationsReducer = ( state: ConversationType[], action: { - type: "create" | "update" | "destroy" | "set"; + type: "append" | "create" | "update" | "destroy" | "set"; record?: ConversationType; records?: ConversationType[]; } ) => { switch (action.type) { + case "append": { + if (action.record) { + return [...state, action.record]; + } else if (action.records) { + return [...state, ...action.records]; + } + } case "create": { return [action.record, ...state]; }