Feat: interactive courses (#736)

* add courses page

* add api for courses

* add course page

* update course type

* update client

* update course page

* refactor courses pages

* render chapter content

* shadow in course

* fix video handler

* update style

* mark finished examples

* fix media player

* update locale

* finish chapter

* refactor

* auto update chapter status

* audo finish chapter

* fix media provider

* fix wavesurfer player

* update continue btn

* refactor chapters & page

* minor fix

* fix undefined

* refactor

* refactor

* disable sentry in dev

* clean markdown format before alignment

* refactor

* fix regenerate

* fix transcription pre-process for `-` connector

* upgrade deps

* handle no chapters

* add llm chat api

* create llm chat

* display llm message

* create message

* handle error

* generate llm message

* display llm datetime

* scroll to message

* tts for llm message

* add course provider

* refactor

* translate llm message

* fix llm chat introduction

* refacotr

* upgrade deps

* refactor style

* handle undefined

* fix posts

* update locales

* update courses api

* add enrollments count

* upgrade yarn

* upgrade deps

* restore dep to fix package in mac

* upgrade deps
This commit is contained in:
an-lee
2024-07-11 19:14:40 +08:00
committed by GitHub
parent d9523269a3
commit 728bfae82f
72 changed files with 3083 additions and 1973 deletions

View File

@@ -468,4 +468,108 @@ export class Client {
params: decamelizeKeys(params),
});
}
courses(params?: {
language?: string;
page?: number;
items?: number;
query?: string;
}): Promise<
{
courses: CourseType[];
} & PagyResponseType
> {
return this.api.get("/api/courses", { params: decamelizeKeys(params) });
}
course(id: string): Promise<CourseType> {
return this.api.get(`/api/courses/${id}`);
}
createEnrollment(courseId: string): Promise<EnrollmentType> {
return this.api.post(`/api/enrollments`, decamelizeKeys({ courseId }));
}
courseChapters(
courseId: string,
params?: {
page?: number;
items?: number;
query?: string;
}
): Promise<
{
chapters: ChapterType[];
} & PagyResponseType
> {
return this.api.get(`/api/courses/${courseId}/chapters`, {
params: decamelizeKeys(params),
});
}
coursechapter(courseId: string, id: number | string): Promise<ChapterType> {
return this.api.get(`/api/courses/${courseId}/chapters/${id}`);
}
finishCourseChapter(courseId: string, id: number | string): Promise<void> {
return this.api.post(`/api/courses/${courseId}/chapters/${id}/finish`);
}
enrollments(params?: { page?: number; items?: number }): Promise<
{
enrollments: EnrollmentType[];
} & PagyResponseType
> {
return this.api.get("/api/enrollments", { params: decamelizeKeys(params) });
}
updateEnrollment(
id: string,
params: {
currentChapterId?: string;
}
): Promise<EnrollmentType> {
return this.api.put(`/api/enrollments/${id}`, decamelizeKeys(params));
}
createLlmChat(params: {
agentId: string;
agentType: string;
}): Promise<LLmChatType> {
return this.api.post("/api/chats", decamelizeKeys(params));
}
llmChat(id: string): Promise<LLmChatType> {
return this.api.get(`/api/chats/${id}`);
}
createLlmMessage(
chatId: string,
params: {
query: string;
agentId?: string;
agentType?: string;
}
): Promise<LlmMessageType> {
return this.api.post(
`/api/chats/${chatId}/messages`,
decamelizeKeys(params)
);
}
llmMessages(
chatId: string,
params: {
page?: number;
items?: number;
}
): Promise<
{
messages: LlmMessageType[];
} & PagyResponseType
> {
return this.api.get(`/api/chats/${chatId}/messages`, {
params: decamelizeKeys(params),
});
}
}

View File

@@ -133,6 +133,7 @@
},
"sidebar": {
"home": "Home",
"courses": "Courses",
"community": "Community",
"audios": "Audios",
"videos": "Videos",
@@ -632,5 +633,11 @@
"noData": "No data",
"selectedFiles": "Selected files",
"moreOptions": "More options",
"lessOptions": "Less options"
"lessOptions": "Less options",
"previousChapter": "Previous",
"nextChapter": "Next",
"examples": "Examples",
"continueLearning": "Continue learning",
"enrollNow": "Enroll now",
"enrollments": "Enrollments"
}

View File

@@ -133,6 +133,7 @@
},
"sidebar": {
"home": "主页",
"courses": "课程",
"community": "社区",
"audios": "音频",
"videos": "视频",
@@ -632,5 +633,11 @@
"noData": "没有数据",
"selectedFiles": "已选中文件",
"moreOptions": "更多选项",
"lessOptions": "更少选项"
"lessOptions": "更少选项",
"previousChapter": "上一章",
"nextChapter": "下一章",
"examples": "示例",
"continueLearning": "继续练习",
"enrollNow": "加入练习",
"enrollments": "参加的课程"
}

View File

@@ -14,9 +14,11 @@ import { updateElectronApp, UpdateSourceType } from "update-electron-app";
const logger = log.scope("main");
Sentry.init({
dsn: SENTRY_DSN,
});
if (app.isPackaged) {
Sentry.init({
dsn: SENTRY_DSN,
});
}
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer");

View File

