feat: support edit message and delete message (#51) and improve working state

This commit is contained in:
Zhang Minghan 2024-01-29 12:15:33 +08:00
parent a3a9e5a525
commit fc391d28b0
14 changed files with 123 additions and 44 deletions

View File

@ -76,7 +76,8 @@ export class Conversation {
const idx = ev.index ?? -1; const idx = ev.index ?? -1;
if (this.isValidIndex(idx)) { if (this.isValidIndex(idx)) {
delete this.data[idx]; this.data.splice(idx, 1);
this.sendRemoveEvent(idx); this.sendRemoveEvent(idx);
this.triggerCallback(); this.triggerCallback();
} }
@ -116,21 +117,11 @@ export class Conversation {
} }
public sendEditEvent(id: number, message: string) { public sendEditEvent(id: number, message: string) {
this.sendEvent( this.sendEvent("edit", `${id}:${message}`);
"edit",
JSON.stringify({
message: `${id}:${message}`,
}),
);
} }
public sendRemoveEvent(id: number) { public sendRemoveEvent(id: number) {
this.sendEvent( this.sendEvent("remove", id.toString());
"remove",
JSON.stringify({
message: `${id}`,
}),
);
} }
public sendShareEvent(refer: string) { public sendShareEvent(refer: string) {

View File

@ -81,6 +81,10 @@ strong {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
&.gold {
color: hsl(var(--gold));
}
} }
.flex-dialog { .flex-dialog {

View File

@ -446,7 +446,7 @@
transition: 0.2s; transition: 0.2s;
opacity: 0; opacity: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
outline: 0; outline: none;
&:hover { &:hover {
color: hsl(var(--text)); color: hsl(var(--text));

View File

@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import "@/assets/common/editor.less"; import "@/assets/common/editor.less";
import { Textarea } from "./ui/textarea.tsx"; import { Textarea } from "./ui/textarea.tsx";
import Markdown from "./Markdown.tsx"; import Markdown from "./Markdown.tsx";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Toggle } from "./ui/toggle.tsx"; import { Toggle } from "./ui/toggle.tsx";
import { mobile } from "@/utils/device.ts"; import { mobile } from "@/utils/device.ts";
import { Button } from "./ui/button.tsx"; import { Button } from "./ui/button.tsx";
@ -22,9 +22,25 @@ type RichEditorProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
maxLength?: number; maxLength?: number;
open?: boolean;
setOpen?: (open: boolean) => void;
children?: React.ReactNode;
submittable?: boolean;
onSubmit?: (value: string) => void;
closeOnSubmit?: boolean;
}; };
function RichEditor({ value, onChange, maxLength }: RichEditorProps) { function RichEditor({
value,
onChange,
maxLength,
submittable,
onSubmit,
setOpen,
closeOnSubmit,
}: RichEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const input = useRef(null); const input = useRef(null);
const [openPreview, setOpenPreview] = useState(!mobile); const [openPreview, setOpenPreview] = useState(!mobile);
@ -113,7 +129,7 @@ function RichEditor({ value, onChange, maxLength }: RichEditorProps) {
> >
{openInput && ( {openInput && (
<Textarea <Textarea
placeholder={t("chat.placeholder")} placeholder={t("chat.placeholder-raw")}
value={value} value={value}
className={`editor-input`} className={`editor-input`}
id={`editor`} id={`editor`}
@ -127,6 +143,26 @@ function RichEditor({ value, onChange, maxLength }: RichEditorProps) {
)} )}
</div> </div>
</div> </div>
{submittable && (
<div className={`editor-footer mt-2 flex flex-row`}>
<Button
variant={`outline`}
className={`ml-auto mr-2`}
onClick={() => setOpen?.(false)}
>
{t("cancel")}
</Button>
<Button
variant={`default`}
onClick={() => {
onSubmit?.(value);
(closeOnSubmit ?? true) && setOpen?.(false);
}}
>
{t("submit")}
</Button>
</div>
)}
</div> </div>
); );
} }
@ -136,12 +172,16 @@ function EditorProvider(props: RichEditorProps) {
return ( return (
<> <>
<Dialog> <Dialog open={props.open} onOpenChange={props.setOpen}>
<DialogTrigger asChild> {!props.setOpen && (
<ChatAction text={t("editor")}> <DialogTrigger asChild>
<Maximize className={`h-4 w-4`} /> {props.children ?? (
</ChatAction> <ChatAction text={t("editor")}>
</DialogTrigger> <Maximize className={`h-4 w-4`} />
</ChatAction>
)}
</DialogTrigger>
)}
<DialogContent className={`editor-dialog flex-dialog`}> <DialogContent className={`editor-dialog flex-dialog`}>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("edit")}</DialogTitle> <DialogTitle>{t("edit")}</DialogTitle>

View File

@ -1,6 +1,7 @@
import { Message } from "@/api/types.ts"; import { Message } from "@/api/types.ts";
import Markdown from "@/components/Markdown.tsx"; import Markdown from "@/components/Markdown.tsx";
import { import {
CalendarCheck2,
CircleSlash, CircleSlash,
Cloud, Cloud,
CloudFog, CloudFog,
@ -17,7 +18,7 @@ import {
import { filterMessage } from "@/utils/processor.ts"; import { filterMessage } from "@/utils/processor.ts";
import { copyClipboard, saveAsFile, useInputValue } from "@/utils/dom.ts"; import { copyClipboard, saveAsFile, useInputValue } from "@/utils/dom.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Ref, useMemo, useRef } from "react"; import { Ref, useMemo, useRef, useState } from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -26,6 +27,7 @@ import {
} from "@/components/ui/dropdown-menu.tsx"; } from "@/components/ui/dropdown-menu.tsx";
import { cn } from "@/components/ui/lib/utils.ts"; import { cn } from "@/components/ui/lib/utils.ts";
import Tips from "@/components/Tips.tsx"; import Tips from "@/components/Tips.tsx";
import EditorProvider from "@/components/EditorProvider.tsx";
type MessageProps = { type MessageProps = {
index: number; index: number;
@ -68,8 +70,18 @@ function MessageQuota({ message }: MessageQuotaProps) {
return ( return (
message.quota && message.quota &&
message.quota !== 0 && ( message.quota !== 0 && (
<Tips classNamePopup={`icon-tooltip justify-center`} trigger={trigger}> <Tips
<CloudFog className={`h-4 w-4 mr-2`} /> classNamePopup={cn(
"icon-tooltip justify-center",
message.plan && "gold",
)}
trigger={trigger}
>
{message.plan ? (
<CalendarCheck2 className={`h-4 w-4 mr-2`} />
) : (
<CloudFog className={`h-4 w-4 mr-2`} />
)}
<p>{message.quota.toFixed(6)}</p> <p>{message.quota.toFixed(6)}</p>
</Tips> </Tips>
) )
@ -80,6 +92,9 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isAssistant = message.role === "assistant"; const isAssistant = message.role === "assistant";
const [open, setOpen] = useState(false);
const [editedMessage, setEditedMessage] = useState<string | undefined>("");
return ( return (
<div <div
className={cn( className={cn(
@ -87,6 +102,14 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
isAssistant ? "flex-row" : "flex-row-reverse", isAssistant ? "flex-row" : "flex-row-reverse",
)} )}
> >
<EditorProvider
submittable={true}
onSubmit={(value) => onEvent && onEvent("edit", index, value)}
open={open}
setOpen={setOpen}
value={editedMessage ?? ""}
onChange={setEditedMessage}
/>
<div className={`message-content`}> <div className={`message-content`}>
{message.content.length ? ( {message.content.length ? (
<Markdown children={message.content} /> <Markdown children={message.content} />
@ -135,11 +158,13 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
<MousePointerSquare className={`h-4 w-4 mr-1.5`} /> <MousePointerSquare className={`h-4 w-4 mr-1.5`} />
{t("message.use")} {t("message.use")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem onClick={() => setOpen(true)}>
<PencilLine className={`h-4 w-4 mr-1.5`} /> <PencilLine className={`h-4 w-4 mr-1.5`} />
{t("message.edit")} {t("message.edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem
onClick={() => onEvent && onEvent("remove", index)}
>
<Trash className={`h-4 w-4 mr-1.5`} /> <Trash className={`h-4 w-4 mr-1.5`} />
{t("message.remove")} {t("message.remove")}
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -8,11 +8,10 @@ import { chatEvent } from "@/events/chat.ts";
import { addEventListeners } from "@/utils/dom.ts"; import { addEventListeners } from "@/utils/dom.ts";
type ChatInterfaceProps = { type ChatInterfaceProps = {
setWorking?: (working: boolean) => void;
setTarget: (target: HTMLDivElement | null) => void; setTarget: (target: HTMLDivElement | null) => void;
}; };
function ChatInterface({ setTarget, setWorking }: ChatInterfaceProps) { function ChatInterface({ setTarget }: ChatInterfaceProps) {
const ref = React.useRef(null); const ref = React.useRef(null);
const messages: Message[] = useSelector(selectMessages); const messages: Message[] = useSelector(selectMessages);
const current: number = useSelector(selectCurrent); const current: number = useSelector(selectCurrent);
@ -29,12 +28,6 @@ function ChatInterface({ setTarget, setWorking }: ChatInterfaceProps) {
[messages], [messages],
); );
useEffect(() => {
const end = messages[messages.length - 1].end;
const working = messages.length > 0 && !(end === undefined ? true : end);
setWorking?.(working);
}, [messages]);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
const el = ref.current as HTMLDivElement; const el = ref.current as HTMLDivElement;

View File

@ -20,7 +20,7 @@ import {
} from "@/components/ui/dialog.tsx"; } from "@/components/ui/dialog.tsx";
import { getLanguage } from "@/i18n.ts"; import { getLanguage } from "@/i18n.ts";
import { selectAuthenticated } from "@/store/auth.ts"; import { selectAuthenticated } from "@/store/auth.ts";
import { docsEndpoint, useDeeptrain } from "@/conf/env.ts"; import { appLogo, docsEndpoint, useDeeptrain } from "@/conf/env.ts";
function ChatSpace() { function ChatSpace() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -32,6 +32,12 @@ function ChatSpace() {
return ( return (
<div className={`chat-product`}> <div className={`chat-product`}>
<img
src={appLogo}
className={`chat-logo h-20 w-20 translate-y-[-2rem]`}
alt={``}
/>
{useDeeptrain && ( {useDeeptrain && (
<Button variant={`outline`} onClick={() => setOpen(true)}> <Button variant={`outline`} onClick={() => setOpen(true)}>
<Users2 className={`h-4 w-4 mr-1.5`} /> <Users2 className={`h-4 w-4 mr-1.5`} />

View File

@ -46,6 +46,13 @@ type InterfaceProps = {
function Interface(props: InterfaceProps) { function Interface(props: InterfaceProps) {
const messages = useSelector(selectMessages); const messages = useSelector(selectMessages);
useEffect(() => {
const end =
messages.length > 0 && (messages[messages.length - 1].end ?? true);
const working = messages.length > 0 && !end;
props.setWorking?.(working);
}, [messages]);
return messages.length > 0 ? <ChatInterface {...props} /> : <ChatSpace />; return messages.length > 0 ? <ChatInterface {...props} /> : <ChatSpace />;
} }

View File

@ -60,6 +60,7 @@ function ConversationSegment({
}} }}
> >
<DropdownMenuTrigger <DropdownMenuTrigger
className={`outline-none`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();

View File

@ -36,7 +36,7 @@ function ChatInput({
onValueChange(e.target.value); onValueChange(e.target.value);
setMemory("history", e.target.value); setMemory("history", e.target.value);
}} }}
placeholder={t("chat.placeholder")} placeholder={sender ? t("chat.placeholder-enter") : t("chat.placeholder")}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
if (sender || e.ctrlKey) { if (sender || e.ctrlKey) {

View File

@ -43,6 +43,7 @@
"upward": "上移", "upward": "上移",
"downward": "下移", "downward": "下移",
"save": "保存", "save": "保存",
"submit": "提交",
"announcement": "站点公告", "announcement": "站点公告",
"i-know": "我已知晓", "i-know": "我已知晓",
"auth": { "auth": {
@ -125,6 +126,8 @@
"web": "联网搜索", "web": "联网搜索",
"web-aria": "切换网络搜索功能", "web-aria": "切换网络搜索功能",
"placeholder": "写点什么... (Ctrl+Enter 发送)", "placeholder": "写点什么... (Ctrl+Enter 发送)",
"placeholder-enter": "写点什么... (Enter 发送)",
"placeholder-raw": "写点什么...",
"recall": "历史复原", "recall": "历史复原",
"recall-desc": "检测到您上次有未发送的消息,已经为您恢复。", "recall-desc": "检测到您上次有未发送的消息,已经为您恢复。",
"recall-cancel": "取消" "recall-cancel": "取消"

View File

@ -78,7 +78,9 @@
"placeholder": "Write something... (Ctrl+Enter to send)", "placeholder": "Write something... (Ctrl+Enter to send)",
"recall": "History Recall", "recall": "History Recall",
"recall-desc": "Detected that you have unsent messages last time, has been restored for you.", "recall-desc": "Detected that you have unsent messages last time, has been restored for you.",
"recall-cancel": "Cancel" "recall-cancel": "Cancel",
"placeholder-enter": "Write something... (Enter to send)",
"placeholder-raw": "Write something..."
}, },
"message": { "message": {
"copy": "Copy Message", "copy": "Copy Message",
@ -617,5 +619,6 @@
"downward": "Move down", "downward": "Move down",
"save": "Save", "save": "Save",
"announcement": "Site Announcement", "announcement": "Site Announcement",
"i-know": "Yes, I understand." "i-know": "Yes, I understand.",
"submit": "Send"
} }

View File

@ -78,7 +78,9 @@
"placeholder": "何か書いてください... Ctrl + Enterで送信", "placeholder": "何か書いてください... Ctrl + Enterで送信",
"recall": "履歴の回復", "recall": "履歴の回復",
"recall-desc": "最後に未送信のメッセージが検出され、復元されました。", "recall-desc": "最後に未送信のメッセージが検出され、復元されました。",
"recall-cancel": "キャンセル" "recall-cancel": "キャンセル",
"placeholder-enter": "何か書いてください... 送信するにはEnterキーを押してください",
"placeholder-raw": "何か書いてください..."
}, },
"message": { "message": {
"copy": "メッセージをコピー", "copy": "メッセージをコピー",
@ -617,5 +619,6 @@
"downward": "下へ移動", "downward": "下へ移動",
"save": "保存", "save": "保存",
"announcement": "サイトのお知らせ", "announcement": "サイトのお知らせ",
"i-know": "私は知っています" "i-know": "私は知っています",
"submit": "提出"
} }

View File

@ -78,7 +78,9 @@
"placeholder": "Напишите что-нибудь... (Ctrl+Enter для отправки)", "placeholder": "Напишите что-нибудь... (Ctrl+Enter для отправки)",
"recall": "История", "recall": "История",
"recall-desc": "Обнаружено, что у вас есть неотправленные сообщения в прошлый раз, они были восстановлены для вас.", "recall-desc": "Обнаружено, что у вас есть неотправленные сообщения в прошлый раз, они были восстановлены для вас.",
"recall-cancel": "Отмена" "recall-cancel": "Отмена",
"placeholder-enter": "Напишите что-нибудь... (Введите, чтобы отправить)",
"placeholder-raw": "Напишите что-нибудь..."
}, },
"message": { "message": {
"copy": "Копировать сообщение", "copy": "Копировать сообщение",
@ -617,5 +619,6 @@
"downward": "Ниже", "downward": "Ниже",
"save": "Сохранить", "save": "Сохранить",
"announcement": "Объявление о площадке", "announcement": "Объявление о площадке",
"i-know": "Мне известно о" "i-know": "Мне известно о",
"submit": "передавать"
} }