add enjoy app

This commit is contained in:
an-lee
2024-01-09 15:19:32 +08:00
parent b88c52d5d8
commit aebd9ee213
434 changed files with 34955 additions and 62 deletions

View File

@@ -0,0 +1,696 @@
import * as z from "zod";
import { t } from "i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
FormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
Input,
ScrollArea,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Textarea,
} from "@renderer/components/ui";
import { useState, useEffect, useContext } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { LoaderIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
const conversationFormSchema = z.object({
name: z.string().optional(),
engine: z.enum(["openai", "ollama", "googleGenerativeAi"]).default("openai"),
configuration: z
.object({
model: z.string().nonempty(),
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"]).default("openai"),
model: z.string().default("tts-1"),
voice: z.string().optional(),
})
.optional(),
})
.optional(),
});
export const ConversationForm = (props: {
conversation: Partial<ConversationType>;
onFinish?: () => void;
}) => {
const { conversation, onFinish } = props;
const [submitting, setSubmitting] = useState<boolean>(false);
const [providers, setProviders] = useState<any>(LLM_PROVIDERS);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const navigate = useNavigate();
const refreshProviders = async () => {
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.error(e);
}
setProviders({ ...providers });
};
const destroyConversation = async () => {
if (!conversation.id) return;
EnjoyApp.conversations.destroy(conversation.id).then(() => {
navigate(`/conversations`);
});
};
useEffect(() => {
refreshProviders();
}, []);
const form = useForm<z.infer<typeof conversationFormSchema>>({
resolver: zodResolver(conversationFormSchema),
// @ts-ignore
values: conversation?.id
? {
name: conversation.name,
engine: conversation.engine,
configuration: {
...conversation.configuration,
},
}
: {
name: conversationDefaultConfiguration.name,
engine: conversationDefaultConfiguration.engine,
configuration: {
...conversationDefaultConfiguration.configuration,
},
},
});
const onSubmit = async (data: z.infer<typeof conversationFormSchema>) => {
const { name, engine, configuration } = data;
setSubmitting(true);
Object.keys(configuration).forEach((key) => {
if (!LLM_PROVIDERS[engine]?.configurable.includes(key)) {
// @ts-ignore
delete configuration[key];
}
});
if (!configuration.baseUrl) {
configuration.baseUrl = LLM_PROVIDERS[engine]?.baseUrl;
}
if (conversation?.id) {
EnjoyApp.conversations
.update(conversation.id, {
name,
configuration,
})
.then(() => {
onFinish && onFinish();
})
.finally(() => {
setSubmitting(false);
});
} else {
EnjoyApp.conversations
.create({
name,
engine,
configuration,
})
.then(() => {
onFinish && onFinish();
})
.finally(() => {
setSubmitting(false);
});
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="h-full flex flex-col pt-6"
>
<div className="mb-4 px-6 text-lg font-bold">
{conversation.id ? t("editConversation") : t("startConversation")}
</div>
<ScrollArea className="flex-1 px-4">
<div className="space-y-4 px-2 mb-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.name")}</FormLabel>
<Input value={field.value} onChange={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="engine"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.engine")}</FormLabel>
<Select
disabled={Boolean(conversation?.id)}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("selectAiEngine")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(providers).map((key) => (
<SelectItem key={key} value={key}>
{providers[key].name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{providers[form.watch("engine")]?.description}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configuration.model"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.model")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("selectAiModel")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{(providers[form.watch("engine")]?.models || []).map(
(option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configuration.roleDefinition"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("models.conversation.roleDefinition")}
</FormLabel>
<Textarea className="h-64" {...field} />
<FormMessage />
</FormItem>
)}
/>
{LLM_PROVIDERS[form.watch("engine")]?.configurable.includes(
"temperature"
) && (
<FormField
control={form.control}
name="configuration.temperature"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("models.conversation.temperature")}
</FormLabel>
<Input
type="number"
min="0"
max="1.0"
step="0.1"
value={field.value}
onChange={(event) => {
field.onChange(
event.target.value
? parseFloat(event.target.value)
: 0.0
);
}}
/>
<FormDescription>
{t("models.conversation.temperatureDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{LLM_PROVIDERS[form.watch("engine")]?.configurable.includes(
"maxTokens"
) && (
<FormField
control={form.control}
name="configuration.maxTokens"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.maxTokens")}</FormLabel>
<Input
type="number"
min="0"
value={field.value}
onChange={(event) => {
if (!event.target.value) return;
field.onChange(parseInt(event.target.value));
}}
/>
<FormDescription>
{t("models.conversation.maxTokensDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{LLM_PROVIDERS[form.watch("engine")]?.configurable.includes(
"presencePenalty"
) && (
<FormField
control={form.control}
name="configuration.presencePenalty"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("models.conversation.presencePenalty")}
</FormLabel>
<Input
type="number"
min="-2"
step="0.1"
max="2"
value={field.value}
onChange={(event) => {
if (!event.target.value) return;
field.onChange(parseInt(event.target.value));
}}
/>
<FormDescription>
{t("models.conversation.presencePenaltyDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{LLM_PROVIDERS[form.watch("engine")]?.configurable.includes(
"frequencyPenalty"
) && (
<FormField
control={form.control}
name="configuration.frequencyPenalty"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("models.conversation.frequencyPenalty")}
</FormLabel>
<Input
type="number"
min="-2"
step="0.1"
max="2"
value={field.value}
onChange={(event) => {
if (!event.target.value) return;
field.onChange(parseInt(event.target.value));
}}
/>
<FormDescription>
{t("models.conversation.frequencyPenaltyDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{LLM_PROVIDERS[form.watch("engine")]?.configurable.includes(
"numberOfChoices"
) && (
<FormField
control={form.control}
name="configuration.numberOfChoices"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("models.conversation.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("models.conversation.numberOfChoicesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="configuration.historyBufferSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("models.conversation.historyBufferSize")}
</FormLabel>
<Input
type="number"
min="0"
step="1"
max="100"
value={field.value}
onChange={(event) => {
field.onChange(
event.target.value ? parseInt(event.target.value) : 0
);
}}
/>
<FormDescription>
{t("models.conversation.historyBufferSizeDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{LLM_PROVIDERS[form.watch("engine")]?.configurable.includes(
"baseUrl"
) && (
<FormField
control={form.control}
name="configuration.baseUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.baseUrl")}</FormLabel>
<Input {...field} />
<FormDescription>
{t("models.conversation.baseUrl")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="configuration.tts.engine"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.ttsEngine")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("selectTtsEngine")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(TTS_PROVIDERS).map((key) => (
<SelectItem key={key} value={key}>
{TTS_PROVIDERS[key].name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configuration.tts.model"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.ttsModel")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("selectTtsModel")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{(
TTS_PROVIDERS[form.watch("configuration.tts.engine")]
?.models || []
).map((model: string) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="configuration.tts.voice"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.conversation.ttsVoice")}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("selectTtsVoice")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{(
TTS_PROVIDERS[form.watch("configuration.tts.engine")]
?.voices || []
).map((voice: string) => (
<SelectItem key={voice} value={voice}>
<span className="capitalize">{voice}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</ScrollArea>
<div className="flex justify-center space-x-4 py-6 px-6 border-t shadow">
{conversation.id && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="w-full h-12 text-destructive"
size="lg"
variant="secondary"
>
{t("delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConversation")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("deleteConversationConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive-hover"
onClick={destroyConversation}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button
disabled={submitting || !form.formState.isDirty}
className="w-full h-12"
size="lg"
type="submit"
>
{submitting && <LoaderIcon className="mr-2 animate-spin" />}
{t("confirm")}
</Button>
</div>
</form>
</Form>
);
};
export const LLM_PROVIDERS: { [key: string]: any } = {
openai: {
name: "OpenAI",
description: t("youNeedToSetupApiKeyBeforeUsingOpenAI"),
baseUrl:
"https://gateway.ai.cloudflare.com/v1/11d43ab275eb7e1b271ba4089ecc3864/enjoy/openai",
models: [
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-instruct",
"gpt-4-1106-preview",
"gpt-4-vision-preview",
"gpt-4",
"gpt-4-32k",
"gpt-4-0613",
"gpt-4-32k-0613",
],
configurable: [
"model",
"baseUrl",
"roleDefinition",
"temperature",
"numberOfChoices",
"maxTokens",
"frequencyPenalty",
"presencePenalty",
"historyBufferSize",
"tts",
],
},
googleGenerativeAi: {
name: "Google Generative AI",
models: ["gemini-pro"],
configurable: [
"model",
"roleDefinition",
"temperature",
"maxTokens",
"historyBufferSize",
"tts",
],
},
ollama: {
name: "Ollama",
description: t("ensureYouHaveOllamaRunningLocallyAndHasAtLeastOneModel"),
baseUrl: "http://localhost:11434",
models: [],
configurable: [
"model",
"baseUrl",
"roleDefinition",
"temperature",
"maxTokens",
"historyBufferSize",
"frequencyPenalty",
"presencePenalty",
"tts",
],
},
};
export const TTS_PROVIDERS: { [key: string]: any } = {
openai: {
name: "OpenAI",
description: t("youNeedToSetupApiKeyBeforeUsingOpenAI"),
models: ["tts-1"],
voices: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"],
},
};
const conversationDefaultConfiguration = {
name: "英语教练",
engine: "openai",
configuration: {
model: "gpt-4-1106-preview",
roleDefinition: `你是我的英语教练。
请将我的话改写成英文。
不需要逐字翻译。
请分析清楚我的内容,而后用英文重新逻辑清晰地组织它。
请使用地道的美式英语,纽约腔调。
请尽量使用日常词汇,尽量优先使用短语动词或者习惯用语。
每个句子最长不应该超过 20 个单词。`,
temperature: 0.2,
numberOfChoices: 1,
maxTokens: 2048,
presencePenalty: 0,
frequencyPenalty: 0,
historyBufferSize: 10,
tts: {
engine: "openai",
model: "tts-1",
voice: "alloy",
},
},
};

View File

@@ -0,0 +1,4 @@
export * from './conversation-form';
export * from './speech-form';
export * from "./speech-player";

View File

@@ -0,0 +1,97 @@
import { useState, useContext } from "react";
import { RecordButton, SpeechPlayer } from "@renderer/components";
import {
Button,
Textarea,
Dialog,
DialogContent,
} from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { LoaderIcon } from "lucide-react";
import { t } from "i18next";
export const SpeechForm = (props: {
lastMessage?: MessageType;
onSubmit: (content: string, file: string) => void;
}) => {
const { lastMessage, onSubmit } = props;
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [transcribing, setTranscribing] = useState(false);
const [editting, setEditting] = useState(false);
const [content, setContent] = useState("");
const [file, setFile] = useState("");
const handleCancel = () => {
setEditting(false);
setContent("");
setFile("");
};
const handleSubmit = () => {
if (!content) return;
onSubmit(content, file);
handleCancel();
};
return (
<>
<RecordButton
disabled={false}
onRecordEnd={async (blob, _duration) => {
setTranscribing(true);
setEditting(true);
EnjoyApp.whisper
.transcribe(
{
type: blob.type.split(";")[0],
arrayBuffer: await blob.arrayBuffer(),
},
lastMessage?.content
)
.then(({ content, file }) => {
setContent(content);
setFile(file);
})
.finally(() => {
setTranscribing(false);
});
}}
/>
<Dialog
open={editting}
onOpenChange={(value) => {
setEditting(value);
}}
>
<DialogContent>
{transcribing ? (
<div className="flex items-center justify-center p-6">
<LoaderIcon className="w-6 h-6 animate-spin" />
</div>
) : (
<div className="">
<div className="my-4">
<Textarea
className="w-full h-36"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</div>
{file && (
<div className="mb-4">
<SpeechPlayer speech={{ playSource: "enjoy://" + file }} />
</div>
)}
<div className="flex items-center justify-end space-x-2">
<Button variant="secondary" onClick={handleCancel}>
{t("cancel")}
</Button>
<Button onClick={handleSubmit}>{t("send")}</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,109 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { PitchContour } from "@renderer/components";
import WaveSurfer from "wavesurfer.js";
import { Button } from "@renderer/components/ui";
import { PlayIcon, PauseIcon } from "lucide-react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { secondsToTimestamp } from "@renderer/lib/utils";
export const SpeechPlayer = (props: {
speech: Partial<SpeechType>;
height?: number;
}) => {
const { speech, height = 100 } = props;
const [isPlaying, setIsPlaying] = useState(false);
const [wavesurfer, setWavesurfer] = useState(null);
const containerRef = useRef();
const [ref, entry] = useIntersectionObserver({
threshold: 1,
});
const [duration, setDuration] = useState<number>(0);
const onPlayClick = useCallback(() => {
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
}, [wavesurfer]);
useEffect(() => {
// use the intersection observer to only create the wavesurfer instance
// when the player is visible
if (!entry?.isIntersecting) return;
if (!speech?.src) return;
if (wavesurfer) return;
const ws = WaveSurfer.create({
container: containerRef.current,
url: speech.src,
height,
barWidth: 1,
cursorWidth: 0,
autoCenter: false,
autoScroll: true,
hideScrollbar: true,
minPxPerSec: 100,
waveColor: "#ddd",
progressColor: "rgba(0, 0, 0, 0.25)",
normalize: true,
});
setWavesurfer(ws);
}, [speech, entry]);
useEffect(() => {
if (!wavesurfer) return;
const subscriptions = [
wavesurfer.on("play", () => {
setIsPlaying(true);
}),
wavesurfer.on("pause", () => {
setIsPlaying(false);
}),
wavesurfer.on("decode", () => {
setDuration(wavesurfer.getDuration());
const peaks = wavesurfer.getDecodedData().getChannelData(0);
const sampleRate = wavesurfer.options.sampleRate;
wavesurfer.renderer.getWrapper().appendChild(
PitchContour({
peaks,
sampleRate,
height,
})
);
}),
];
return () => {
subscriptions.forEach((unsub) => unsub());
wavesurfer?.destroy();
};
}, [wavesurfer]);
return (
<div className="w-full">
<div className="flex justify-end">
<span className="text-xs text-muted-foreground mb-1">
{secondsToTimestamp(duration)}
</span>
</div>
<div
ref={ref}
className="bg-white rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
>
<div className="flex justify-center">
<Button
onClick={onPlayClick}
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
>
{isPlaying ? (
<PauseIcon className="w-6 h-6 text-white" />
) : (
<PlayIcon className="w-6 h-6 text-white" />
)}
</Button>
</div>
<div className="col-span-8" ref={containerRef}></div>
</div>
</div>
);
};