mirror of
https://github.com/coaidev/coai.git
synced 2025-05-23 23:10:13 +09:00
feat: support edit message and delete message (#51) and improve working state
This commit is contained in:
parent
a3a9e5a525
commit
fc391d28b0
@ -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) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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));
|
||||||
|
@ -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}>
|
||||||
|
{!props.setOpen && (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
{props.children ?? (
|
||||||
<ChatAction text={t("editor")}>
|
<ChatAction text={t("editor")}>
|
||||||
<Maximize className={`h-4 w-4`} />
|
<Maximize className={`h-4 w-4`} />
|
||||||
</ChatAction>
|
</ChatAction>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent className={`editor-dialog flex-dialog`}>
|
<DialogContent className={`editor-dialog flex-dialog`}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("edit")}</DialogTitle>
|
<DialogTitle>{t("edit")}</DialogTitle>
|
||||||
|
@ -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
|
||||||
|
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`} />
|
<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>
|
||||||
|
@ -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;
|
||||||
|
@ -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`} />
|
||||||
|
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,6 +60,7 @@ function ConversationSegment({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
|
className={`outline-none`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -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) {
|
||||||
|
@ -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": "取消"
|
||||||
|
@ -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"
|
||||||
}
|
}
|
@ -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": "提出"
|
||||||
}
|
}
|
@ -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": "передавать"
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user