Feat add chat suggetion helper (#1006)

* use recorder config

* add message timestamp in prompt

* refactor

* add chat suggest

* update prompt

* update style

* update style

* update ux

* refactor suggestion button

* fix chat input

* add hotkey for record

* refactor agent reply

* fix `replaceAll`

* hotkey for continue

* refactor chat list
This commit is contained in:
an-lee
2024-08-22 18:39:22 +08:00
committed by GitHub
parent 70127a3c0e
commit 54977405cb
13 changed files with 631 additions and 233 deletions

View File

@@ -0,0 +1,59 @@
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { z } from "zod";
import { jsonCommand } from "./json.command";
export const chatSuggestionCommand = async (
params: {
learningLanguage: string;
nativeLanguage: string;
context: string;
},
options: {
key: string;
modelName?: string;
temperature?: number;
baseUrl?: string;
}
): Promise<{
suggestions: {
text: string;
explaination: string;
}[];
}> => {
const { learningLanguage, nativeLanguage, context } = params;
const schema = z.object({
words: z.array(
z.object({
content: z.string().min(1),
explaination: z.string().min(1),
})
),
});
const prompt = await ChatPromptTemplate.fromMessages([
["system", SYSTEM_PROMPT],
["human", PROMPT],
]).format({
native_language: nativeLanguage,
learning_language: learningLanguage,
context,
});
return jsonCommand(prompt, { ...options, schema });
};
const SYSTEM_PROMPT = `I speak {native_language}. You're my {learning_language} coach. I'am chatting with foreign friends. And I don't know what to say next.
{context}`;
const PROMPT = `Please provide me with at least 5 suggestions for what counld I say in {learning_language} and explain them in {native_language}.
Reply in JSON format only. The output should be structured like this:
{{
suggestions: [
{{
text: "suggestion in {learning_language}",
explaination: "explaination"
}}
]
}}`;

View File

@@ -8,3 +8,4 @@ export * from "./punctuate.command";
export * from "./summarize-topic.command";
export * from "./text.command";
export * from "./refine.command";
export * from "./chat-suggestion.command";

View File

@@ -31,7 +31,6 @@ export const jsonCommand = async (
configuration: {
baseURL: baseUrl,
},
cache: true,
verbose: true,
maxRetries: 1,
});

View File

@@ -697,6 +697,7 @@
"deleteChatAgentConfirmation": "Are you sure to delete this chat agent?",
"deleteMessage": "Delete message",
"deleteMessageConfirmation": "Are you sure to delete this message?",
"refine": "Refine",
"suggestion": "Suggestion",
"editChat": "Edit chat",
"newChat": "New chat",

View File

@@ -697,7 +697,8 @@
"deleteChatAgentConfirmation": "您确定要删除此角色吗?",
"deleteMessage": "删除消息",
"deleteMessageConfirmation": "您确定要删除此消息吗?",
"suggestion": "修改建议",
"refine": "修改润色",
"suggestion": "建议",
"editChat": "编辑聊天",
"newChat": "新聊天",
"addChat": "添加聊天",

View File