@@ -35,6 +35,7 @@ class AudiosHandler {
],
where,
...options,
group: ["Audio.id"],
});
if (!audios) {
@@ -116,14 +117,21 @@ class AudiosHandler {
id: string,
params: Attributes<Audio>
) {
const { name, description, metadata, language } = params;
const { name, description, metadata, language, coverUrl, source } = params;
const audio = await Audio.findByPk(id);
if (!audio) {
throw new Error(t("models.audio.notFound"));
}
return await audio.update({ name, description, metadata, language });
return await audio.update({
name,
description,
metadata,
language,
coverUrl,
source,
});
}
private async destroy(_event: IpcMainEvent, id: string) {

View File

@@ -4,8 +4,21 @@ import fs from "fs-extra";
import path from "path";
import settings from "@main/settings";
import { hashFile } from "@main/utils";
import { Attributes, WhereOptions } from "sequelize";
class SpeechesHandler {
private async findOne(
_event: IpcMainEvent,
where: WhereOptions<Attributes<Speech>>
) {
const speech = await Speech.findOne({ where });
if (!speech) {
return null;
}
return speech.toJSON();
}
private async create(
event: IpcMainEvent,
params: {
@@ -43,6 +56,7 @@ class SpeechesHandler {
}
register() {
ipcMain.handle("speeches-find-one", this.findOne);
ipcMain.handle("speeches-create", this.create);
}
}

View File

@@ -35,6 +35,7 @@ class VideosHandler {
],
where,
...options,
group: ["Video.id"],
});
if (!videos) {
return [];
@@ -106,13 +107,13 @@ class VideosHandler {
id: string,
params: Attributes<Video>
) {
const { name, description, metadata, language } = params;
const { name, description, metadata, language, coverUrl, source } = params;
const video = await Video.findByPk(id);
if (!video) {
throw new Error(t("models.video.notFound"));
}
video.update({ name, description, metadata, language });
video.update({ name, description, metadata, language, coverUrl, source });
}
private async destroy(event: IpcMainEvent, id: string) {

View File

@@ -311,7 +311,7 @@ export class Audio extends Model<Audio> {
},
});
if (existing) {
throw new Error(t("audioAlreadyAddedToLibrary", { file: filePath }));
return existing;
}
// Generate ID

View File

@@ -123,6 +123,7 @@ export class Speech extends Model<Speech> {
if (!Array.isArray(findResult)) findResult = [findResult];
for (const instance of findResult) {
if (!instance) continue;
if (instance.sourceType === "Message" && instance.message !== undefined) {
instance.source = instance.message;
}

View File

@@ -327,7 +327,7 @@ export class Video extends Model<Video> {
},
});
if (existing) {
throw new Error(t("videoAlreadyAddedToLibrary", { file: filePath }));
return existing;
}
// Generate ID

View File

@@ -2,8 +2,6 @@
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import { version } from "../package.json";
import { callback } from "chart.js/dist/helpers/helpers.core";
import { remove } from "lodash";
contextBridge.exposeInMainWorld("__ENJOY_APP__", {
app: {
@@ -37,7 +35,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
createIssue: (title: string, body: string) => {
return ipcRenderer.invoke("app-create-issue", title, body);
},
onCmdOutput: (callback: (event: IpcRendererEvent, data: string) => void) => {
onCmdOutput: (
callback: (event: IpcRendererEvent, data: string) => void
) => {
ipcRenderer.on("app-on-cmd-output", callback);
},
removeCmdOutputListeners: () => {
@@ -399,6 +399,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
},
},
speeches: {
findOne: (where: any) => {
return ipcRenderer.invoke("speeches-find-one", where);
},
create: (
params: {
sourceId: string;

View File

@@ -28,12 +28,6 @@
import "./index.css";
import "./renderer/index";
import * as Sentry from "@sentry/electron/renderer";
import { SENTRY_DSN } from "@/constants";
Sentry.init({
dsn: SENTRY_DSN,
});
declare global {
interface Window {

View File

@@ -24,6 +24,7 @@ export const AudioPlayer = (props: {
setCurrentSegmentIndex,
getCachedSegmentIndex,
} = useContext(MediaPlayerProviderContext);
const { audio } = useAudio({ id, md5 });
const updateCurrentSegmentIndex = async () => {
@@ -32,15 +33,19 @@ export const AudioPlayer = (props: {
};
useEffect(() => {
if (!audio) return;
setMedia(audio);
}, [audio]);
useEffect(() => {
if (!media) return;
updateCurrentSegmentIndex();
return () => {
setCurrentSegmentIndex(0);
};
}, [media]);
if (!audio) return null;
if (!layout) return <LoaderSpin />;
return (

View File

@@ -105,7 +105,6 @@ export const AudiosComponent = () => {
order,
where,
query: debouncedQuery,
})
.then((_audios) => {
setHasMore(_audios.length >= limit);

View File

@@ -0,0 +1,30 @@
import { CheckCircleIcon } from "lucide-react";
import { Link } from "react-router-dom";
export const ChapterCard = (props: {
chapter: ChapterType;
active?: boolean;
}) => {
const { chapter, active } = props;
return (
<Link
to={`/courses/${chapter.courseId}/chapters/${chapter.sequence}`}
key={chapter.id}
className="p-4 border hover:shadow cursor-pointer rounded-lg relative"
>
<div className="text-center text-sm font-bold font-mono mb-2">
# {chapter.sequence}
</div>
<div className="text-center font-mono line-clamp-1">{chapter.title}</div>
<CheckCircleIcon
className={`absolute top-2 left-2 w-4 h-4 ${
active
? "text-yellow-500"
: chapter.finished
? "text-green-600"
: "text-muted-foreground"
}`}
/>
</Link>
);
};

View File

@@ -0,0 +1,114 @@
import { toast, Button } from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { useContext, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { ExampleContent, MarkdownWrapper } from "@renderer/components";
import { CheckCircleIcon } from "lucide-react";
export const ChapterContent = (props: {
chapter: ChapterType;
onUpdate?: () => void;
}) => {
const { webApi, nativeLanguage } = useContext(AppSettingsProviderContext);
const { chapter, onUpdate } = props;
const translation = chapter?.translations?.find(
(t) => t.language === nativeLanguage
);
const [audios, setAudios] = useState<AudioType[]>([]);
useEffect(() => {
if (chapter?.finished) return;
if (audios.length === 0) return;
if (!chapter?.examples) return;
const finished = audios.filter((a) => a.recordingsCount > 0);
if (finished.length === 0) return;
if (chapter.examples.length === 0) return;
if (finished.length >= chapter.examples.length) {
webApi
.finishCourseChapter(chapter.course.id, chapter.sequence)
.then(() => onUpdate?.())
.catch((err) => {
toast.error(err.message);
});
}
}, [audios]);
useEffect(() => {
setAudios([]);
}, [chapter]);
if (!chapter) return null;
return (
<div className="">
<div className="flex items-center justify-between mb-2">
<div className="">
<CheckCircleIcon
className={`w-4 h-4 ${chapter.finished ? "text-green-600" : ""}`}
/>
</div>
<div className="flex items-center space-x-2">
{chapter.sequence > 1 && (
<Link
to={`/courses/${chapter.course.id}/chapters/${
chapter.sequence - 1
}`}
>
<Button variant="outline" size="sm">
{t("previousChapter")}
</Button>
</Link>
)}
{chapter.course.chaptersCount > chapter.sequence + 1 && (
<Link
to={`/courses/${chapter.course.id}/chapters/${
chapter.sequence + 1
}`}
>
<Button variant="outline" size="sm">
{t("nextChapter")}
</Button>
</Link>
)}
</div>
</div>
<div className="select-text prose dark:prose-invert prose-em:font-bold prose-em:text-red-700 mx-auto">
<h2>{chapter?.title}</h2>
<MarkdownWrapper>{chapter?.content}</MarkdownWrapper>
{translation && (
<details>
<summary>{t("translation")}</summary>
<MarkdownWrapper>{translation.content}</MarkdownWrapper>
</details>
)}
{chapter.examples.length > 0 && <h3>{t("examples")}</h3>}
<div className="grid gap-4">
{chapter.examples.map((example, index) => (
<ExampleContent
key={index}
example={example}
course={chapter.course}
onAudio={(audio) => {
setAudios((audios) => {
if (!audio) return [];
const index = audios.findIndex((a) => a.id === audio.id);
if (index >= 0) {
audios[index] = audio;
} else {
audios.push(audio);
}
return [...audios];
});
}}
/>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,101 @@
import { AppSettingsProviderContext } from "@renderer/context";
import { useContext, useEffect, useState } from "react";
import { ChapterCard } from "@renderer/components";
import {
Input,
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@renderer/components/ui";
import { t } from "i18next";
const ITEMS_PER_PAGE = 30;
export const Chapters = (props: { course: CourseType }) => {
const { course } = props;
const { webApi } = useContext(AppSettingsProviderContext);
const [chapters, setChapters] = useState<ChapterType[]>([]);
const [currentPage, setCurrentPage] = useState<number>(1);
const [lastPage, setLastPage] = useState<number>();
const [hasMore, setHasMore] = useState<boolean>(true);
const fetchCourseChapters = async (params?: { page: number }) => {
if (!course?.id) return;
let { page } = params || {};
if (!page && course.enrollment?.currentChapterSequence) {
page = Math.ceil(
course.enrollment.currentChapterSequence / ITEMS_PER_PAGE
);
}
page = page || currentPage;
webApi
.courseChapters(course.id, { page, items: ITEMS_PER_PAGE })
.then(({ chapters, page, next, last }) => {
setCurrentPage(page);
setLastPage(last);
setHasMore(!!next);
setChapters(chapters);
});
};
useEffect(() => {
if (!course) return;
fetchCourseChapters();
}, [course]);
if (!course) return null;
if (!chapters || chapters.length === 0)
return <div className="flex justify-center p-4">{t("noData")}</div>;
return (
<div className="">
<div className="grid gap-4 grid-cols-5 mb-4">
{chapters.map((chapter) => (
<ChapterCard
key={chapter.id}
chapter={chapter}
active={course.enrollment?.currentChapterId === chapter.id}
/>
))}
</div>
<div className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
{currentPage > 1 && (
<PaginationPrevious
onClick={() => fetchCourseChapters({ page: currentPage - 1 })}
/>
)}
</PaginationItem>
<PaginationItem>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-16 text-center"
value={currentPage}
onChange={(e) => {
setCurrentPage(parseInt(e.target.value));
fetchCourseChapters({ page: parseInt(e.target.value) });
}}
/>
/<span>{lastPage}</span>
</div>
</PaginationItem>
<PaginationItem>
{hasMore && (
<PaginationNext
onClick={() => fetchCourseChapters({ page: currentPage + 1 })}
/>
)}
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import { GraduationCapIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { Progress } from "../ui";
export const CourseCard = (props: {
course: CourseType;
progress?: number;
className?: string;
}) => {
const { course, progress = 0, className = "" } = props;
return (
<div className={className}>
<Link to={`/courses/${course.id}`}>
<div className="aspect-square rounded-lg border overflow-hidden flex relative">
{course.coverUrl ? (
<img
src={course.coverUrl}
crossOrigin="anonymous"
className="hover:scale-105 object-cover w-full h-full"
/>
) : (
<GraduationCapIcon className="hover:scale-105 object-cover w-1/2 h-1/2 m-auto" />
)}
<Progress
className="absolute bottom-0 left-0 right-0"
value={progress}
/>
</div>
</Link>
<div className="text-sm font-semibold mt-2 max-w-full line-clamp-2 h-10">
{course.title}
</div>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { useContext, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
import { t } from "i18next";
import { AppSettingsProviderContext } from "@renderer/context";
import { CourseCard } from "./course-card";
export const EnrollmentSegment = () => {
const { webApi } = useContext(AppSettingsProviderContext);
const [enrollments, setEnrollments] = useState<EnrollmentType[]>([]);
const fetchEnrollments = async () => {
webApi.enrollments().then(({ enrollments }) => {
setEnrollments(enrollments);
});
};
useEffect(() => {
fetchEnrollments();
}, []);
if (!enrollments?.length) return null;
return (
<div>
<div className="flex items-start justify-between mb-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight capitalize">
{t("enrollments")}
</h2>
</div>
<div className="ml-auto mr-4">
<Link to="/courses">
<Button variant="link" className="capitalize">
{t("seeMore")}
</Button>
</Link>
</div>
</div>
<ScrollArea>
<div className="flex items-center space-x-4 pb-4">
{enrollments.map((enrollment) => {
return (
<CourseCard
className="w-36"
key={enrollment.id}
course={enrollment.course}
progress={enrollment.progress * 100}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
);
};

View File

@@ -0,0 +1,184 @@
import { toast } from "@renderer/components/ui";
import {
AppSettingsProviderContext,
CourseProviderContext,
DbProviderContext,
} from "@renderer/context";
import { t } from "i18next";
import { useContext, useEffect, useState } from "react";
import { MarkdownWrapper, WavesurferPlayer } from "@renderer/components";
import { DownloadIcon, LoaderIcon, MicIcon } from "lucide-react";
export const ExampleContent = (props: {
example: ChapterType["examples"][0];
course?: CourseType;
onAudio?: (audio: AudioType) => void;
}) => {
const { nativeLanguage, EnjoyApp } = useContext(AppSettingsProviderContext);
const { setShadowing } = useContext(CourseProviderContext);
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { example, course, onAudio } = props;
const translation = example?.translations?.find(
(t) => t.language === nativeLanguage
);
const [resourcing, setResourcing] = useState(false);
const [audio, setAudio] = useState<AudioType | null>(null);
const onAudioUpdate = (event: CustomEvent) => {
const { model, action, record } = event.detail || {};
if (
model === "Audio" &&
action === "create" &&
record.source === example.audioUrl
) {
setAudio(record);
onAudio?.(record);
} else if (model === "Recording" && audio?.id === record.targetId) {
EnjoyApp.audios.findOne({ id: audio.id }).then((audio) => {
setAudio(audio);
onAudio?.(audio);
});
}
};
const fetchAudio = async () => {
if (!example) return;
EnjoyApp.audios
.findOne({
source: example.audioUrl,
})
.then((audio) => {
setAudio(audio);
onAudio?.(audio);
});
};
const startShadow = async () => {
if (resourcing) return;
if (audio) {
setShadowing(audio);
return;
}
setResourcing(true);
const name =
example.keywords.join(" ") + "-" + example.audioUrl.split("/").pop();
EnjoyApp.audios
.create(example.audioUrl, {
name,
originalText: example.content,
coverUrl: course?.coverUrl,
})
.then((audio) => {
if (!audio) return;
setAudio(audio);
if (audio.source !== example.audioUrl) {
EnjoyApp.audios
.update(audio.id, {
name,
coverUrl: course.coverUrl,
source: example.audioUrl,
})
.finally(() => {
setShadowing(audio);
});
} else {
setShadowing(audio);
}
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setResourcing(false);
});
};
const handleDownload = async () => {
const filename =
example.keywords.join(" ") + "-" + example.audioUrl.split("/").pop();
EnjoyApp.dialog
.showSaveDialog({
title: t("download"),
defaultPath: filename,
filters: [
{
name: "Audio",
extensions: [example.audioUrl.split(".").pop()],
},
],
})
.then((savePath) => {
if (!savePath) return;
toast.promise(
EnjoyApp.download.start(example.audioUrl, savePath as string),
{
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",
}
);
})
.catch((err) => {
toast.error(err.message);
});
};
useEffect(() => {
fetchAudio();
}, [example?.audioUrl]);
useEffect(() => {
addDblistener(onAudioUpdate);
return () => {
removeDbListener(onAudioUpdate);
};
}, [audio]);
if (!example) return null;
return (
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
<MarkdownWrapper>{example.content}</MarkdownWrapper>
{translation && (
<details>
<summary>{t("translation")}</summary>
<MarkdownWrapper>{translation.content}</MarkdownWrapper>
</details>
)}
<WavesurferPlayer id={example.id} src={example.audioUrl} />
<div className="flex items-center justify-start space-x-2">
{resourcing ? (
<LoaderIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("addingResource")}
className="w-3 h-3 animate-spin"
/>
) : (
<MicIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("shadowingExercise")}
data-testid="message-start-shadow"
onClick={startShadow}
className={`w-3 h-3 cursor-pointer ${
audio && audio.recordingsCount > 0 ? "text-green-600" : ""
}`}
/>
)}
<DownloadIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("download")}
data-testid="message-download-speech"
onClick={handleDownload}
className="w-3 h-3 cursor-pointer"
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,6 @@
export * from "./chapter-card";
export * from "./chapter-content";
export * from "./chapters";
export * from "./course-card";
export * from "./enrollments-segment";
export * from "./example-content";

View File

@@ -1,5 +1,7 @@
export * from "./audios";
export * from "./conversations";
export * from "./courses";
export * from "./llm-chats";
export * from "./meanings";
export * from "./messages";
export * from "./medias";

View File

@@ -0,0 +1,2 @@
export * from "./llm-chat";
export * from "./llm-message";

View File

@@ -0,0 +1,207 @@
import { AppSettingsProviderContext } from "@renderer/context";
import { useContext, useEffect, useReducer, useRef, useState } from "react";
import { Button, ScrollArea, Textarea, toast } from "@renderer/components/ui";
import { LlmMessage, LoaderSpin } from "@renderer/components";
import { t } from "i18next";
import { LoaderIcon, SendIcon } from "lucide-react";
import autosize from "autosize";
import { llmMessagesReducer } from "@renderer/reducers";
export const LlmChat = (props: {
id?: string;
agentType?: string;
agentId?: string;
}) => {
const { webApi } = useContext(AppSettingsProviderContext);
const { id, agentType, agentId } = props;
const [llmChat, setLlmChat] = useState<LLmChatType | null>(null);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState<boolean>(false);
const [query, setQuery] = useState("");
const [llmMessages, dispatchLlmMessages] = useReducer(llmMessagesReducer, []);
const inputRef = useRef<HTMLTextAreaElement>(null);
const submitRef = useRef<HTMLButtonElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleSubmit = () => {
if (!llmChat) return;
if (!query) return;
if (submitting) return;
setSubmitting(true);
webApi
.createLlmMessage(llmChat.id, { query })
.then((message) => {
dispatchLlmMessages({ type: "create", record: message });
setQuery("");
scrollToMessage(message);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => setSubmitting(false));
};
const scrollToMessage = (message: LlmMessageType) => {
if (!message) return;
setTimeout(() => {
const container = containerRef.current;
if (!container) return;
container
.querySelector(`#llm-message-${message.id}-resopnse .timestamp`)
?.scrollIntoView({
behavior: "smooth",
});
inputRef.current.focus();
}, 1000);
};
const resizeTextarea = () => {
if (!inputRef?.current) return;
inputRef.current.style.height = "auto";
inputRef.current.style.height = inputRef.current.scrollHeight + "px";
};
const findOrCreateChat = async () => {
if (id) {
setLoading(true);
webApi
.llmChat(id)
.then((chat) => {
setLlmChat(chat);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
} else if (agentId && agentType) {
setLoading(true);
webApi
.createLlmChat({ agentId, agentType })
.then((chat) => {
setLlmChat(chat);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
}
};
const fetchLlmMessages = async () => {
if (!llmChat) return;
setLoading(true);
webApi
.llmMessages(llmChat.id, { items: 100 })
.then(({ messages }) => {
dispatchLlmMessages({ type: "set", records: messages });
scrollToMessage(messages[messages.length - 1]);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (!inputRef.current) return;
autosize(inputRef.current);
inputRef.current.addEventListener("keypress", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submitRef.current?.click();
}
});
inputRef.current.focus();
return () => {
inputRef.current?.removeEventListener("keypress", () => {});
autosize.destroy(inputRef.current);
};
}, [id, inputRef.current]);
useEffect(() => {
resizeTextarea();
}, [query]);
useEffect(() => {
findOrCreateChat();
}, [id, agentType, agentId]);
useEffect(() => {
fetchLlmMessages();
}, [llmChat]);
if (loading) return <LoaderSpin />;
if (!llmChat)
return (
<div className="flex items-center justify-center py-6">{t("noData")}</div>
);
return (
<ScrollArea
ref={containerRef}
className="h-full max-w-screen-lg mx-auto p-4 pb-24 relative"
>
<LlmMessage
llmMessage={{
response: llmChat.agent.introduction,
agent: llmChat.agent,
chat: llmChat,
}}
/>
{llmMessages.map((message) => (
<LlmMessage key={message.id} llmMessage={message} />
))}
<div className="bg-muted px-4 absolute w-full bottom-4 left-0 z-50">
<div className="focus-within:bg-background pr-4 py-2 flex items-end space-x-4 rounded-lg shadow-lg border scrollbar">
<Textarea
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={submitting}
placeholder={t("pressEnterToSend")}
data-testid="llm-chat-input"
className="text-base px-4 py-0 shadow-none focus-visible:outline-0 focus-visible:ring-0 border-none min-h-[1rem] max-h-[70vh] scrollbar-thin !overflow-x-hidden"
/>
<div className="h-12 py-1">
<Button
type="submit"
ref={submitRef}
disabled={submitting || !query}
data-testid="llm-chat-submit"
onClick={() => handleSubmit()}
data-tooltip-id="global-tooltip"
data-tooltip-content={t("send")}
className="h-10"
>
{submitting ? (
<LoaderIcon className="w-5 h-5 animate-spin" />
) : (
<SendIcon className="w-5 h-5" />
)}
</Button>
</div>
</div>
</div>
</ScrollArea>
);
};

View File

@@ -0,0 +1,322 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
toast,
} from "@renderer/components/ui";
import {
ConversationShortcuts,
MarkdownWrapper,
SpeechPlayer,
} from "@renderer/components";
import { formatDateTime } from "@renderer/lib/utils";
import { useContext, useEffect, useState } from "react";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
ForwardIcon,
LanguagesIcon,
LoaderIcon,
MicIcon,
SpeechIcon,
} from "lucide-react";
import { t } from "i18next";
import { useAiCommand, useConversation } from "@renderer/hooks";
import {
AppSettingsProviderContext,
CourseProviderContext,
} from "@renderer/context";
import { md5 } from "js-md5";
export const LlmMessage = (props: { llmMessage: LlmMessageType }) => {
const { llmMessage } = props;
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { setShadowing } = useContext(CourseProviderContext);
const [_, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const [speech, setSpeech] = useState<Partial<SpeechType>>();
const [speeching, setSpeeching] = useState<boolean>(false);
const [resourcing, setResourcing] = useState<boolean>(false);
const { tts } = useConversation();
const { summarizeTopic } = useAiCommand();
const createSpeech = () => {
if (speeching) return;
setSpeeching(true);
tts({
sourceType: "LlmMessage",
sourceId: llmMessage.id,
text: llmMessage.response,
configuration: {
engine: "enjoyai",
model: "tts-1",
voice: "alloy",
},
})
.then((speech) => {
setSpeech(speech);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setSpeeching(false);
});
};
const findSpeech = () => {
if (!llmMessage.id) return;
EnjoyApp.speeches
.findOne({
sourceType: "LlmMessage",
sourceId: llmMessage.id,
})
.then((speech) => {
setSpeech(speech);
})
.catch((err) => {
toast.error(err.message);
});
};
const startShadow = async () => {
if (resourcing) return;
const audio = await EnjoyApp.audios.findOne({
md5: speech.md5,
});
if (!audio) {
setResourcing(true);
let title =
speech.text.length > 20
? speech.text.substring(0, 17).trim() + "..."
: speech.text;
try {
title = await summarizeTopic(speech.text);
} catch (e) {
console.warn(e);
}
EnjoyApp.audios
.create(speech.filePath, {
name: title,
originalText: speech.text,
})
.then((audio) => setShadowing(audio))
.catch((err) => toast.error(t(err.message)))
.finally(() => {
setResourcing(false);
});
}
setShadowing(audio);
};
const handleDownload = async () => {
EnjoyApp.dialog
.showSaveDialog({
title: t("download"),
defaultPath: speech.filename,
filters: [
{
name: "Audio",
extensions: [speech.filename.split(".").pop()],
},
],
})
.then((savePath) => {
if (!savePath) return;
toast.promise(EnjoyApp.download.start(speech.src, savePath as string), {
success: () => t("downloadedSuccessfully"),
error: t("downloadFailed"),
position: "bottom-right",
});
})
.catch((err) => {
toast.error(err.message);
});
};
const [translation, setTranslation] = useState<string>();
const [translating, setTranslating] = useState<boolean>(false);
const { translate } = useAiCommand();
const handleTranslate = async () => {
if (translating) return;
if (!llmMessage.response) return;
const cacheKey = `translate-${md5(llmMessage.response)}`;
try {
const cached = await EnjoyApp.cacheObjects.get(cacheKey);
if (cached && !translation) {
setTranslation(cached);
} else {
setTranslating(true);
const result = await translate(llmMessage.response, cacheKey);
setTranslation(result);
setTranslating(false);
}
} catch (err) {
toast.error(err.message);
setTranslating(false);
}
};
useEffect(() => {
findSpeech();
}, []);
return (
<>
{llmMessage.query && (
<div id={`llm-message-${llmMessage.id}-query`} className="mb-6">
<div className="flex items-center space-x-2 justify-end mb-2">
<div className="text-sm text-muted-foreground">
{llmMessage.user.name}
</div>
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage src={llmMessage.user.avatarUrl}></AvatarImage>
<AvatarFallback className="bg-background">
{llmMessage.user.name}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col gap-2 px-4 py-2 mb-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm max-w-full">
<MarkdownWrapper className="select-text prose dark:prose-invert">
{llmMessage.query}
</MarkdownWrapper>
</div>
{llmMessage.createdAt && (
<div className="flex justify-end text-xs text-muted-foreground timestamp">
{formatDateTime(llmMessage.createdAt)}
</div>
)}
</div>
)}
{llmMessage.response && (
<div id={`llm-message-${llmMessage.id}-response`} className="mb-6">
<div className="flex items-center space-x-2 mb-2">
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage src={llmMessage.agent.avatarUrl}></AvatarImage>
<AvatarFallback className="bg-background">
{llmMessage.agent.name}
</AvatarFallback>
</Avatar>
<div className="text-sm text-muted-foreground">
{llmMessage.agent.name}
</div>
</div>
<div className="flex flex-col gap-4 px-4 py-2 mb-2 bg-background border rounded-lg shadow-sm max-w-full">
<MarkdownWrapper className="select-text prose dark:prose-invert">
{llmMessage.response}
</MarkdownWrapper>
{translation && (
<MarkdownWrapper className="select-text prose dark:prose-invert">
{translation}
</MarkdownWrapper>
)}
{Boolean(speech) && <SpeechPlayer speech={speech} />}
<div className="flex items-center space-x-4">
{translating ? (
<LoaderIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("creatingSpeech")}
className="w-4 h-4 animate-spin"
/>
) : (
<LanguagesIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("translation")}
className="w-4 h-4 cursor-pointer"
onClick={handleTranslate}
/>
)}
{llmMessage.id &&
!speech &&
(speeching ? (
<LoaderIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("creatingSpeech")}
className="w-4 h-4 animate-spin"
/>
) : (
<SpeechIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("textToSpeech")}
data-testid="message-create-speech"
onClick={createSpeech}
className="w-4 h-4 cursor-pointer"
/>
))}
{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(llmMessage.response);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
}}
/>
)}
<ConversationShortcuts
prompt={llmMessage.response}
excludedIds={[]}
trigger={
<ForwardIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("forward")}
className="w-4 h-4 cursor-pointer"
/>
}
/>
{Boolean(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"
/>
))}
{Boolean(speech) && (
<DownloadIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("download")}
data-testid="llm-message-download-speech"
onClick={handleDownload}
className="w-4 h-4 cursor-pointer"
/>
)}
</div>
</div>
{llmMessage.createdAt && (
<div className="flex justify-start text-xs text-muted-foreground timestamp">
{formatDateTime(llmMessage.createdAt)}
</div>
)}
</div>
)}
</>
);
};

View File

@@ -328,6 +328,10 @@ export const MediaPlayerControls = () => {
if (!wavesurfer) return;
const segment = transcription.result.timeline[currentSegmentIndex];
if (!segment) {
setCurrentSegmentIndex(0);
return;
}
wavesurfer.seekTo(
Math.floor((segment.startTime / wavesurfer.getDuration()) * 1e8) / 1e8
);

View File

@@ -73,6 +73,7 @@ export const MediaTranscriptionGenerateButton = (props: {
toast.error(e.message);
});
}}
originalText=""
transcribing={transcribing}
transcribingProgress={transcribingProgress}
transcribingOutput={transcribingOutput}

View File

@@ -16,6 +16,7 @@ import {
SpeechPlayer,
AudioPlayer,
ConversationShortcuts,
MarkdownWrapper,
} from "@renderer/components";
import { useState, useEffect, useContext } from "react";
import {
@@ -33,8 +34,8 @@ import {
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { t } from "i18next";
import { AppSettingsProviderContext } from "@renderer/context";
import Markdown from "react-markdown";
import { useConversation, useAiCommand } from "@renderer/hooks";
import { formatDateTime } from "@renderer/lib/utils";
export const AssistantMessageComponent = (props: {
message: MessageType;
@@ -152,15 +153,19 @@ export const AssistantMessageComponent = (props: {
};
return (
<div
id={`message-${message.id}`}
className="ai-message flex items-end space-x-2 pr-10"
>
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage></AvatarImage>
<AvatarFallback className="bg-background">AI</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
<div id={`message-${message.id}`} className="ai-message">
<div className="flex items-center space-x-2 mb-2">
<Avatar className="w-8 h-8 bg-muted avatar">
<AvatarImage></AvatarImage>
<AvatarFallback className="bg-muted capitalize">
{configuration?.model?.[0] || "AI"}
</AvatarFallback>
</Avatar>
<div className="text-sm text-muted-foreground">
{configuration?.model}
</div>
</div>
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full mb-2">
{configuration.type === "tts" &&
(speeching ? (
<div className="text-muted-foreground text-sm py-2">
@@ -176,36 +181,25 @@ export const AssistantMessageComponent = (props: {
))}
{configuration.type === "gpt" && (
<Markdown
<MarkdownWrapper
className="message-content select-text prose dark:prose-invert"
data-source-type="Message"
data-source-id={message.id}
components={{
a({ node, children, ...props }) {
try {
new URL(props.href ?? "");
props.target = "_blank";
props.rel = "noopener noreferrer";
} catch (e) {}
return <a {...props}>{children}</a>;
},
}}
>
{message.content}
</Markdown>
</MarkdownWrapper>
)}
{Boolean(speech) && <SpeechPlayer speech={speech} />}
<DropdownMenu>
<div className="flex items-center justify-start space-x-2">
<div className="flex items-center justify-start space-x-4">
{!speech &&
(speeching ? (
<LoaderIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("creatingSpeech")}
className="w-3 h-3 animate-spin"
className="w-4 h-4 animate-spin"
/>
) : (
<SpeechIcon
@@ -213,19 +207,19 @@ export const AssistantMessageComponent = (props: {
data-tooltip-content={t("textToSpeech")}
data-testid="message-create-speech"
onClick={createSpeech}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
/>
))}
{configuration.type === "gpt" && (
<>
{copied ? (
<CheckIcon className="w-3 h-3 text-green-500" />
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<CopyIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("copyText")}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
onClick={() => {
copyToClipboard(message.content);
setCopied(true);
@@ -242,7 +236,7 @@ export const AssistantMessageComponent = (props: {
<ForwardIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("forward")}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
/>
}
/>
@@ -254,7 +248,7 @@ export const AssistantMessageComponent = (props: {
<LoaderIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("addingResource")}
className="w-3 h-3 animate-spin"
className="w-4 h-4 animate-spin"
/>
) : (
<MicIcon
@@ -262,7 +256,7 @@ export const AssistantMessageComponent = (props: {
data-tooltip-content={t("shadowingExercise")}
data-testid="message-start-shadow"
onClick={startShadow}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
/>
))}
{Boolean(speech) && (
@@ -271,12 +265,12 @@ export const AssistantMessageComponent = (props: {
data-tooltip-content={t("download")}
data-testid="message-download-speech"
onClick={handleDownload}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
/>
)}
<DropdownMenuTrigger>
<MoreVerticalIcon className="w-3 h-3" />
<MoreVerticalIcon className="w-4 h-4" />
</DropdownMenuTrigger>
</div>
@@ -290,6 +284,10 @@ export const AssistantMessageComponent = (props: {
</DropdownMenu>
</div>
<div className="flex justify-start text-xs text-muted-foreground timestamp">
{formatDateTime(message.createdAt)}
</div>
<Sheet
modal={false}
open={shadowing}

View File

@@ -36,6 +36,7 @@ import { useCopyToClipboard } from "@uidotdev/usehooks";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
import Markdown from "react-markdown";
import { formatDateTime } from "@renderer/lib/utils";
export const UserMessageComponent = (props: {
message: MessageType;
@@ -81,11 +82,17 @@ export const UserMessageComponent = (props: {
};
return (
<div
id={`message-${message.id}`}
className="flex items-end justify-end space-x-2 pl-10"
>
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full">
<div id={`message-${message.id}`} className="">
<div className="flex items-center justify-end space-x-2 mb-2">
<div className="text-sm text-muted-foreground">{user.name}</div>
<Avatar className="w-8 h-8 bg-background">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="bg-primary text-white capitalize">
{user.name?.[0] ?? "U"}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full mb-2">
<Markdown
className="select-text prose dark:prose-invert"
components={{
@@ -106,33 +113,33 @@ export const UserMessageComponent = (props: {
{Boolean(speech) && <SpeechPlayer speech={speech} />}
<DropdownMenu>
<div className="flex items-center justify-end space-x-2">
<div className="flex items-center justify-end space-x-4">
{message.createdAt ? (
<CheckCircleIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("sent")}
className="w-3 h-3"
className="w-4 h-4"
/>
) : message.status === "pending" ? (
<LoaderIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("sending")}
className="w-3 h-3 animate-spin"
className="w-4 h-4 animate-spin"
/>
) : (
message.status === "error" && (
<DropdownMenuTrigger>
<AlertCircleIcon className="w-3 h-3 text-destructive" />
<AlertCircleIcon className="w-4 h-4 text-destructive" />
</DropdownMenuTrigger>
)
)}
{copied ? (
<CheckIcon className="w-3 h-3 text-green-500" />
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<CopyIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("copy")}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
onClick={() => {
copyToClipboard(message.content);
setCopied(true);
@@ -150,7 +157,7 @@ export const UserMessageComponent = (props: {
<ForwardIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("forward")}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
/>
}
/>
@@ -161,7 +168,7 @@ export const UserMessageComponent = (props: {
<Share2Icon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("share")}
className="w-3 h-3 cursor-pointer"
className="w-4 h-4 cursor-pointer"
/>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -184,7 +191,7 @@ export const UserMessageComponent = (props: {
)}
<DropdownMenuTrigger>
<MoreVerticalIcon className="w-3 h-3" />
<MoreVerticalIcon className="w-4 h-4" />
</DropdownMenuTrigger>
</div>
@@ -201,13 +208,9 @@ export const UserMessageComponent = (props: {
</DropdownMenuContent>
</DropdownMenu>
</div>
<Avatar className="w-8 h-8 bg-background">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="bg-primary text-white capitalize">
{user.name[0]}
</AvatarFallback>
</Avatar>
<div className="flex justify-end text-xs text-muted-foreground timestamp">
{formatDateTime(message.createdAt)}
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ export * from "./layout";
export * from "./loader-spin";
export * from "./login-form";
export * from "./github-login-form";
export * from "./markdown-wrapper";
export * from "./mixin-login-form";
export * from "./no-records-found";
export * from "./page-placeholder";

View File

@@ -0,0 +1,30 @@
import Markdown from "react-markdown";
export const MarkdownWrapper = ({
children,
className,
...props
}: {
children: string;
className?: string;
}) => {
return (
<Markdown
className={className}
components={{
a({ node, children, ...props }) {
try {
new URL(props.href ?? "");
props.target = "_blank";
props.rel = "noopener noreferrer";
} catch (e) {}
return <a {...props}>{children}</a>;
},
}}
{...props}
>
{children}
</Markdown>
);
};

View File

@@ -31,6 +31,7 @@ import {
ExternalLinkIcon,
NotebookPenIcon,
SpeechIcon,
GraduationCapIcon,
} from "lucide-react";
import { useLocation, Link } from "react-router-dom";
import { t } from "i18next";
@@ -71,6 +72,14 @@ export const Sidebar = () => {
Icon={HomeIcon}
/>
<SidebarItem
href="/courses"
label={t("sidebar.courses")}
tooltip={t("sidebar.courses")}
active={activeTab.startsWith("/courses")}
Icon={GraduationCapIcon}
/>
<Separator className="hidden xl:block" />
<SidebarItem

View File

@@ -49,12 +49,9 @@ export const WavesurferPlayer = (props: {
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;
const initialize = () => {
if (!containerRef.current) return;
if (!src) return;
if (wavesurfer) return;
const ws = WaveSurfer.create({
container: containerRef.current,
@@ -73,7 +70,14 @@ export const WavesurferPlayer = (props: {
});
setWavesurfer(ws);
}, [src, entry]);
};
useEffect(() => {
if (!entry?.isIntersecting) return;
if (wavesurfer?.options?.url === src) return;
initialize();
}, [src, entry, containerRef]);
useEffect(() => {
if (!wavesurfer) return;

View File

@@ -13,14 +13,16 @@ import {
} from "@renderer/components//ui";
import { t } from "i18next";
export const Posts = (props: { userId?: string }) => {
export const Posts = (props: { userId?: string; by?: string }) => {
const { userId } = props;
const { webApi } = useContext(AppSettingsProviderContext);
const [loading, setLoading] = useState<boolean>(true);
const [type, setType] = useState<
"all" | "recording" | "medium" | "story" | "prompt" | "gpt" | "note"
>("all");
const [by, setBy] = useState<"all" | "following">("following");
const [by, setBy] = useState<"all" | "following">(
userId ? "all" : "following"
);
const [posts, setPosts] = useState<PostType[]>([]);
const [nextPage, setNextPage] = useState(1);

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@renderer/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -4,6 +4,7 @@ export * from "./alert-dialog";
export * from "./aspect-ratio";
export * from "./avatar";
export * from "./badage";
export * from "./breadcrumb";
export * from "./button";
export * from "./card";
export * from "./collapsible";
@@ -16,11 +17,13 @@ export * from "./hover-card";
export * from "./input";
export * from "./label";
export * from "./menubar";
export * from "./pagination";
export * from "./ping-point";
export * from "./popover";
export * from "./progress";
export * from "./radial-progress";
export * from "./radio-group";
export * from "./resizable";
export * from "./scroll-area";
export * from "./select";
export * from "./separator";

View File

@@ -0,0 +1,121 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
} from "@radix-ui/react-icons";
import { cn } from "@renderer/lib/utils";
import { ButtonProps, buttonVariants } from "@renderer/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeftIcon className="h-4 w-4" />
{props.children && <span>{props.children}</span>}
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
{props.children && <span>{props.children}</span>}
<ChevronRightIcon className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,45 @@
"use client";
import { DragHandleDots2Icon } from "@radix-ui/react-icons";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@renderer/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<DragHandleDots2Icon className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -32,16 +32,19 @@ export const VideoPlayer = (props: {
};
useEffect(() => {
if (!video) return;
setMedia(video);
}, [video]);
useEffect(() => {
if (!media) return;
updateCurrentSegmentIndex();
return () => {
setCurrentSegmentIndex(0);
};
}, [media]);
if (!video) return null;
if (!layout) return <LoaderSpin />;
return (

View File

@@ -5,7 +5,6 @@ import {
Popover,
PopoverAnchor,
PopoverContent,
ScrollArea,
toast,
} from "@renderer/components/ui";
import { useAiCommand } from "@renderer/hooks";

View File

@@ -4,6 +4,8 @@ import { Client } from "@/api";
import i18n from "@renderer/i18n";
import ahoy from "ahoy.js";
import { type Consumer, createConsumer } from "@rails/actioncable";
import * as Sentry from "@sentry/electron/renderer";
import { SENTRY_DSN } from "@/constants";
type AppSettingsProviderState = {
webApi: Client;
@@ -59,41 +61,15 @@ export const AppSettingsProvider = ({
IPA_MAPPINGS
);
useEffect(() => {
fetchVersion();
fetchUser();
fetchLibraryPath();
fetchLanguages();
fetchProxyConfig();
}, []);
useEffect(() => {
if (!apiUrl) return;
setWebApi(
new Client({
baseUrl: apiUrl,
accessToken: user?.accessToken,
locale: language,
})
);
}, [user, apiUrl, language]);
useEffect(() => {
if (!apiUrl) return;
ahoy.configure({
urlPrefix: apiUrl,
const initSentry = () => {
EnjoyApp.app.isPackaged().then((isPackaged) => {
if (isPackaged) {
Sentry.init({
dsn: SENTRY_DSN,
});
}
});
}, [apiUrl]);
useEffect(() => {
if (!webApi) return;
webApi.config("ipa_mappings").then((mappings) => {
if (mappings) setIpaMappings(mappings);
});
}, [webApi]);
};
const fetchLanguages = async () => {
const language = await EnjoyApp.settings.getLanguage();
@@ -196,6 +172,43 @@ export const AppSettingsProvider = ({
setCable(consumer);
};
useEffect(() => {
fetchVersion();
fetchUser();
fetchLibraryPath();
fetchLanguages();
fetchProxyConfig();
initSentry();
}, []);
useEffect(() => {
if (!apiUrl) return;
setWebApi(
new Client({
baseUrl: apiUrl,
accessToken: user?.accessToken,
locale: language,
})
);
}, [user, apiUrl, language]);
useEffect(() => {
if (!apiUrl) return;
ahoy.configure({
urlPrefix: apiUrl,
});
}, [apiUrl]);
useEffect(() => {
if (!webApi) return;
webApi.config("ipa_mappings").then((mappings) => {
if (mappings) setIpaMappings(mappings);
});
}, [webApi]);
return (
<AppSettingsProviderContext.Provider
value={{

View File

@@ -0,0 +1,92 @@
import { createContext, useEffect, useState, useContext } from "react";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
toast,
} from "@renderer/components/ui";
import {
MediaPlayerProvider,
AppSettingsProviderContext,
} from "@renderer/context";
import { ChevronDownIcon } from "lucide-react";
import { AudioPlayer } from "@renderer/components";
type CourseProviderState = {
course?: CourseType;
currentChapter?: ChapterType;
setCurrentChapter?: (chapter: ChapterType) => void;
shadowing?: AudioType;
setShadowing?: (audio: AudioType) => void;
};
const initialState: CourseProviderState = {};
export const CourseProviderContext =
createContext<CourseProviderState>(initialState);
export const CourseProvider = ({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) => {
const { webApi } = useContext(AppSettingsProviderContext);
const [course, setCourse] = useState<CourseType>(null);
const [currentChapter, setCurrentChapter] = useState<ChapterType>(null);
const [shadowing, setShadowing] = useState<AudioType>(null);
const fetchCourse = async (id: string) => {
webApi
.course(id)
.then((course) => setCourse(course))
.catch((err) => toast.error(err.message));
};
useEffect(() => {
fetchCourse(id);
}, [id]);
return (
<CourseProviderContext.Provider
value={{
course,
currentChapter,
setCurrentChapter,
shadowing,
setShadowing,
}}
>
<MediaPlayerProvider>
{children}
<Sheet
modal={false}
open={Boolean(shadowing)}
onOpenChange={(value) => {
if (!value) setShadowing(null);
}}
>
<SheetContent
side="bottom"
className="h-screen p-0"
displayClose={false}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<SheetHeader className="flex items-center justify-center h-14">
<SheetTitle className="sr-only">Shadow</SheetTitle>
<SheetClose>
<ChevronDownIcon />
</SheetClose>
</SheetHeader>
<AudioPlayer id={shadowing?.id} />
</SheetContent>
</Sheet>
</MediaPlayerProvider>
</CourseProviderContext.Provider>
);
};

View File

@@ -1,7 +1,8 @@
export * from "./ai-settings-provider";
export * from "./app-settings-provider";
export * from "./course-provider";
export * from "./db-provider";
export * from './hotkeys-settings-provider'
export * from "./media-player-provider";
export * from "./theme-provider";
export * from "./wavesurfer-provider";
export * from "./media-player-provider";
export * from './hotkeys-settings-provider'

View File

@@ -53,15 +53,6 @@ type MediaPlayerContextType = {
setActiveRegion: (region: RegionType) => void;
editingRegion: boolean;
setEditingRegion: (editing: boolean) => void;
renderPitchContour: (
region: RegionType,
options?: {
repaint?: boolean;
canvasId?: string;
containerClassNames?: string[];
data?: Chart["data"];
}
) => void;
pitchChart: Chart;
// Transcription
transcription: TranscriptionType;
@@ -263,6 +254,9 @@ export const MediaPlayerProvider = ({
if (!waveform?.frequencies?.length) return;
if (!wavesurfer) return;
const caption = transcription?.result?.timeline?.[currentSegmentIndex];
if (!caption) return;
const { repaint = true, containerClassNames = [] } = options || {};
const duration = wavesurfer.getDuration();
const fromIndex = Math.round(
@@ -319,7 +313,6 @@ export const MediaPlayerProvider = ({
const regionDuration = region.end - region.start;
const labels = new Array(data.length).fill("");
const caption = transcription?.result?.timeline?.[currentSegmentIndex];
if (region.id.startsWith("segment-region")) {
caption.timeline.forEach((segment: TimelineEntry) => {
const index = Math.round(
@@ -520,6 +513,7 @@ export const MediaPlayerProvider = ({
*/
useEffect(() => {
if (!activeRegion) return;
if (!wavesurfer) return;
renderPitchContour(activeRegion);
}, [wavesurfer, activeRegion]);
@@ -605,7 +599,6 @@ export const MediaPlayerProvider = ({
minPxPerSec,
transcription,
regions,
renderPitchContour,
pitchChart,
activeRegion,
setActiveRegion,

View File

@@ -25,7 +25,9 @@ export const useAudio = (options: { id?: string; md5?: string }) => {
};
useEffect(() => {
const where = id ? { id } : { md5 };
const where = id ? { id } : md5 ? { md5 } : null;
if (!where) return;
EnjoyApp.audios.findOne(where).then((audio) => {
if (audio) {
setAudio(audio);

View File

@@ -10,6 +10,7 @@ import * as sdk from "microsoft-cognitiveservices-speech-sdk";
import axios from "axios";
import { AlignmentResult } from "echogarden/dist/api/API.d.js";
import { useAiCommand } from "./use-ai-command";
import { toast } from "@renderer/components/ui";
export const useTranscribe = () => {
const { EnjoyApp, user, webApi } = useContext(AppSettingsProviderContext);
@@ -83,10 +84,12 @@ export const useTranscribe = () => {
let transcript = originalText || result.text;
// Remove all content inside `()`, `[]`, `{}` and trim the text
// remove all markdown formatting
transcript = transcript
.replace(/\(.*?\)/g, "")
.replace(/\[.*?\]/g, "")
.replace(/\{.*?\}/g, "")
.replace(/[*_`]/g, "")
.trim();
// if the transcript does not contain any punctuation, use AI command to add punctuation
@@ -94,6 +97,7 @@ export const useTranscribe = () => {
try {
transcript = await punctuateText(transcript);
} catch (err) {
toast.error(err.message);
console.warn(err.message);
}
}

View File

@@ -190,7 +190,7 @@ export const useTranscriptions = (media: AudioType | VideoType) => {
}
for (let k = j + 1; k <= sentence.timeline.length - 1; k++) {
if (word.includes(sentence.timeline[k].text.toLowerCase())) {
while (word.includes(sentence.timeline[k]?.text?.toLowerCase())) {
let connector = "";
if (match[0] === "-") {
connector = "-";
@@ -204,9 +204,8 @@ export const useTranscriptions = (media: AudioType | VideoType) => {
];
token.endTime = sentence.timeline[k].endTime;
sentence.timeline.splice(k, 1);
} else {
break;
}
break;
}
});
}

View File

@@ -14,7 +14,7 @@ export const useVideo = (options: { id?: string; md5?: string }) => {
const [video, setVideo] = useState<VideoType>(null);
const throttledVideo = useThrottle(video, 500);
const onAudioUpdate = (event: CustomEvent) => {
const onVideoUpdate = (event: CustomEvent) => {
const { model, action, record } = event.detail || {};
if (model !== "Video") return;
if (id && record.id !== id) return;
@@ -25,7 +25,9 @@ export const useVideo = (options: { id?: string; md5?: string }) => {
};
useEffect(() => {
const where = id ? { id } : { md5 };
const where = id ? { id } : md5 ? { md5 } : null;
if (!where) return;
EnjoyApp.videos.findOne(where).then((video) => {
if (video) {
setVideo(video);
@@ -34,9 +36,9 @@ export const useVideo = (options: { id?: string; md5?: string }) => {
}
});
addDblistener(onAudioUpdate);
addDblistener(onVideoUpdate);
return () => {
removeDbListener(onAudioUpdate);
removeDbListener(onVideoUpdate);
};
}, [id, md5]);

View File

@@ -44,7 +44,7 @@ export function bytesToSize(bytes: number) {
return Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i];
}
export function formatDateTime(date: Date) {
export function formatDateTime(date: Date | string) {
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
const now = dayjs();
const then = dayjs(date);

View File

@@ -46,12 +46,12 @@ export default () => {
ttsPreset: {
key: "tts",
name: "TTS",
engine: currentEngine.name,
engine: currentEngine?.name,
configuration: {
type: "tts",
tts: {
engine: currentEngine.name,
model: currentEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
engine: currentEngine?.name,
model: currentEngine?.name === "enjoyai" ? "openai/tts-1" : "tts-1",
voice: "alloy",
},
},

View File

@@ -0,0 +1,80 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
ScrollArea,
toast,
} from "@renderer/components/ui";
import { AppSettingsProviderContext, CourseProvider } from "@renderer/context";
import { t } from "i18next";
import { useContext, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { ChapterContent, LlmChat } from "@renderer/components";
export default () => {
const { id, sequence } = useParams<{ id: string; sequence: string }>();
const { webApi } = useContext(AppSettingsProviderContext);
const [chapter, setChapter] = useState<ChapterType | null>(null);
const fetchChapter = async (id: string, sequence: string) => {
webApi
.coursechapter(id, sequence)
.then((chapter) => setChapter(chapter))
.catch((err) => toast.error(err.message));
};
useEffect(() => {
fetchChapter(id, sequence);
}, [id, sequence]);
useEffect(() => {
if (!chapter) return;
webApi.updateEnrollment(chapter.enrollment.id, {
currentChapterId: chapter.id,
});
}, [chapter]);
return (
<CourseProvider id={id}>
<div className="flex flex-col h-screen px-4 xl:px-6 py-6">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/courses">{t("sidebar.courses")}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbPage>
<Link to={`/courses/${id}`}>{chapter?.course?.title || id}</Link>
</BreadcrumbPage>
<BreadcrumbSeparator />
<BreadcrumbPage>{chapter?.title || sequence}</BreadcrumbPage>
</BreadcrumbList>
</Breadcrumb>
<div className="flex-1 h-[calc(100vh-5.75rem)] border rounded-lg">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={50}>
<ScrollArea className="px-4 py-3 h-full relative bg-muted">
<ChapterContent
chapter={chapter}
onUpdate={() => fetchChapter(id, sequence)}
/>
</ScrollArea>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel className="bg-muted">
<LlmChat agentType="Chapter" agentId={chapter?.id} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</CourseProvider>
);
};

View File

@@ -0,0 +1,67 @@
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { Button, toast } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
import { CourseCard } from "@renderer/components";
export default () => {
const navigate = useNavigate();
return (
<div className="h-full max-w-5xl mx-auto px-4 py-6">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span>{t("sidebar.courses")}</span>
</div>
<div className="">
<CoursesList />
</div>
</div>
);
};
const CoursesList = () => {
const { webApi, learningLanguage } = useContext(AppSettingsProviderContext);
const [courses, setCourses] = useState<CourseType[]>([]);
const [nextPage, setNextPage] = useState(1);
const [loading, setLoading] = useState(false);
const fetchCourses = async () => {
if (loading) return;
webApi
.courses({ page: nextPage, language: learningLanguage })
.then(({ courses = [], next }) => {
setCourses(courses);
setNextPage(next);
})
.catch((err) => toast.error(err.message))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchCourses();
}, []);
if (!courses.length) {
return (
<div className="flex items-center justify-center py-4">
<div>
<span>{t("noData")}</span>
</div>
</div>
);
}
return (
<div className="grid gap-4 grid-cols-5">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
);
};

View File

@@ -0,0 +1,123 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Progress,
Separator,
toast,
} from "@renderer/components/ui";
import { Link, useParams } from "react-router-dom";
import { t } from "i18next";
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { LoaderIcon, UsersIcon } from "lucide-react";
import { Chapters } from "@renderer/components";
export default () => {
const { id } = useParams<{ id: string }>();
const { webApi } = useContext(AppSettingsProviderContext);
const [course, setCourse] = useState<CourseType | null>(null);
const fetchCourse = async (id: string) => {
webApi
.course(id)
.then((course) => setCourse(course))
.catch((err) => toast.error(err.message));
};
useEffect(() => {
fetchCourse(id);
}, [id]);
return (
<div className="h-full max-w-5xl mx-auto px-4 py-6">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/courses">{t("sidebar.courses")}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbPage>{course?.title || id}</BreadcrumbPage>
</BreadcrumbList>
</Breadcrumb>
<div className="mb-6">
<CourseDetail course={course} onUpdate={() => fetchCourse(course.id)} />
</div>
<Separator className="mb-6" />
<div className="mb-6">
<Chapters course={course} />
</div>
</div>
);
};
const CourseDetail = (props: { course: CourseType; onUpdate: () => void }) => {
const { course, onUpdate } = props;
const { webApi } = useContext(AppSettingsProviderContext);
const [enrolling, setEnrolling] = useState(false);
const handleEnroll = async () => {
if (!course) return;
if (course.enrolled) return;
setEnrolling(true);
webApi
.createEnrollment(course.id)
.then(() => onUpdate())
.catch((err) => toast.error(err.message))
.finally(() => setEnrolling(false));
};
if (!course) return null;
return (
<div className="flex items-center justify-between mb-4 gap-4">
<div className="h-48 aspect-square bg-black/10 rounded">
<img
src={course.coverUrl}
alt={course.title}
className="object-cover w-full h-full rounded-lg"
/>
</div>
<div className="flex-1 min-h-48">
<div className="text-2xl font-bold mb-4">{course.title}</div>
<div className="mb-4">{course.description}</div>
{course.enrolled && (
<div className="flex items-center space-x-2 mb-4">
<span>{(course.enrollment.progress * 100).toFixed(2)}%</span>
<Progress value={course.enrollment.progress * 100} />
</div>
)}
<div className="flex items-center gap-4">
{course.enrolled ? (
<Link
to={`/courses/${course.id}/chapters/${
course.enrollment.currentChapterSequence || 1
}`}
>
<Button onClick={handleEnroll}>{t("continueLearning")}</Button>
</Link>
) : (
<Button disabled={course.enrolled} onClick={handleEnroll}>
{enrolling && (
<LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
)}
{t("enrollNow")}
</Button>
)}
{course.enrollmentsCount > 0 && (
<div className="flex items-center space-x-1">
<UsersIcon className="w-4 h-4" />
<span className="text-sm text-muted-foreground">{course.enrollmentsCount}</span>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -4,12 +4,14 @@ import {
StoriesSegment,
VideosSegment,
YoutubeVideosSegment,
EnrollmentSegment,
} from "@renderer/components";
export default () => {
return (
<div className="max-w-5xl mx-auto px-4 py-6 lg:px-8">
<div className="space-y-4">
<EnrollmentSegment />
<AudiosSegment />
<VideosSegment />
<StoriesSegment />

View File

@@ -1,5 +1,6 @@
export * from './audios-reducer';
export * from './conversations-reducer';
export * from './messages-reducer';
export * from './recordings-reducer';
export * from './videos-reducer';
export * from "./audios-reducer";
export * from "./conversations-reducer";
export * from "./messages-reducer";
export * from "./llm-messages-reducer";
export * from "./recordings-reducer";
export * from "./videos-reducer";

View File

@@ -0,0 +1,39 @@
export const llmMessagesReducer = (
state: LlmMessageType[],
action: {
type: "create" | "update" | "destroy" | "set";
record?: LlmMessageType;
records?: LlmMessageType[];
}
) => {
switch (action.type) {
case "create": {
const index = state.findIndex((c) => c.id === action.record.id);
if (index === -1) {
state.push(action.record);
} else {
state[index] = action.record;
}
return [...state];
}
case "update": {
return state.map((c) => {
if (c.id === action.record.id) {
return action.record;
} else {
return c;
}
});
}
case "destroy": {
return state.filter((c) => c.id !== action.record.id);
}
case "set": {
return action.records || [];
}
default: {
throw Error(`Unknown action: ${action.type}`);
}
}
};

View File

@@ -20,6 +20,9 @@ import StoryPreview from "./pages/story-preview";
import Notes from "./pages/notes";
import PronunciationAssessmentsIndex from "./pages/pronunciation-assessments/index";
import PronunciationAssessmentsNew from "./pages/pronunciation-assessments/new";
import Courses from "./pages/courses/index";
import Course from "./pages/courses/show";
import Chapter from "./pages/courses/chapter";
export default createHashRouter([
{
@@ -28,6 +31,18 @@ export default createHashRouter([
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
{
path: "/courses",
element: <Courses />,
},
{
path: "/courses/:id",
element: <Course />,
},
{
path: "/courses/:id/chapters/:sequence",
element: <Chapter />,
},
{
path: "/community",
element: <Community />,

26
enjoy/src/types/chapter.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
type ChapterType = {
id: string;
courseId: string;
sequence: number;
keywords: string[];
title: string;
content: string;
course?: CourseType;
translations?: {
language: string;
content: string;
}[];
finished?: boolean;
examples?: {
id: string;
keywords: string[];
language: string;
content: string;
audioUrl?: string;
translations?: {
language: string;
content: string;
}[];
}[];
enrollment?: EnrollmentType;
};

11
enjoy/src/types/course.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
type CourseType = {
id: string;
title: string;
description: string;
chaptersCount: number;
enrollmentsCount: number;
coverUrl?: string;
chapters?: ChapterType[];
enrolled?: boolean;
enrollment?: EnrollmentType;
};

View File

@@ -227,6 +227,7 @@ type EnjoyAppType = {
createSpeech: (id: string, configuration?: any) => Promise<SpeechType>;
};
speeches: {
findOne: (where: any) => Promise<SpeechType>;
create: (
params: {
sourceId: string;

11
enjoy/src/types/enrollment.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
type EnrollmentType = {
id: string;
courseId: string;
state: string;
progress: number;
currentChapterId?: string;
currentChapterSequence?: number;
course?: CourseType;
createdAt: string;
updatedAt: string;
};

View File

@@ -117,6 +117,7 @@ type MeaningType = {
type PagyResponseType = {
page: number;
next: number | null;
last: number;
};
type AudibleBookType = {

6
enjoy/src/types/llm-agent.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type LLmAgentType = {
id: string;
name: string;
avatarUrl: string;
introduction: string;
};

5
enjoy/src/types/llm-chat.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
type LLmChatType = {
id: string;
user: UserType;
agent: LLmAgentType;
};

10
enjoy/src/types/llm-message.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
type LlmMessageType = {
id?: string;
query?: string;
response?: string;
agent: LLmAgentType;
user?: UserType;
chat: LLmChatType;
createdAt?: string;
updatedAt?: string;
};