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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,4 +4,4 @@ nmHoistingLimits: workspaces
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.2.1.cjs
yarnPath: .yarn/releases/yarn-4.3.1.cjs

View File

@@ -9,7 +9,7 @@
"markdown-it-sup": "^2.0.0",
"mermaid": "^10.9.1",
"sass": "^1.77.6",
"vitepress": "^1.2.3",
"vitepress": "^1.3.0",
"vitepress-plugin-mermaid": "^2.0.16",
"vue": "^3.4.30"
},

View File

@@ -41,7 +41,7 @@
"@electron-forge/plugin-vite": "^7.4.0",
"@electron-forge/publisher-github": "^7.4.0",
"@electron/fuses": "^1.8.0",
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.45.1",
"@tailwindcss/typography": "^0.5.13",
"@types/ahoy.js": "^0.4.2",
"@types/autosize": "^4.0.3",
@@ -52,17 +52,17 @@
"@types/intl-tel-input": "^18.1.4",
"@types/lodash": "^4.17.6",
"@types/mark.js": "^8.11.12",
"@types/node": "^20.14.9",
"@types/node": "^20.14.10",
"@types/rails__actioncable": "^6.1.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.0",
"@types/wavesurfer.js": "^6.0.12",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"electron": "^31.1.0",
"electron": "^31.2.0",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.6.0",
"eslint-import-resolver-typescript": "^3.6.1",
@@ -70,22 +70,22 @@
"flora-colossus": "^2.0.0",
"octokit": "^4.0.2",
"progress": "^2.0.3",
"tailwind-merge": "^2.3.0",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"vite": "^5.3.2",
"vite-plugin-static-copy": "^1.0.5",
"zx": "^8.1.3"
"vite": "^5.3.3",
"vite-plugin-static-copy": "^1.0.6",
"zx": "^8.1.4"
},
"dependencies": {
"@andrkrn/ffprobe-static": "^5.2.0",
"@electron-forge/publisher-s3": "^7.4.0",
"@hookform/resolvers": "^3.6.0",
"@langchain/community": "^0.2.16",
"@hookform/resolvers": "^3.9.0",
"@langchain/community": "^0.2.17",
"@langchain/google-genai": "^0.0.21",
"@mozilla/readability": "^0.5.0",
"@radix-ui/react-accordion": "^1.2.0",
@@ -112,9 +112,9 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@rails/actioncable": "7.1.3",
"@sentry/electron": "^5.1.0",
"@sentry/electron": "^5.2.0",
"@uidotdev/usehooks": "^2.4.1",
"@vidstack/react": "^1.11.24",
"@vidstack/react": "^1.11.27",
"ahoy.js": "^0.4.4",
"autosize": "^6.0.1",
"axios": "^1.7.2",
@@ -143,29 +143,30 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.5",
"i18next": "^23.11.5",
"intl-tel-input": "^23.1.0",
"intl-tel-input": "^23.3.0",
"js-md5": "^0.8.3",
"langchain": "^0.2.8",
"lodash": "^4.17.21",
"lucide-react": "^0.400.0",
"lucide-react": "^0.407.0",
"mark.js": "^8.11.1",
"microsoft-cognitiveservices-speech-sdk": "^1.38.0",
"next-themes": "^0.3.0",
"openai": "^4.52.2",
"openai": "^4.52.5",
"pitchfinder": "^2.3.2",
"postcss": "^8.4.38",
"proxy-agent": "^6.4.0",
"react": "^18.3.1",
"react-activity-calendar": "^2.2.11",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-hook-form": "^7.52.1",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.1.2",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.24.0",
"react-tooltip": "^5.27.0",
"react-resizable-panels": "^2.0.20",
"react-router-dom": "^6.24.1",
"react-tooltip": "^5.27.1",
"reflect-metadata": "^0.2.2",
"rimraf": "^5.0.7",
"rimraf": "^6.0.1",
"sequelize": "^6.37.3",
"sequelize-typescript": "^2.1.6",
"sonner": "^1.5.0",
@@ -173,7 +174,7 @@
"tailwind-scrollbar-hide": "^1.1.7",
"umzug": "^3.8.1",
"update-electron-app": "^3.0.0",
"wavesurfer.js": "^7.8.0",
"wavesurfer.js": "^7.8.1",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.1"
}

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

View File

@@ -24,7 +24,7 @@
"docs:preview": "yarn workspace 1000-hours preview",
"portal:generate": "yarn workspace 1000h-portal generate"
},
"packageManager": "yarn@4.2.1",
"packageManager": "yarn@4.3.1",
"engines": {
"node": ">=18.0.0"
}

1141
yarn.lock

File diff suppressed because it is too large Load Diff