feat: update quota tooltip and article generation style mobile adapter, edit message and delete message alpha version (#51)

This commit is contained in:
Zhang Minghan 2024-01-26 10:39:50 +08:00
parent 06ccf4e25c
commit a3a9e5a525
25 changed files with 271 additions and 142 deletions

View File

@ -31,8 +31,6 @@ type ChatProps struct {
RequestProps
Model string
Plan bool
Infinity bool
Message []globals.Message
Token int
PresencePenalty *float32
@ -56,7 +54,7 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
Message: props.Message,
Token: utils.Multi(
props.Token == 0,
utils.Multi(props.Infinity || props.Plan, nil, utils.ToPtr(2500)),
utils.ToPtr(2500),
&props.Token,
),
PresencePenalty: props.PresencePenalty,
@ -74,7 +72,7 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
Message: props.Message,
Token: utils.Multi(
props.Token == 0,
utils.Multi(props.Infinity || props.Plan, nil, utils.ToPtr(2500)),
utils.ToPtr(2500),
&props.Token,
),
PresencePenalty: props.PresencePenalty,
@ -136,7 +134,7 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
return dashscope.NewChatInstanceFromConfig(conf).CreateStreamChatRequest(&dashscope.ChatProps{
Model: model,
Message: props.Message,
Token: utils.Multi(props.Infinity || props.Plan, 2048, props.Token),
Token: props.Token,
Temperature: props.Temperature,
TopP: props.TopP,
TopK: props.TopK,
@ -164,7 +162,7 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
return skylark.NewChatInstanceFromConfig(conf).CreateStreamChatRequest(&skylark.ChatProps{
Model: model,
Message: props.Message,
Token: utils.Multi(props.Token == 0, 4096, props.Token),
Token: props.Token,
TopP: props.TopP,
TopK: props.TopK,
Temperature: props.Temperature,
@ -178,7 +176,7 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
return zhinao.NewChatInstanceFromConfig(conf).CreateStreamChatRequest(&zhinao.ChatProps{
Model: model,
Message: props.Message,
Token: utils.Multi(props.Infinity || props.Plan, nil, utils.ToPtr(2048)),
Token: &props.Token,
TopP: props.TopP,
TopK: props.TopK,
Temperature: props.Temperature,
@ -193,13 +191,9 @@ func createChatRequest(conf globals.ChannelConfig, props *ChatProps, hook global
case globals.OneAPIChannelType:
return oneapi.NewChatInstanceFromConfig(conf).CreateStreamChatRequest(&oneapi.ChatProps{
Model: model,
Message: props.Message,
Token: utils.Multi(
props.Token == 0,
utils.Multi(props.Plan || props.Infinity, nil, utils.ToPtr(2500)),
&props.Token,
),
Model: model,
Message: props.Message,
Token: &props.Token,
PresencePenalty: props.PresencePenalty,
FrequencyPenalty: props.FrequencyPenalty,
Temperature: props.Temperature,

View File

@ -18,11 +18,9 @@ func CreateGeneration(group, model, prompt, path string, plan bool, hook func(bu
buffer := utils.NewBuffer(model, message, channel.ChargeInstance.GetCharge(model))
err := channel.NewChatRequest(group, &adapter.ChatProps{
Model: model,
Message: message,
Plan: plan,
Infinity: true,
Buffer: *buffer,
Model: model,
Message: message,
Buffer: *buffer,
}, func(data string) error {
buffer.Write(data)
hook(buffer, data)

View File

@ -61,6 +61,27 @@ export class Conversation {
this.sendRestartEvent();
break;
case "edit":
const index = ev.index ?? -1;
const message = ev.message ?? "";
if (this.isValidIndex(index)) {
this.data[index].content = message;
this.sendEditEvent(index, message);
this.triggerCallback();
}
break;
case "remove":
const idx = ev.index ?? -1;
if (this.isValidIndex(idx)) {
delete this.data[idx];
this.sendRemoveEvent(idx);
this.triggerCallback();
}
break;
default:
console.debug(
`[conversation] unknown event: ${ev.event} (from: ${ev.id})`,
@ -82,6 +103,10 @@ export class Conversation {
this.sendEvent("stop");
}
public isValidIndex(idx: number): boolean {
return idx >= 0 && idx < this.data.length;
}
public sendRestartEvent() {
this.sendEvent("restart");
}
@ -90,6 +115,24 @@ export class Conversation {
this.sendEvent("mask", JSON.stringify(mask.context));
}
public sendEditEvent(id: number, message: string) {
this.sendEvent(
"edit",
JSON.stringify({
message: `${id}:${message}`,
}),
);
}
public sendRemoveEvent(id: number) {
this.sendEvent(
"remove",
JSON.stringify({
message: `${id}`,
}),
);
}
public sendShareEvent(refer: string) {
this.sendEvent("share", refer);
}

View File

@ -30,6 +30,12 @@
align-items: center;
}
.article-action {
@media (max-width: 768px) {
flex-direction: column;
}
}
.article-content {
display: flex;
flex-direction: column;

View File

@ -63,7 +63,6 @@
.content-wrapper {
display: flex;
flex-direction: row;
max-width: 100%;
.message-toolbar {

View File

@ -6,7 +6,7 @@ import {
DialogTitle,
DialogTrigger,
} from "./ui/dialog.tsx";
import { Maximize, Image, MenuSquare, PanelRight, XSquare } from "lucide-react";
import { Maximize, Image, MenuSquare, PanelRight, Eraser } from "lucide-react";
import { useTranslation } from "react-i18next";
import "@/assets/common/editor.less";
import { Textarea } from "./ui/textarea.tsx";
@ -64,7 +64,7 @@ function RichEditor({ value, onChange, maxLength }: RichEditorProps) {
className={`h-8 w-8 p-0`}
onClick={() => value && onChange("")}
>
<XSquare className={`h-3.5 w-3.5`} />
<Eraser className={`h-3.5 w-3.5`} />
</Button>
<div className={`grow`} />
<Toggle

View File

@ -9,72 +9,84 @@ import {
Loader2,
MoreVertical,
MousePointerSquare,
PencilLine,
Power,
RotateCcw,
Trash,
} from "lucide-react";
import { filterMessage } from "@/utils/processor.ts";
import { copyClipboard, saveAsFile, useInputValue } from "@/utils/dom.ts";
import { useTranslation } from "react-i18next";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import { Ref, useRef } from "react";
import { Ref, useMemo, useRef } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { cn } from "@/components/ui/lib/utils.ts";
import Tips from "@/components/Tips.tsx";
type MessageProps = {
index: number;
message: Message;
end?: boolean;
onEvent?: (event: string) => void;
onEvent?: (event: string, index?: number, message?: string) => void;
ref?: Ref<HTMLElement>;
};
function MessageSegment(props: MessageProps) {
const { t } = useTranslation();
const ref = useRef(null);
const { message } = props;
return (
<div className={`message ${message.role}`} ref={ref}>
<MessageContent {...props} />
{message.quota && message.quota !== 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`message-quota ${
message.plan ? "subscription" : ""
}`}
>
<Cloud className={`h-4 w-4 icon`} />
<span className={`quota`}>
{(message.quota < 0 ? 0 : message.quota).toFixed(2)}
</span>
</div>
</TooltipTrigger>
<TooltipContent className={`icon-tooltip`}>
<CloudFog className={`h-4 w-4 mr-2`} />
<p>{t("quota-description")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<MessageQuota message={message} />
</div>
);
}
function MessageContent({ message, end, onEvent }: MessageProps) {
const { t } = useTranslation();
type MessageQuotaProps = {
message: Message;
};
function MessageQuota({ message }: MessageQuotaProps) {
const trigger = useMemo(
() =>
message.quota && (
<div className={cn("message-quota", message.plan && "subscription")}>
<Cloud className={`h-4 w-4 icon`} />
<span className={`quota`}>
{(message.quota < 0 ? 0 : message.quota).toFixed(2)}
</span>
</div>
),
[message],
);
return (
<div className={`content-wrapper`}>
message.quota &&
message.quota !== 0 && (
<Tips classNamePopup={`icon-tooltip justify-center`} trigger={trigger}>
<CloudFog className={`h-4 w-4 mr-2`} />
<p>{message.quota.toFixed(6)}</p>
</Tips>
)
);
}
function MessageContent({ message, end, index, onEvent }: MessageProps) {
const { t } = useTranslation();
const isAssistant = message.role === "assistant";
return (
<div
className={cn(
"content-wrapper",
isAssistant ? "flex-row" : "flex-row-reverse",
)}
>
<div className={`message-content`}>
{message.content.length ? (
<Markdown children={message.content} />
@ -84,62 +96,67 @@ function MessageContent({ message, end, onEvent }: MessageProps) {
<Loader2 className={`h-5 w-5 m-1 animate-spin`} />
)}
</div>
{message.role === "assistant" && (
<div className={`message-toolbar`}>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreVertical className={`h-4 w-4 m-0.5`} />
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`}>
{end && (
<DropdownMenuItem
onClick={() =>
onEvent &&
onEvent(message.end !== false ? "restart" : "stop")
}
>
{message.end !== false ? (
<>
<RotateCcw className={`h-4 w-4 mr-1.5`} />
{t("message.restart")}
</>
) : (
<>
<Power className={`h-4 w-4 mr-1.5`} />
{t("message.stop")}
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => copyClipboard(filterMessage(message.content))}
>
<Copy className={`h-4 w-4 mr-1.5`} />
{t("message.copy")}
</DropdownMenuItem>
<div className={`message-toolbar`}>
<DropdownMenu>
<DropdownMenuTrigger className={`outline-none`}>
<MoreVertical className={`h-4 w-4 m-0.5`} />
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`}>
{isAssistant && end && (
<DropdownMenuItem
onClick={() =>
useInputValue("input", filterMessage(message.content))
onEvent && onEvent(message.end !== false ? "restart" : "stop")
}
>
<MousePointerSquare className={`h-4 w-4 mr-1.5`} />
{t("message.use")}
{message.end !== false ? (
<>
<RotateCcw className={`h-4 w-4 mr-1.5`} />
{t("message.restart")}
</>
) : (
<>
<Power className={`h-4 w-4 mr-1.5`} />
{t("message.stop")}
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
saveAsFile(
`message-${message.role}.txt`,
filterMessage(message.content),
)
}
>
<File className={`h-4 w-4 mr-1.5`} />
{t("message.save")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
)}
<DropdownMenuItem
onClick={() => copyClipboard(filterMessage(message.content))}
>
<Copy className={`h-4 w-4 mr-1.5`} />
{t("message.copy")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
useInputValue("input", filterMessage(message.content))
}
>
<MousePointerSquare className={`h-4 w-4 mr-1.5`} />
{t("message.use")}
</DropdownMenuItem>
<DropdownMenuItem>
<PencilLine className={`h-4 w-4 mr-1.5`} />
{t("message.edit")}
</DropdownMenuItem>
<DropdownMenuItem>
<Trash className={`h-4 w-4 mr-1.5`} />
{t("message.remove")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
saveAsFile(
`message-${message.role}.txt`,
filterMessage(message.content),
)
}
>
<File className={`h-4 w-4 mr-1.5`} />
{t("message.save")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@ -15,12 +15,21 @@ import {
type TipsProps = {
content?: string;
trigger?: React.ReactNode;
children?: React.ReactNode;
className?: string;
classNamePopup?: string;
hideTimeout?: number;
};
function Tips({ content, children, className, hideTimeout }: TipsProps) {
function Tips({
content,
trigger,
children,
className,
classNamePopup,
hideTimeout,
}: TipsProps) {
const timeout = hideTimeout ?? 2500;
const comp = useMemo(
() => (
@ -49,14 +58,17 @@ function Tips({ content, children, className, hideTimeout }: TipsProps) {
<TooltipProvider>
<Tooltip open={tooltip} onOpenChange={setTooltip}>
<TooltipTrigger asChild>
<HelpCircle className={cn("tips-icon", className)} />
{trigger ?? <HelpCircle className={cn("tips-icon", className)} />}
</TooltipTrigger>
<TooltipContent>{comp}</TooltipContent>
<TooltipContent className={classNamePopup}>{comp}</TooltipContent>
</Tooltip>
</TooltipProvider>
</DropdownMenuTrigger>
<DropdownMenuContent
className={"px-3 py-1.5 cursor-pointer text-sm"}
className={cn(
"px-3 py-1.5 cursor-pointer text-sm min-w-0",
classNamePopup,
)}
side={`top`}
>
{comp}

View File

@ -64,13 +64,16 @@ function ChatInterface({ setTarget, setWorking }: ChatInterfaceProps) {
<MessageSegment
message={message}
end={i === messages.length - 1}
onEvent={(e: string) => {
onEvent={(e: string, index?: number, message?: string) => {
connectionEvent.emit({
id: current,
event: e,
index,
message,
});
}}
key={i}
index={i}
/>
))}
</div>

View File

@ -21,9 +21,10 @@ function ChatInput({
onEnterPressed,
}: ChatInputProps) {
const { t } = useTranslation();
const [pressed, setPressed] = React.useState(false);
const sender = useSelector(senderSelector);
// sender: Ctrl + Enter if false, Enter if true
return (
<Textarea
id={`input`}
@ -37,20 +38,16 @@ function ChatInput({
}}
placeholder={t("chat.placeholder")}
onKeyDown={async (e) => {
if (e.key === "Control") {
setPressed(true);
} else if (e.key === "Enter" && !e.shiftKey) {
if (sender || pressed) {
if (e.key === "Enter") {
if (sender || e.ctrlKey) {
// condition sender: Ctrl + Enter if false, Enter if true
// condition e.ctrlKey: Ctrl + Enter if true, Enter if false
e.preventDefault();
onEnterPressed();
}
}
}}
onKeyUp={(e) => {
if (e.key === "Control") {
setTimeout(() => setPressed(false), 250);
}
}}
/>
);
}

View File

@ -3,6 +3,8 @@ import { EventCommitter } from "./struct.ts";
export type ConnectionEvent = {
id: number;
event: string;
index?: number;
message?: string;
};
export const connectionEvent = new EventCommitter<ConnectionEvent>({

View File

@ -133,7 +133,9 @@
"copy": "复制消息",
"save": "保存为文件",
"use": "使用消息",
"edit": "编辑消息",
"stop": "暂停回答",
"remove": "删除消息",
"restart": "重新回答",
"copy-area": "复制选中区域"
},

View File

@ -86,7 +86,9 @@
"use": "Use Message",
"stop": "Pause Answer",
"restart": "Restart Answer",
"copy-area": "Copy Selected Area"
"copy-area": "Copy Selected Area",
"edit": "Edit messages",
"remove": "Delete a Message"
},
"quota-description": "spending quota for the message",
"buy": {

View File

@ -86,7 +86,9 @@
"use": "メッセージを使用する",
"stop": "回答を一時停止",
"restart": "再回答",
"copy-area": "選択を複製"
"copy-area": "選択を複製",
"edit": "メッセージを編集",
"remove": "メッセージを削除"
},
"quota-description": "メッセージのクレジット使用額",
"buy": {

View File

@ -86,7 +86,9 @@
"use": "Использовать сообщение",
"stop": "Приостановить ответ",
"restart": "Перезапустить ответ",
"copy-area": "Копировать выбранную область"
"copy-area": "Копировать выбранную область",
"edit": "Редактировать сообщение",
"remove": "Удалить сообщение"
},
"quota-description": "квота расходов на сообщение",
"buy": {

View File

@ -33,16 +33,20 @@ function GenerateProgress({ current, total }: ProgressProps) {
return (
<div className={`article-progress w-full mb-4`}>
<p
className={`select-none mt-4 mb-2.5 flex flex-row items-center content-center mx-auto w-max`}
className={`select-none mt-4 mb-2.5 flex flex-row items-center content-center w-full justify-center text-center`}
>
{total !== 0 && current === total ? (
<>
<Check className={`h-5 w-5 mr-2 inline-block animate-out`} />
<Check
className={`h-5 w-5 mr-2 inline-block animate-out shrink-0`}
/>
{t("article.generate-success")}
</>
) : (
<>
<Loader2 className={`h-5 w-5 mr-2 inline-block animate-spin`} />
<Loader2
className={`h-5 w-5 mr-2 inline-block animate-spin shrink-0`}
/>
{t("article.progress-title", { current, total })}
</>
)}
@ -117,10 +121,10 @@ function ArticleContent() {
<>
<GenerateProgress {...state} />
{hash && (
<div className={`flex flex-row items-center mb-6`}>
<div className={`article-action flex flex-row items-center my-4 gap-4`}>
<Button
variant={`outline`}
className={`mt-5 w-full mr-2`}
className={`w-full whitespace-nowrap`}
onClick={() => {
location.href = `${apiEndpoint}/article/download/zip?hash=${hash}`;
}}
@ -131,7 +135,7 @@ function ArticleContent() {
<Button
variant={`outline`}
className={`mt-5 w-full ml-2`}
className={`w-full whitespace-nowrap`}
onClick={() => {
location.href = `${apiEndpoint}/article/download/tar?hash=${hash}`;
}}

View File

@ -41,7 +41,7 @@ function SharingForm({ refer, data }: SharingFormProps) {
</div>
<div className={`body`}>
{data.messages.map((message, i) => (
<MessageSegment message={message} key={i} />
<MessageSegment message={message} key={i} index={i} />
))}
</div>
<div className={`action`}>

View File

@ -12,10 +12,10 @@ export const settingsSlice = createSlice({
name: "settings",
initialState: {
dialog: false,
context: getBooleanMemory("context", true),
align: getBooleanMemory("align", false),
history: getNumberMemory("history_context", 8),
sender: getBooleanMemory("sender", false),
context: getBooleanMemory("context", true), // keep context
align: getBooleanMemory("align", false), // chat textarea align center
history: getNumberMemory("history_context", 8), // max history context length
sender: getBooleanMemory("sender", false), // sender (false: Ctrl + Enter, true: Enter)
},
reducers: {
toggleDialog: (state) => {

View File

@ -97,7 +97,6 @@ func ChatHandler(conn *Connection, user *auth.User, instance *conversation.Conve
&adapter.ChatProps{
Model: model,
Message: segment,
Plan: plan,
Buffer: *buffer,
},
func(data string) error {

View File

@ -71,7 +71,6 @@ func getChatProps(form RelayForm, messages []globals.Message, buffer *utils.Buff
return &adapter.ChatProps{
Model: form.Model,
Message: messages,
Plan: plan,
Token: utils.Multi(form.MaxTokens == 0, 2500, form.MaxTokens),
PresencePenalty: form.PresencePenalty,
FrequencyPenalty: form.FrequencyPenalty,

View File

@ -44,7 +44,6 @@ func NativeChatHandler(c *gin.Context, user *auth.User, model string, message []
auth.GetGroup(db, user),
&adapter.ChatProps{
Model: model,
Plan: plan,
Message: segment,
Buffer: *buffer,
},

View File

@ -16,6 +16,8 @@ const (
RestartType = "restart"
ShareType = "share"
MaskType = "mask"
EditType = "edit"
RemoveType = "remove"
)
type Stack chan *conversation.FormMessage

View File

@ -297,3 +297,17 @@ func (c *Conversation) RemoveMessage(index int) globals.Message {
func (c *Conversation) RemoveLatestMessage() globals.Message {
return c.RemoveMessage(len(c.Message) - 1)
}
func (c *Conversation) EditMessage(index int, message string) {
if index < 0 || index >= len(c.Message) {
return
}
c.Message[index].Content = message
}
func (c *Conversation) DeleteMessage(index int) {
if index < 0 || index >= len(c.Message) {
return
}
c.Message = append(c.Message[:index], c.Message[index+1:]...)
}

View File

@ -57,11 +57,10 @@ func ImagesRelayAPI(c *gin.Context) {
createRelayImageObject(c, form, prompt, created, user, false)
}
func getImageProps(form RelayImageForm, messages []globals.Message, buffer *utils.Buffer, plan bool) *adapter.ChatProps {
func getImageProps(form RelayImageForm, messages []globals.Message, buffer *utils.Buffer) *adapter.ChatProps {
return &adapter.ChatProps{
Model: form.Model,
Message: messages,
Plan: plan,
Token: 2500,
Buffer: *buffer,
}
@ -90,7 +89,7 @@ func createRelayImageObject(c *gin.Context, form RelayImageForm, prompt string,
}
buffer := utils.NewBuffer(form.Model, messages, channel.ChargeInstance.GetCharge(form.Model))
err := channel.NewChatRequest(auth.GetGroup(db, user), getImageProps(form, messages, buffer, plan), func(data string) error {
err := channel.NewChatRequest(auth.GetGroup(db, user), getImageProps(form, messages, buffer), func(data string) error {
buffer.Write(data)
return nil
})

View File

@ -34,6 +34,25 @@ func ParseAuth(c *gin.Context, token string) *auth.User {
return auth.ParseToken(c, token)
}
func splitMessage(message string) (int, string, error) {
parts := strings.SplitN(message, ":", 2)
if len(parts) == 2 {
if id, err := strconv.Atoi(parts[0]); err == nil {
return id, parts[1], nil
}
}
return 0, message, fmt.Errorf("message type error")
}
func getId(message string) (int, error) {
if id, err := strconv.Atoi(message); err == nil {
return id, nil
}
return 0, fmt.Errorf("message type error")
}
func ChatAPI(c *gin.Context) {
var conn *utils.WebSocket
if conn = utils.NewWebsocket(c, false); conn == nil {
@ -84,6 +103,21 @@ func ChatAPI(c *gin.Context) {
instance.SaveResponse(db, response)
case MaskType:
instance.LoadMask(form.Message)
case EditType:
if id, message, err := splitMessage(form.Message); err == nil {
instance.EditMessage(id, message)
instance.SaveConversation(db)
} else {
return err
}
case RemoveType:
id, err := getId(form.Message)
if err != nil {
return err
}
instance.RemoveMessage(id)
instance.SaveConversation(db)
}
return nil