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

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