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:
59
enjoy/src/commands/chat-suggestion.command.ts
Normal file
59
enjoy/src/commands/chat-suggestion.command.ts
Normal 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"
|
||||
}}
|
||||
]
|
||||
}}`;
|
||||
@@ -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";
|
||||
|
||||
@@ -31,7 +31,6 @@ export const jsonCommand = async (
|
||||
configuration: {
|
||||
baseURL: baseUrl,
|
||||
},
|
||||
cache: true,
|
||||
verbose: true,
|
||||
maxRetries: 1,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -697,7 +697,8 @@
|
||||
"deleteChatAgentConfirmation": "您确定要删除此角色吗?",
|
||||
"deleteMessage": "删除消息",
|
||||
"deleteMessageConfirmation": "您确定要删除此消息吗?",
|
||||
"suggestion": "修改建议",
|
||||
"refine": "修改润色",
|
||||
"suggestion": "建议",
|
||||
"editChat": "编辑聊天",
|
||||
"newChat": "新聊天",
|
||||
"addChat": "添加聊天",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user