Improve conversation (#609)

* fix conversation form

* refactor conversation list
This commit is contained in:
an-lee
2024-05-14 23:40:18 +08:00
committed by GitHub
parent e090ccadf9
commit 838ed1e601
9 changed files with 204 additions and 238 deletions

View File

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

View File

@@ -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": "正在生成语音",

View File

@@ -0,0 +1,36 @@
import { MessageCircleIcon, SpeechIcon } from "lucide-react";
import dayjs from "dayjs";
export const ConversationCard = (props: { conversation: ConversationType }) => {
const { conversation } = props;
return (
<div
className="bg-background hover:bg-muted hover:text-muted-foreground border rounded-full w-full mb-2 px-4 py-2 cursor-pointer flex items-center"
style={{
borderLeftColor: `#${conversation.id.replaceAll("-", "").slice(0, 6)}`,
borderLeftWidth: 3,
}}
>
<div className="">
{conversation.type === "gpt" && <MessageCircleIcon className="mr-2" />}
{conversation.type === "tts" && <SpeechIcon className="mr-2" />}
</div>
<div className="flex-1 flex items-center justify-between space-x-4">
<div className="">
<div className="line-clamp-1 text-sm">{conversation.name}</div>
<div className="text-xs text-muted-foreground">
{conversation.engine} /{" "}
{conversation.type === "tts"
? conversation.configuration?.tts?.model
: conversation.model}
</div>
</div>
<span className="min-w-fit text-sm text-muted-foreground">
{dayjs(conversation.createdAt).format("HH:mm l")}
</span>
</div>
</div>
);
};

View File

@@ -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<boolean>(false);
const [gptProviders, setGptProviders] = useState<any>(GPT_PROVIDERS);
const [ttsProviders, setTtsProviders] = useState<any>(TTS_PROVIDERS);
const [gptProviders, setGptProviders] = useState<any>([]);
const [ttsProviders, setTtsProviders] = useState<any>([]);
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<typeof conversationFormSchema>) => {
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<typeof conversationFormSchema>
) => {
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 (
<Form {...form}>
<form
@@ -367,7 +397,7 @@ export const ConversationForm = (props: {
)}
/>
{GPT_PROVIDERS[form.watch("engine")]?.configurable.includes(
{gptProviders[form.watch("engine")]?.configurable.includes(
"temperature"
) && (
<FormField
@@ -401,7 +431,7 @@ export const ConversationForm = (props: {
/>
)}
{GPT_PROVIDERS[form.watch("engine")]?.configurable.includes(
{gptProviders[form.watch("engine")]?.configurable.includes(
"maxTokens"
) && (
<FormField
@@ -430,7 +460,7 @@ export const ConversationForm = (props: {
/>
)}
{GPT_PROVIDERS[form.watch("engine")]?.configurable.includes(
{gptProviders[form.watch("engine")]?.configurable.includes(
"presencePenalty"
) && (
<FormField
@@ -461,7 +491,7 @@ export const ConversationForm = (props: {
/>
)}
{GPT_PROVIDERS[form.watch("engine")]?.configurable.includes(
{gptProviders[form.watch("engine")]?.configurable.includes(
"frequencyPenalty"
) && (
<FormField
@@ -492,7 +522,7 @@ export const ConversationForm = (props: {
/>
)}
{GPT_PROVIDERS[form.watch("engine")]?.configurable.includes(
{gptProviders[form.watch("engine")]?.configurable.includes(
"numberOfChoices"
) && (
<FormField
@@ -555,7 +585,7 @@ export const ConversationForm = (props: {
)}
/>
{GPT_PROVIDERS[form.watch("engine")]?.configurable.includes(
{gptProviders[form.watch("engine")]?.configurable.includes(
"baseUrl"
) && (
<FormField
@@ -611,7 +641,7 @@ export const ConversationForm = (props: {
{ttsProviders[
form.watch("configuration.tts.engine")
]?.configurable.includes("model") && (
]?.configurable?.includes("model") && (
<FormField
control={form.control}
name="configuration.tts.model"
@@ -647,7 +677,7 @@ export const ConversationForm = (props: {
{ttsProviders[
form.watch("configuration.tts.engine")
]?.configurable.includes("voice") && (
]?.configurable?.includes("voice") && (
<FormField
control={form.control}
name="configuration.tts.voice"

View File

@@ -11,13 +11,8 @@ import {
ScrollArea,
toast,
} from "@renderer/components/ui";
import { LoaderSpin } from "@renderer/components";
import {
MessageCircleIcon,
LoaderIcon,
SpeechIcon,
CheckCircleIcon,
} from "lucide-react";
import { ConversationCard, LoaderSpin } from "@renderer/components";
import { LoaderIcon, CheckCircleIcon } from "lucide-react";
import { t } from "i18next";
import { useConversation } from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
@@ -43,35 +38,34 @@ export const ConversationShortcuts = (props: {
} = props;
const [conversations, setConversations] = useState<ConversationType[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [offset, setOffset] = useState<number>(0);
const [hasMore, setHasMore] = useState<boolean>(false);
const { chat } = useConversation();
const [replies, setReplies] = useState<Partial<MessageType>[]>([]);
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 (
<div
key={conversation.id}
onClick={() => 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,
}}
>
<div className="">
{conversation.type === "gpt" && (
<MessageCircleIcon className="mr-2" />
)}
{conversation.type === "tts" && (
<SpeechIcon className="mr-2" />
)}
</div>
<div className="flex-1 truncated">{conversation.name}</div>
<div key={conversation.id} onClick={() => ask(conversation)}>
<ConversationCard conversation={conversation} />
</div>
);
})}
{offset > -1 && (
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => fetchConversations()}
disabled={loading || offset === -1}
disabled={loading || !hasMore}
className="px-4 py-2"
>
{t("loadMore")}

View File

@@ -1,115 +0,0 @@
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { Button, ScrollArea } from "@renderer/components/ui";
import { LoaderSpin } from "@renderer/components";
import { MessageCircleIcon, LoaderIcon, SpeechIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
export const ConversationsList = (props: {
prompt: string;
excludedIds?: string[];
}) => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { prompt, excludedIds = [] } = props;
const [conversations, setConversations] = useState<ConversationType[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [offset, setOffset] = useState<number>(0);
const navigate = useNavigate();
const fetchConversations = () => {
if (offset === -1) return;
const limit = 5;
setLoading(true);
EnjoyApp.conversations
.findAll({
order: [["updatedAt", "DESC"]],
limit,
offset,
})
.then((_conversations) => {
if (_conversations.length === 0) {
setOffset(-1);
return;
}
if (_conversations.length < limit) {
setOffset(-1);
} else {
setOffset(offset + _conversations.length);
}
if (offset === 0) {
setConversations(_conversations);
} else {
setConversations([...conversations, ..._conversations]);
}
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
fetchConversations();
}, []);
if (loading) {
return <LoaderSpin />;
}
return (
<ScrollArea>
{conversations.filter((c) => !excludedIds.includes(c.id)).length ===
0 && (
<div className="text-center text-sm text-muted-foreground py-4">
{t("noConversationsYet")}
</div>
)}
{conversations
.filter((c) => !excludedIds.includes(c.id))
.map((conversation) => {
return (
<div
key={conversation.id}
onClick={() => {
navigate(`/conversations/${conversation.id}?text=${prompt}`);
}}
className="bg-background text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border"
style={{
borderLeftColor: `#${conversation.id
.replaceAll("-", "")
.substr(0, 6)}`,
borderLeftWidth: 3,
}}
>
<div className="">
{conversation.type === "gpt" && (
<MessageCircleIcon className="mr-2" />
)}
{conversation.type === "tts" && <SpeechIcon className="mr-2" />}
</div>
<div className="flex-1 truncated">{conversation.name}</div>
</div>
);
})}
{offset > -1 && (
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => fetchConversations()}
disabled={loading || offset === -1}
className="px-4 py-2"
>
{t("loadMore")}
{loading && <LoaderIcon className="w-4 h-4 animate-spin ml-2" />}
</Button>
</div>
)}
</ScrollArea>
);
};

View File

@@ -1,3 +1,4 @@
export * from "./conversation-card";
export * from "./conversation-form";
export * from "./conversation-shortcuts";

View File

@@ -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<boolean>(false);
const [loading, setLoading] = useState<boolean>(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) => (
<Link key={conversation.id} to={`/conversations/${conversation.id}`}>
<div
className="bg-background hover:bg-muted hover:text-muted-foreground border rounded-full w-full mb-2 p-4 cursor-pointer flex items-center"
style={{
borderLeftColor: `#${conversation.id
.replaceAll("-", "")
.slice(0, 6)}`,
borderLeftWidth: 3,
}}
>
<div className="">
{conversation.type === "gpt" && (
<MessageCircleIcon className="mr-2" />
)}
{conversation.type === "tts" && <SpeechIcon className="mr-2" />}
</div>
<div className="flex-1 flex items-center justify-between space-x-4">
<span className="line-clamp-1">{conversation.name}</span>
<span className="min-w-fit">
{dayjs(conversation.createdAt).format("HH:mm L")}
</span>
</div>
</div>
<ConversationCard conversation={conversation} />
</Link>
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => fetchConversations()}
disabled={loading || !hasMore}
className="px-4 py-2"
>
{t("loadMore")}
{loading && <LoaderIcon className="w-4 h-4 animate-spin ml-2" />}
</Button>
</div>
)}
</div>
</div>
);

View File

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