@@ -29,6 +29,7 @@ import {
MicIcon,
MoreVerticalIcon,
RotateCcwIcon,
Volume2Icon,
} from "lucide-react";
import { useContext, useEffect, useRef, useState } from "react";
import {
@@ -39,8 +40,11 @@ import { useAiCommand, useConversation } from "@renderer/hooks";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { md5 } from "js-md5";
export const ChatAgentMessage = (props: { chatMessage: ChatMessageType }) => {
const { chatMessage } = props;
export const ChatAgentMessage = (props: {
chatMessage: ChatMessageType;
isLastMessage: boolean;
}) => {
const { chatMessage, isLastMessage } = props;
const { dispatchChatMessages, setShadowing, onDeleteMessage } = useContext(
ChatSessionProviderContext
);
@@ -51,11 +55,12 @@ export const ChatAgentMessage = (props: { chatMessage: ChatMessageType }) => {
const [copied, setCopied] = useState<boolean>(false);
const [speeching, setSpeeching] = useState(false);
const [resourcing, setResourcing] = useState<boolean>(false);
const [displayContent, setDisplayContent] = useState(false);
const { tts } = useConversation();
const [translation, setTranslation] = useState<string>();
const [translating, setTranslating] = useState<boolean>(false);
const { translate, summarizeTopic } = useAiCommand();
const [displayContent, setDisplayContent] = useState(!isLastMessage);
const [displayPlayer, setDisplayPlayer] = useState(false);
const handleTranslate = async () => {
if (translating) return;
@@ -203,10 +208,22 @@ export const ChatAgentMessage = (props: { chatMessage: ChatMessageType }) => {
<div className="flex flex-col gap-4 px-4 py-2 mb-2 bg-background border rounded-lg shadow-sm w-full max-w-prose">
{Boolean(chatMessage.speech) ? (
<>
<WavesurferPlayer
id={chatMessage.speech.id}
src={chatMessage.speech.src}
/>
{displayPlayer ? (
<WavesurferPlayer
id={chatMessage.speech.id}
src={chatMessage.speech.src}
autoplay={true}
/>
) : (
<Button
onClick={() => setDisplayPlayer(true)}
className="w-8 h-8"
variant="ghost"
size="icon"
>
<Volume2Icon className="w-5 h-5" />
</Button>
)}
{displayContent && (
<>
<MarkdownWrapper className="select-text prose dark:prose-invert">

View File

@@ -7,16 +7,37 @@ import {
SendIcon,
StepForwardIcon,
TextIcon,
WandIcon,
XIcon,
} from "lucide-react";
import { Button, Textarea } from "@renderer/components/ui";
import { useContext, useEffect, useRef, useState } from "react";
import {
Button,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
ScrollArea,
Separator,
Textarea,
} from "@renderer/components/ui";
import { ReactElement, useContext, useEffect, useRef, useState } from "react";
import { LiveAudioVisualizer } from "react-audio-visualize";
import { ChatSessionProviderContext } from "@renderer/context";
import {
AppSettingsProviderContext,
ChatProviderContext,
ChatSessionProviderContext,
HotKeysSettingsProviderContext,
} from "@renderer/context";
import { t } from "i18next";
import autosize from "autosize";
import { LoaderSpin } from "@renderer/components";
import { useAiCommand } from "@renderer/hooks";
import { formatDateTime } from "@renderer/lib/utils";
import { md5 } from "js-md5";
import { useHotkeys } from "react-hotkeys-hook";
export const ChatInput = () => {
const { currentChat } = useContext(ChatProviderContext);
const {
submitting,
startRecording,
@@ -30,10 +51,12 @@ export const ChatInput = () => {
askAgent,
onCreateMessage,
} = useContext(ChatSessionProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const inputRef = useRef<HTMLTextAreaElement>(null);
const submitRef = useRef<HTMLButtonElement>(null);
const [inputMode, setInputMode] = useState<"text" | "audio">("audio");
const [content, setContent] = useState("");
const { currentHotkeys } = useContext(HotKeysSettingsProviderContext);
useEffect(() => {
if (!inputRef.current) return;
@@ -55,6 +78,38 @@ export const ChatInput = () => {
};
}, [inputRef.current]);
useEffect(() => {
EnjoyApp.cacheObjects
.get(`chat-input-mode-${currentChat.id}`)
.then((cachedInputMode) => {
if (cachedInputMode) {
setInputMode(cachedInputMode as typeof inputMode);
}
});
}, []);
useEffect(() => {
EnjoyApp.cacheObjects.set(`chat-input-mode-${currentChat.id}`, inputMode);
}, [inputMode]);
useHotkeys(
currentHotkeys.StartOrStopRecording,
() => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
},
{
preventDefault: true,
}
);
useHotkeys(currentHotkeys.PlayNextSegment, () => askAgent(), {
preventDefault: true,
});
if (isRecording) {
return (
<div className="w-full flex justify-center">
@@ -158,6 +213,16 @@ export const ChatInput = () => {
<SendIcon className="w-6 h-6" />
)}
</Button>
<ChatSuggestionButton asChild>
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("suggestion")}
variant="ghost"
size="icon"
>
<WandIcon className="w-6 h-6" />
</Button>
</ChatSuggestionButton>
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("continue")}
@@ -174,7 +239,7 @@ export const ChatInput = () => {
}
return (
<div className="w-full flex items-center gap-4 justify-center">
<div className="w-full flex items-center gap-4 justify-center relative">
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("textInput")}
@@ -200,13 +265,14 @@ export const ChatInput = () => {
<MicIcon className="w-6 h-6" />
)}
</Button>
<ChatSuggestionButton />
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("continue")}
disabled={submitting}
onClick={() => askAgent()}
className="rounded-full shadow w-8 h-8"
variant="secondary"
className="absolute right-4 rounded-full shadow w-8 h-8"
variant="default"
size="icon"
>
<StepForwardIcon className="w-4 h-4" />
@@ -214,3 +280,149 @@ export const ChatInput = () => {
</div>
);
};
const ChatSuggestionButton = (props: {
asChild?: boolean;
children?: ReactElement;
}) => {
const { currentChat } = useContext(ChatProviderContext);
const { chatMessages, onCreateMessage } = useContext(
ChatSessionProviderContext
);
const [suggestions, setSuggestions] = useState<
{ text: string; explaination: string }[]
>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { chatSuggestion } = useAiCommand();
const context = `I'm ${
currentChat.members.find((member) => member.user).user.name
}.
[Chat Topic]
${currentChat.topic}
[Chat Members]
${currentChat.members.map((m) => {
if (m.user) {
return `- ${m.user.name} (${m.config.introduction})[It's me]`;
} else if (m.agent) {
return `- ${m.agent.name} (${m.agent.introduction})`;
}
})}
[Chat History]
${chatMessages
.filter((m) => m.state === "completed")
.map(
(message) =>
`- ${(message.member.user || message.member.agent).name}: ${
message.content
}(${formatDateTime(message.createdAt)})`
)
.join("\n")}
`;
const contextCacheKey = `chat-suggestion-${md5(
chatMessages
.filter((m) => m.state === "completed")
.map((m) => m.content)
.join("\n")
)}`;
const suggest = async () => {
setLoading(true);
chatSuggestion(context, {
cacheKey: contextCacheKey,
})
.then((res) => setSuggestions(res.suggestions))
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (open && !suggestions?.length) {
suggest();
}
}, [open]);
useEffect(() => {
EnjoyApp.cacheObjects.get(contextCacheKey).then((result) => {
if (result && result?.suggestions) {
setSuggestions(result.suggestions as typeof suggestions);
} else {
setSuggestions([]);
}
});
}, [contextCacheKey]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{props.asChild ? (
{ ...props.children }
) : (
<Button
data-tooltip-id="chat-input-tooltip"
data-tooltip-content={t("suggestion")}
className="rounded-full shadow w-8 h-8"
variant="secondary"
size="icon"
>
<WandIcon className="w-4 h-4" />
</Button>
)}
</PopoverTrigger>
<PopoverContent side="top" className="bg-muted w-full max-w-screen-md">
{loading || suggestions.length === 0 ? (
<LoaderSpin />
) : (
<ScrollArea className="h-72 px-3">
<div className="select-text grid gap-6">
{suggestions.map((suggestion, index) => (
<div key={index} className="grid gap-4">
<div className="text-sm">{suggestion.explaination}</div>
<div className="px-4 py-2 rounded bg-background flex items-end justify-between space-x-2">
<div className="font-serif">{suggestion.text}</div>
<div>
<Button
data-tooltip-id="global-tooltip"
data-tooltip-content={t("send")}
variant="default"
size="icon"
className="rounded-full w-6 h-6"
onClick={() =>
onCreateMessage(suggestion.text).finally(() =>
setOpen(false)
)
}
>
<SendIcon className="w-3 h-3" />
</Button>
</div>
</div>
<Separator />
</div>
))}
<div className="flex justify-end">
<Button
disabled={loading}
variant="default"
size="sm"
onClick={() => suggest()}
>
{t("refresh")}
</Button>
</div>
</div>
</ScrollArea>
)}
<PopoverArrow />
</PopoverContent>
</Popover>
);
};

View File

@@ -5,8 +5,11 @@ import {
ChatSessionProviderContext,
} from "@renderer/context";
export const ChatMessage = (props: { chatMessage: ChatMessageType }) => {
const { chatMessage } = props;
export const ChatMessage = (props: {
chatMessage: ChatMessageType;
isLastMessage: boolean;
}) => {
const { chatMessage, isLastMessage } = props;
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { dispatchChatMessages } = useContext(ChatSessionProviderContext);
@@ -22,8 +25,18 @@ export const ChatMessage = (props: { chatMessage: ChatMessageType }) => {
}, [chatMessage]);
if (chatMessage.member?.userType === "User") {
return <ChatUserMessage chatMessage={props.chatMessage} />;
return (
<ChatUserMessage
chatMessage={props.chatMessage}
isLastMessage={isLastMessage}
/>
);
} else if (props.chatMessage.member?.userType === "Agent") {
return <ChatAgentMessage chatMessage={props.chatMessage} />;
return (
<ChatAgentMessage
chatMessage={props.chatMessage}
isLastMessage={isLastMessage}
/>
);
}
};

View File

@@ -6,11 +6,12 @@ import { useContext } from "react";
export const ChatMessages = () => {
const { chatMessages } = useContext(ChatSessionProviderContext);
const lastMessage = chatMessages[chatMessages.length - 1];
return (
<div className="flex-1 space-y-4 px-4 mb-4">
{chatMessages.map((message) => (
<ChatMessage key={message.id} chatMessage={message} />
<ChatMessage key={message.id} chatMessage={message} isLastMessage={lastMessage?.id === message.id} />
))}
</div>
);

View File

@@ -24,7 +24,7 @@ import { t } from "i18next";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
EditIcon,
@@ -33,7 +33,8 @@ import {
LoaderIcon,
MicIcon,
MoreVerticalIcon,
SparkleIcon,
SparklesIcon,
Volume2Icon,
} from "lucide-react";
import { useContext, useEffect, useRef, useState } from "react";
import {
@@ -45,44 +46,151 @@ import { useAiCommand } from "@renderer/hooks";
import { md5 } from "js-md5";
import { useCopyToClipboard } from "@uidotdev/usehooks";
export const ChatUserMessage = (props: { chatMessage: ChatMessageType }) => {
const { chatMessage } = props;
export const ChatUserMessage = (props: {
chatMessage: ChatMessageType;
isLastMessage: boolean;
}) => {
const { chatMessage, isLastMessage } = props;
const { recording } = chatMessage;
const ref = useRef<HTMLDivElement>(null);
const [editing, setEditing] = useState<boolean>(false);
const [content, setContent] = useState<string>(chatMessage.content);
const { onUpdateMessage } = useContext(ChatSessionProviderContext);
const [displayPlayer, setDisplayPlayer] = useState(isLastMessage);
useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({ behavior: "smooth" });
}
}, [ref]);
return (
<div ref={ref} className="mb-6">
<div className="flex items-center space-x-2 justify-end mb-2">
<div className="text-sm text-muted-foreground">
{chatMessage.member.user.name}
</div>
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage src={chatMessage.member.user.avatarUrl}></AvatarImage>
<AvatarFallback className="bg-background">
{chatMessage.member.user.name}
</AvatarFallback>
</Avatar>
</div>
<div className="flex justify-end">
<div className="flex flex-col gap-2 p-4 mb-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full max-w-prose">
{recording &&
(displayPlayer ? (
<>
<WavesurferPlayer
id={recording.id}
src={recording.src}
autoplay={true}
/>
{recording?.pronunciationAssessment && (
<div className="flex justify-end">
<PronunciationAssessmentScoreDetail
assessment={recording.pronunciationAssessment}
/>
</div>
)}
</>
) : (
<Button
onClick={() => setDisplayPlayer(true)}
className="w-8 h-8"
variant="ghost"
size="icon"
>
<Volume2Icon className="w-5 h-5" />
</Button>
))}
{editing ? (
<div className="">
<Textarea
className="bg-background mb-2"
value={content}
onChange={(event) => setContent(event.target.value)}
/>
<div className="flex justify-end space-x-4">
<Button
onClick={() => setEditing(false)}
variant="secondary"
size="sm"
>
{t("cancel")}
</Button>
<Button
onClick={() =>
onUpdateMessage(chatMessage.id, { content }).finally(() =>
setEditing(false)
)
}
variant="default"
size="sm"
>
{t("save")}
</Button>
</div>
</div>
) : (
<MarkdownWrapper className="select-text prose dark:prose-invert">
{chatMessage.content}
</MarkdownWrapper>
)}
<ChatUserMessageActions
chatMessage={chatMessage}
setContent={setContent}
setEditing={setEditing}
/>
</div>
</div>
<div className="flex justify-end text-xs text-muted-foreground timestamp">
{formatDateTime(chatMessage.createdAt)}
</div>
</div>
);
};
const ChatUserMessageActions = (props: {
chatMessage: ChatMessageType;
setContent: (content: string) => void;
setEditing: (value: boolean) => void;
}) => {
const { chatMessage, setContent, setEditing } = props;
const { recording } = chatMessage;
const [refinement, setRefinement] = useState<string>();
const [refining, setRefining] = useState<boolean>(false);
const [refinementVisible, setRefinementVisible] = useState<boolean>(true);
const { refine } = useAiCommand();
const [_, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const {
chatMessages,
startRecording,
isRecording,
isPaused,
assessing,
setAssessing,
onDeleteMessage,
onUpdateMessage,
submitting,
} = useContext(ChatSessionProviderContext);
const { currentChat } = useContext(ChatProviderContext);
const { recording } = chatMessage;
const ref = useRef<HTMLDivElement>(null);
const [suggestion, setSuggestion] = useState<string>();
const [suggesting, setSuggesting] = useState<boolean>(false);
const [suggestionVisible, setSuggestionVisible] = useState<boolean>(true);
const [editing, setEditing] = useState<boolean>(false);
const [content, setContent] = useState<string>(chatMessage.content);
const { refine } = useAiCommand();
const [_, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const handleSuggest = async (params?: { reload?: boolean }) => {
if (suggesting) return;
const handleRefine = async (params?: { reload?: boolean }) => {
if (refining) return;
if (!chatMessage.content) return;
const { reload = false } = params || {};
const cacheKey = `chat-message-suggestion-${md5(chatMessage.id)}`;
const cacheKey = `chat-message-refinement-${md5(chatMessage.id)}`;
try {
const cached = await EnjoyApp.cacheObjects.get(cacheKey);
if (cached && !reload && !suggestion) {
setSuggestion(cached);
if (cached && !reload && !refinement) {
setRefinement(cached);
} else {
setSuggesting(true);
setRefining(true);
const context = `I'm chatting in a chatroom. The previous messages are as follows:\n\n${buildChatHistory()}`;
const result = await refine(chatMessage.content, {
@@ -90,12 +198,12 @@ export const ChatUserMessage = (props: { chatMessage: ChatMessageType }) => {
context,
});
EnjoyApp.cacheObjects.set(cacheKey, result);
setSuggestion(result);
setSuggesting(false);
setRefinement(result);
setRefining(false);
}
} catch (err) {
toast.error(err.message);
setSuggesting(false);
setRefining(false);
}
};
@@ -147,200 +255,145 @@ export const ChatUserMessage = (props: { chatMessage: ChatMessageType }) => {
if (err) toast.error(err.message);
});
};
useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({ behavior: "smooth" });
}
}, [ref]);
return (
<div ref={ref} className="mb-6">
<div className="flex items-center space-x-2 justify-end mb-2">
<div className="text-sm text-muted-foreground">
{chatMessage.member.user.name}
</div>
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage src={chatMessage.member.user.avatarUrl}></AvatarImage>
<AvatarFallback className="bg-background">
{chatMessage.member.user.name}
</AvatarFallback>
</Avatar>
</div>
<div className="flex justify-end">
<div className="flex flex-col gap-2 px-4 py-2 mb-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full max-w-prose">
{recording && (
<WavesurferPlayer id={recording.id} src={recording.src} />
)}
{recording?.pronunciationAssessment && (
<div className="flex justify-end">
<PronunciationAssessmentScoreDetail
assessment={recording.pronunciationAssessment}
/>
</div>
)}
{editing ? (
<div className="">
<Textarea
className="bg-background mb-2"
value={content}
onChange={(event) => setContent(event.target.value)}
/>
<div className="flex justify-end space-x-4">
<Button
onClick={() => setEditing(false)}
variant="secondary"
size="sm"
>
{t("cancel")}
</Button>
<Button
onClick={() =>
onUpdateMessage(chatMessage.id, { content }).finally(() =>
setEditing(false)
)
}
variant="default"
size="sm"
>
{t("save")}
</Button>
</div>
</div>
) : (
<MarkdownWrapper className="select-text prose dark:prose-invert">
{chatMessage.content}
</MarkdownWrapper>
)}
{suggestion && (
<Collapsible
open={suggestionVisible}
onOpenChange={(value) => setSuggestionVisible(value)}
>
<CollapsibleContent>
<div className="p-4 font-serif bg-background rounded">
<MarkdownWrapper className="select-text prose dark:prose-invert">
{suggestion}
</MarkdownWrapper>
</div>
</CollapsibleContent>
<div className="my-2 flex justify-center">
<CollapsibleTrigger asChild>
<Button
onClick={() => setSuggestionVisible(!suggestionVisible)}
variant="ghost"
size="icon"
className="w-6 h-6"
>
{suggestionVisible ? (
<ChevronUpIcon className="w-4 h-4" />
) : (
<ChevronDownIcon className="w-4 h-4" />
)}
</Button>
</CollapsibleTrigger>
</div>
</Collapsible>
)}
<DropdownMenu>
<div className="flex items-center justify-end space-x-4">
{chatMessage.state === "pending" && (
<>
<EditIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("edit")}
className="w-4 h-4 cursor-pointer"
onClick={() => {
setContent(chatMessage.content);
setEditing(true);
}}
/>
{isPaused || isRecording || submitting ? (
<LoaderIcon className="w-4 h-4 animate-spin" />
) : (
<MicIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("reRecord")}
className="w-4 h-4 cursor-pointer"
onClick={startRecording}
/>
)}
</>
)}
{chatMessage.recording && (
<GaugeCircleIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("pronunciationAssessment")}
onClick={() => setAssessing(recording)}
className="w-4 h-4 cursor-pointer"
/>
)}
{suggesting ? (
<>
<DropdownMenu>
<div className="flex items-center justify-end space-x-4">
{chatMessage.state === "pending" && (
<>
{submitting ? (
<LoaderIcon className="w-4 h-4 animate-spin" />
) : (
<SparkleIcon
<EditIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("suggestion")}
className="w-4 h-4 cursor-pointer"
onClick={() => handleSuggest()}
/>
)}
{copied ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<CopyIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("copyText")}
data-tooltip-content={t("edit")}
className="w-4 h-4 cursor-pointer"
onClick={() => {
copyToClipboard(chatMessage.content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
setContent(chatMessage.content);
setEditing(true);
}}
/>
)}
<ConversationShortcuts
prompt={chatMessage.content}
excludedIds={[]}
trigger={
<ForwardIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("forward")}
className="w-4 h-4 cursor-pointer"
/>
}
/>
{Boolean(chatMessage.recording) && (
<DownloadIcon
{isPaused || isRecording || submitting ? (
<LoaderIcon className="w-4 h-4 animate-spin" />
) : (
<MicIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("download")}
data-testid="chat-message-download-recording"
onClick={handleDownload}
data-tooltip-content={t("reRecord")}
className="w-4 h-4 cursor-pointer"
onClick={startRecording}
/>
)}
<DropdownMenuTrigger>
<MoreVerticalIcon className="w-4 h-4" />
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDeleteMessage(chatMessage.id)}
>
<span className="mr-auto text-destructive capitalize">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{chatMessage.recording &&
(assessing ? (
<LoaderIcon className="w-4 h-4 animate-spin" />
) : (
<GaugeCircleIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("pronunciationAssessment")}
onClick={() => setAssessing(recording)}
className="w-4 h-4 cursor-pointer"
/>
))}
{refining ? (
<LoaderIcon className="w-4 h-4 animate-spin" />
) : (
<SparklesIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("refine")}
className="w-4 h-4 cursor-pointer"
onClick={() => handleRefine()}
/>
)}
{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(chatMessage.content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
}}
/>
)}
<ConversationShortcuts
prompt={chatMessage.content}
excludedIds={[]}
trigger={
<ForwardIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("forward")}
className="w-4 h-4 cursor-pointer"
/>
}
/>
{Boolean(chatMessage.recording) && (
<DownloadIcon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("download")}
data-testid="chat-message-download-recording"
onClick={handleDownload}
className="w-4 h-4 cursor-pointer"
/>
)}
<DropdownMenuTrigger>
<MoreVerticalIcon className="w-4 h-4" />
</DropdownMenuTrigger>
</div>
</div>
<div className="flex justify-end text-xs text-muted-foreground timestamp">
{formatDateTime(chatMessage.createdAt)}
</div>
</div>
<DropdownMenuContent>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => onDeleteMessage(chatMessage.id)}
>
<span className="mr-auto text-destructive capitalize">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{refinement && (
<div className="w-full bg-background rounded mt-4">
<Collapsible
open={refinementVisible}
onOpenChange={(value) => setRefinementVisible(value)}
>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between px-4 py-2 cursor-pointer">
<div className="flex items-center space-x-2">
<SparklesIcon className="w-4 h-4" />
<span className="text-sm">{t("refine")}</span>
</div>
<Button
onClick={() => setRefinementVisible(!refinementVisible)}
variant="ghost"
size="icon"
className="w-6 h-6"
>
{refinementVisible ? (
<ChevronRightIcon className="w-4 h-4" />
) : (
<ChevronDownIcon className="w-4 h-4" />
)}
</Button>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 font-serif border-t">
<MarkdownWrapper className="select-text prose dark:prose-invert">
{refinement}
</MarkdownWrapper>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</>
);
};

