mirror of
https://github.com/QuivrHQ/quivr.git
synced 2024-12-15 09:32:22 +03:00
feat: add onboarding first step (#1303)
* refactor: split <MessageRow /> into small components * feat: add onboarding page first step
This commit is contained in:
parent
160588cfae
commit
63e90a317c
@ -1,3 +1,5 @@
|
||||
import { useFeatureIsOn } from "@growthbook/growthbook-react";
|
||||
|
||||
import { useChatContext } from "@/lib/context";
|
||||
|
||||
import { ChatDialogue } from "./components/ChatDialogue";
|
||||
@ -11,8 +13,10 @@ export const ChatDialogueArea = (): JSX.Element => {
|
||||
messages,
|
||||
notifications
|
||||
);
|
||||
const shouldDisplayOnboarding = useFeatureIsOn("onboarding");
|
||||
|
||||
const shouldDisplayShortcuts = chatItems.length === 0;
|
||||
const shouldDisplayShortcuts =
|
||||
chatItems.length === 0 && !shouldDisplayOnboarding;
|
||||
|
||||
if (!shouldDisplayShortcuts) {
|
||||
return <ChatDialogue chatItems={chatItems} />;
|
||||
|
@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RiDownloadLine } from "react-icons/ri";
|
||||
|
||||
import Button from "@/lib/components/ui/Button";
|
||||
|
||||
import { useStreamText } from "./hooks/useStreamText";
|
||||
import { MessageRow } from "../QADisplay";
|
||||
|
||||
export const Onboarding = (): JSX.Element => {
|
||||
const { t } = useTranslation(["chat"]);
|
||||
const assistantMessage = t("onboarding.step_1_message_1");
|
||||
const step1Text = t("onboarding.step_1_message_2");
|
||||
|
||||
const { streamingText: streamingAssistantMessage, isDone: isAssistantDone } =
|
||||
useStreamText(assistantMessage);
|
||||
const { streamingText: streamingStep1Text, isDone: isStep1Done } =
|
||||
useStreamText(step1Text, isAssistantDone);
|
||||
|
||||
return (
|
||||
<MessageRow speaker={"assistant"} brainName={"Quivr"}>
|
||||
<p>{streamingAssistantMessage}</p>
|
||||
<div>
|
||||
{streamingStep1Text}
|
||||
{isStep1Done && isAssistantDone && (
|
||||
<Link
|
||||
href="/documents/doc.pdf"
|
||||
download
|
||||
target="_blank"
|
||||
referrerPolicy="no-referrer"
|
||||
>
|
||||
<Button className="bg-black p-2 ml-2 rounded-full inline-flex">
|
||||
<RiDownloadLine />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</MessageRow>
|
||||
);
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useStreamText = (text: string, enabled = true) => {
|
||||
const [streamingText, setStreamingText] = useState<string>("");
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const isDone = currentIndex === text.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStreamingText("");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const messageInterval = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
setStreamingText((prevText) => prevText + (text[currentIndex] ?? ""));
|
||||
setCurrentIndex((prevIndex) => prevIndex + 1);
|
||||
} else {
|
||||
clearInterval(messageInterval);
|
||||
}
|
||||
}, 30);
|
||||
|
||||
return () => {
|
||||
clearInterval(messageInterval);
|
||||
};
|
||||
}, [text, currentIndex, enabled]);
|
||||
|
||||
return { streamingText, isDone };
|
||||
};
|
@ -1,49 +1,35 @@
|
||||
import React, { useState } from "react";
|
||||
import { FaCheckCircle, FaCopy } from "react-icons/fa";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { MessageContent } from "./components/MessageContent";
|
||||
import { QuestionBrain } from "./components/QuestionBrain";
|
||||
import { QuestionPrompt } from "./components/QuestionPrompt";
|
||||
import { useMessageRow } from "./hooks/useMessageRow";
|
||||
|
||||
type MessageRowProps = {
|
||||
speaker: string;
|
||||
text: string;
|
||||
speaker: "user" | "assistant";
|
||||
text?: string;
|
||||
brainName?: string | null;
|
||||
promptName?: string | null;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MessageRow = React.forwardRef(
|
||||
(
|
||||
{ speaker, text, brainName, promptName }: MessageRowProps,
|
||||
{ speaker, text, brainName, promptName, children }: MessageRowProps,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const isUserSpeaker = speaker === "user";
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => setIsCopied(true),
|
||||
(err) => console.error("Failed to copy!", err)
|
||||
);
|
||||
setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds
|
||||
};
|
||||
|
||||
const containerClasses = cn(
|
||||
"py-3 px-5 w-fit",
|
||||
isUserSpeaker
|
||||
? "bg-msg-gray bg-opacity-60 items-start"
|
||||
: "bg-msg-purple bg-opacity-60 items-end",
|
||||
"dark:bg-gray-800 rounded-3xl flex flex-col overflow-hidden scroll-pb-32"
|
||||
);
|
||||
|
||||
const containerWrapperClasses = cn(
|
||||
"flex flex-col",
|
||||
isUserSpeaker ? "items-end" : "items-start"
|
||||
);
|
||||
|
||||
const markdownClasses = cn("prose", "dark:prose-invert");
|
||||
const {
|
||||
containerClasses,
|
||||
containerWrapperClasses,
|
||||
handleCopy,
|
||||
isCopied,
|
||||
isUserSpeaker,
|
||||
markdownClasses,
|
||||
} = useMessageRow({
|
||||
speaker,
|
||||
text,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerWrapperClasses}>
|
||||
@ -53,19 +39,16 @@ export const MessageRow = React.forwardRef(
|
||||
<QuestionBrain brainName={brainName} />
|
||||
<QuestionPrompt promptName={promptName} />
|
||||
</div>
|
||||
{!isUserSpeaker && (
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 transition"
|
||||
onClick={handleCopy}
|
||||
title={isCopied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{isCopied ? <FaCheckCircle /> : <FaCopy />}
|
||||
</button>
|
||||
{!isUserSpeaker && text !== undefined && (
|
||||
<CopyButton handleCopy={handleCopy} isCopied={isCopied} />
|
||||
)}
|
||||
</div>
|
||||
<div data-testid="chat-message-text">
|
||||
<ReactMarkdown className={markdownClasses}>{text}</ReactMarkdown>
|
||||
</div>
|
||||
{children ?? (
|
||||
<MessageContent
|
||||
text={text ?? ""}
|
||||
markdownClasses={markdownClasses}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { FaCheckCircle, FaCopy } from "react-icons/fa";
|
||||
|
||||
type CopyButtonProps = {
|
||||
handleCopy: () => void;
|
||||
isCopied: boolean;
|
||||
};
|
||||
|
||||
export const CopyButton = ({
|
||||
handleCopy,
|
||||
isCopied,
|
||||
}: CopyButtonProps): JSX.Element => (
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 transition"
|
||||
onClick={handleCopy}
|
||||
title={isCopied ? "Copied!" : "Copy to clipboard"}
|
||||
>
|
||||
{isCopied ? <FaCheckCircle /> : <FaCopy />}
|
||||
</button>
|
||||
);
|
@ -0,0 +1,13 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export const MessageContent = ({
|
||||
text,
|
||||
markdownClasses,
|
||||
}: {
|
||||
text: string;
|
||||
markdownClasses: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="chat-message-text">
|
||||
<ReactMarkdown className={markdownClasses}>{text}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type UseMessageRowProps = {
|
||||
speaker: "user" | "assistant";
|
||||
text?: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useMessageRow = ({ speaker, text }: UseMessageRowProps) => {
|
||||
const isUserSpeaker = speaker === "user";
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (text === undefined) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => setIsCopied(true),
|
||||
(err) => console.error("Failed to copy!", err)
|
||||
);
|
||||
setTimeout(() => setIsCopied(false), 2000); // Reset after 2 seconds
|
||||
};
|
||||
|
||||
const containerClasses = cn(
|
||||
"py-3 px-5 w-fit",
|
||||
isUserSpeaker ? "bg-msg-gray bg-opacity-60" : "bg-msg-purple bg-opacity-60",
|
||||
"dark:bg-gray-800 rounded-3xl flex flex-col overflow-hidden scroll-pb-32"
|
||||
);
|
||||
|
||||
const containerWrapperClasses = cn(
|
||||
"flex flex-col",
|
||||
isUserSpeaker ? "items-end" : "items-start"
|
||||
);
|
||||
|
||||
const markdownClasses = cn("prose", "dark:prose-invert");
|
||||
|
||||
return {
|
||||
isUserSpeaker,
|
||||
isCopied,
|
||||
handleCopy,
|
||||
containerClasses,
|
||||
containerWrapperClasses,
|
||||
markdownClasses,
|
||||
};
|
||||
};
|
@ -1,7 +1,13 @@
|
||||
import { useFeatureIsOn } from "@growthbook/growthbook-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatItem } from "./components";
|
||||
import { Onboarding } from "./components/Onboarding/Onboarding";
|
||||
import { useChatDialogue } from "./hooks/useChatDialogue";
|
||||
import {
|
||||
chatDialogueContainerClassName,
|
||||
chatItemContainerClassName,
|
||||
} from "./styles";
|
||||
import { getKeyFromChatItem } from "./utils/getKeyFromChatItem";
|
||||
import { ChatItemWithGroupedNotifications } from "../../types";
|
||||
|
||||
@ -15,17 +21,23 @@ export const ChatDialogue = ({
|
||||
const { t } = useTranslation(["chat"]);
|
||||
const { chatListRef } = useChatDialogue();
|
||||
|
||||
const shouldDisplayOnboarding = useFeatureIsOn("onboarding");
|
||||
|
||||
if (shouldDisplayOnboarding) {
|
||||
return (
|
||||
<div className={chatDialogueContainerClassName} ref={chatListRef}>
|
||||
<Onboarding />
|
||||
<div className={chatItemContainerClassName}>
|
||||
{chatItems.map((chatItem) => (
|
||||
<ChatItem key={getKeyFromChatItem(chatItem)} content={chatItem} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
ref={chatListRef}
|
||||
>
|
||||
<div className={chatDialogueContainerClassName} ref={chatListRef}>
|
||||
{chatItems.length === 0 ? (
|
||||
<div
|
||||
data-testid="empty-history-message"
|
||||
@ -34,7 +46,7 @@ export const ChatDialogue = ({
|
||||
{t("ask", { ns: "chat" })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className={chatItemContainerClassName}>
|
||||
{chatItems.map((chatItem) => (
|
||||
<ChatItem key={getKeyFromChatItem(chatItem)} content={chatItem} />
|
||||
))}
|
||||
|
@ -0,0 +1,4 @@
|
||||
export const chatItemContainerClassName = "flex flex-col gap-3";
|
||||
|
||||
export const chatDialogueContainerClassName =
|
||||
"flex flex-col flex-1 overflow-y-auto mb-10";
|
198
frontend/public/documents/doc.pdf
Normal file
198
frontend/public/documents/doc.pdf
Normal file
@ -0,0 +1,198 @@
|
||||
%PDF-1.3
|
||||
%âãÏÓ
|
||||
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Outlines 2 0 R
|
||||
/Pages 3 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Outlines
|
||||
/Count 0
|
||||
>>
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Count 2
|
||||
/Kids [ 4 0 R 6 0 R ]
|
||||
>>
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 3 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 9 0 R
|
||||
>>
|
||||
/ProcSet 8 0 R
|
||||
>>
|
||||
/MediaBox [0 0 612.0000 792.0000]
|
||||
/Contents 5 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<< /Length 1074 >>
|
||||
stream
|
||||
2 J
|
||||
BT
|
||||
0 0 0 rg
|
||||
/F1 0027 Tf
|
||||
57.3750 722.2800 Td
|
||||
( A Simple PDF File ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 688.6080 Td
|
||||
( This is a small demonstration .pdf file - ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 664.7040 Td
|
||||
( just for use in the Virtual Mechanics tutorials. More text. And more ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 652.7520 Td
|
||||
( text. And more text. And more text. And more text. ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 628.8480 Td
|
||||
( And more text. And more text. And more text. And more text. And more ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 616.8960 Td
|
||||
( text. And more text. Boring, zzzzz. And more text. And more text. And ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 604.9440 Td
|
||||
( more text. And more text. And more text. And more text. And more text. ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 592.9920 Td
|
||||
( And more text. And more text. ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 569.0880 Td
|
||||
( And more text. And more text. And more text. And more text. And more ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 557.1360 Td
|
||||
( text. And more text. And more text. Even more. Continued on page 2 ...) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
6 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 3 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 9 0 R
|
||||
>>
|
||||
/ProcSet 8 0 R
|
||||
>>
|
||||
/MediaBox [0 0 612.0000 792.0000]
|
||||
/Contents 7 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
7 0 obj
|
||||
<< /Length 676 >>
|
||||
stream
|
||||
2 J
|
||||
BT
|
||||
0 0 0 rg
|
||||
/F1 0027 Tf
|
||||
57.3750 722.2800 Td
|
||||
( Simple PDF File 2 ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 688.6080 Td
|
||||
( ...continued from page 1. Yet more text. And more text. And more text. ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 676.6560 Td
|
||||
( And more text. And more text. And more text. And more text. And more ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 664.7040 Td
|
||||
( text. Oh, how boring typing this stuff. But not as boring as watching ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 652.7520 Td
|
||||
( paint dry. And more text. And more text. And more text. And more text. ) Tj
|
||||
ET
|
||||
BT
|
||||
/F1 0010 Tf
|
||||
69.2500 640.8000 Td
|
||||
( Boring. More, a little more text. The end, and just as well. ) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
8 0 obj
|
||||
[/PDF /Text]
|
||||
endobj
|
||||
|
||||
9 0 obj
|
||||
<<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/Name /F1
|
||||
/BaseFont /Helvetica
|
||||
/Encoding /WinAnsiEncoding
|
||||
>>
|
||||
endobj
|
||||
|
||||
10 0 obj
|
||||
<<
|
||||
/Creator (Rave \(http://www.nevrona.com/rave\))
|
||||
/Producer (Nevrona Designs)
|
||||
/CreationDate (D:20060301072826)
|
||||
>>
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 11
|
||||
0000000000 65535 f
|
||||
0000000019 00000 n
|
||||
0000000093 00000 n
|
||||
0000000147 00000 n
|
||||
0000000222 00000 n
|
||||
0000000390 00000 n
|
||||
0000001522 00000 n
|
||||
0000001690 00000 n
|
||||
0000002423 00000 n
|
||||
0000002456 00000 n
|
||||
0000002574 00000 n
|
||||
|
||||
trailer
|
||||
<<
|
||||
/Size 11
|
||||
/Root 1 0 R
|
||||
/Info 10 0 R
|
||||
>>
|
||||
|
||||
startxref
|
||||
2714
|
||||
%%EOF
|
@ -36,5 +36,9 @@
|
||||
"new_prompt": "Create new prompt",
|
||||
"feed_brain_placeholder": "Choose which @brain you want to feed with these files",
|
||||
"feedingBrain": "Your newly added knowledge is being processed, you can keep chatting in the meantime !",
|
||||
"add_content_card_button_tooltip": "Add knowledge to a brain"
|
||||
"add_content_card_button_tooltip": "Add knowledge to a brain",
|
||||
"onboarding":{
|
||||
"step_1_message_1": "Hi 👋🏻 Want to discover Quivr ? 😇",
|
||||
"step_1_message_2": "Step 1: Download “Quivr documentation”"
|
||||
}
|
||||
}
|
@ -37,5 +37,9 @@
|
||||
"new_prompt": "Crear nueva instrucción",
|
||||
"feed_brain_placeholder" : "Elige cuál @cerebro quieres alimentar con estos archivos",
|
||||
"feedingBrain": "Su conocimiento recién agregado se está procesando, ¡puede seguir chateando mientras tanto!",
|
||||
"add_content_card_button_tooltip": "Agregar conocimiento a un cerebro"
|
||||
"add_content_card_button_tooltip": "Agregar conocimiento a un cerebro",
|
||||
"onboarding":{
|
||||
"step_1_message_1": "Hola 👋🏻 ¿Quieres descubrir Quivr? 😇",
|
||||
"step_1_message_2": "Paso 1: Descargar la documentación de “Quivr”"
|
||||
}
|
||||
}
|
||||
|
@ -37,5 +37,9 @@
|
||||
"new_prompt": "Créer un nouveau prompt",
|
||||
"feed_brain_placeholder" : "Choisissez le @cerveau que vous souhaitez nourrir avec ces fichiers",
|
||||
"feedingBrain": "Vos nouvelles connaissances sont en cours de traitement. Vous pouvez continuer à discuter en attendant !",
|
||||
"add_content_card_button_tooltip": "Ajouter des connaissances à un cerveau"
|
||||
"add_content_card_button_tooltip": "Ajouter des connaissances à un cerveau",
|
||||
"onboarding":{
|
||||
"step_1_message_1": "Salut 👋🏻 Envie de découvrir Quivr ? 😇",
|
||||
"step_1_message_2": "Étape 1 : Téléchargez la documentation de “Quivr”"
|
||||
}
|
||||
}
|
||||
|
@ -37,5 +37,9 @@
|
||||
"new_prompt": "Criar novo prompt",
|
||||
"feed_brain_placeholder" : "Escolha qual @cérebro você deseja alimentar com esses arquivos",
|
||||
"feedingBrain": "Seu conhecimento recém-adicionado está sendo processado, você pode continuar conversando enquanto isso!",
|
||||
"add_content_card_button_tooltip": "Adicionar conhecimento a um cérebro"
|
||||
"add_content_card_button_tooltip": "Adicionar conhecimento a um cérebro",
|
||||
"onboarding":{
|
||||
"step_1_message_1": "Oi 👋🏻 Quer descobrir o Quivr? 😇",
|
||||
"step_1_message_2": "Passo 1: Baixe a documentação do 'Quivr'"
|
||||
}
|
||||
}
|
||||
|
@ -37,5 +37,9 @@
|
||||
"new_prompt": "Создать новый запрос",
|
||||
"feed_brain_placeholder" : "Выберите, какой @мозг вы хотите питать этими файлами",
|
||||
"feedingBrain": "Ваш недавно добавленный знаний обрабатывается, вы можете продолжить общение в это время!",
|
||||
"add_content_card_button_tooltip": "Добавить знаний в мозг"
|
||||
"add_content_card_button_tooltip": "Добавить знаний в мозг",
|
||||
"onboarding":{
|
||||
"step_1_message_1": "Привет 👋🏻 Хочешь узнать о Quivr? 😇",
|
||||
"step_1_message_2": "Шаг 1: Скачайте документацию Quivr"
|
||||
}
|
||||
}
|
||||
|
@ -37,5 +37,9 @@
|
||||
"new_prompt": "新提示",
|
||||
"feed_brain_placeholder" : "选择要用这些文件喂养的 @大脑",
|
||||
"feedingBrain": "您新添加的知识正在处理中,同时您可以继续聊天!",
|
||||
"add_content_card_button_tooltip": "向大脑添加知识"
|
||||
"add_content_card_button_tooltip": "向大脑添加知识",
|
||||
"onboarding":{
|
||||
"step_1_message_1": "你好 👋🏻 想要发现 Quivr 吗? 😇",
|
||||
"step_1_message_2": "步骤 1:下载“Quivr 文档”"
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user