View File

@@ -21,6 +21,7 @@ export const WavesurferPlayer = (props: {
wavesurferOptions?: any;
pitchContourOptions?: any;
className?: string;
autoplay?: boolean;
}) => {
const {
id,
@@ -31,6 +32,7 @@ export const WavesurferPlayer = (props: {
wavesurferOptions,
pitchContourOptions,
className = "",
autoplay = false,
} = props;
const [initialized, setInitialized] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
@@ -61,6 +63,7 @@ export const WavesurferPlayer = (props: {
cursorWidth: 0,
autoCenter: true,
autoScroll: true,
autoplay,
dragToSeek: true,
hideScrollbar: true,
minPxPerSec: 100,

View File

@@ -29,6 +29,7 @@ import { ChatPromptTemplate } from "@langchain/core/prompts";
import { ChevronDownIcon } from "lucide-react";
import { AudioPlayer, RecordingDetail } from "@renderer/components";
import { CHAT_SYSTEM_PROMPT_TEMPLATE } from "@/constants";
import { formatDateTime } from "@renderer/lib/utils";
type ChatSessionProviderState = {
chatMessages: ChatMessageType[];
@@ -91,7 +92,9 @@ export const ChatSessionProvider = ({
children: React.ReactNode;
chat: ChatType;
}) => {
const { EnjoyApp, user, apiUrl } = useContext(AppSettingsProviderContext);
const { EnjoyApp, user, apiUrl, recorderConfig } = useContext(
AppSettingsProviderContext
);
const { openai } = useContext(AISettingsProviderContext);
const [submitting, setSubmitting] = useState(false);
const [shadowing, setShadowing] = useState<AudioType>(null);
@@ -115,7 +118,9 @@ export const ChatSessionProvider = ({
isPaused,
recordingTime,
mediaRecorder,
} = useAudioRecorder();
} = useAudioRecorder(recorderConfig, (exception) => {
toast.error(exception.message);
});
const { transcribe } = useTranscribe();
@@ -214,20 +219,23 @@ export const ChatSessionProvider = ({
(message) =>
`- ${(message.member.user || message.member.agent).name}: ${
message.content
}`
}(${formatDateTime(message.createdAt)})`
)
.join("\n"),
input:
chatMessages.length > 0
? "Say somthing to continue the conversation."
: "Say something to start the conversation.",
input: chatMessages.length > 0 ? "Continue" : "Start the conversation",
});
// the reply may contain the member's name like "Agent: xxx". We need to remove it.
const content = reply.content
.toString()
.replace(new RegExp(`^(${member.agent.name}):`), "")
.trim();
return EnjoyApp.chatMessages
.create({
chatId: chat.id,
memberId: member.id,
content: reply.content,
content,
state: "completed",
})
.then((message) =>

View File

@@ -11,6 +11,7 @@ import {
punctuateCommand,
summarizeTopicCommand,
refineCommand,
chatSuggestionCommand,
} from "@commands";
export const useAiCommand = () => {
@@ -170,6 +171,34 @@ export const useAiCommand = () => {
);
};
const chatSuggestion = async (
context: string,
options?: {
learningLanguage?: string;
nativeLanguage?: string;
cacheKey?: string;
}
) => {
const result = await chatSuggestionCommand(
{
context,
learningLanguage: options?.learningLanguage || learningLanguage,
nativeLanguage: options?.nativeLanguage || nativeLanguage,
},
{
key: currentEngine.key,
modelName: currentEngine.models.default,
baseUrl: currentEngine.baseUrl,
}
);
if (options.cacheKey) {
EnjoyApp.cacheObjects.set(options.cacheKey, result);
}
return result;
};
return {
lookupWord,
extractStory,
@@ -178,5 +207,6 @@ export const useAiCommand = () => {
punctuateText,
summarizeTopic,
refine,
chatSuggestion,
};
};