From 7cf6bd1ed8ebf6bd465ca1773eeaeaf49c11d083 Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Wed, 6 Mar 2024 09:31:01 +0800 Subject: [PATCH] feat: support rename conversation, support vision capability for claude-3 models and fix high context bug (#83) --- adapter/azure/processor.go | 2 +- adapter/chatgpt/processor.go | 2 +- app/src/api/common.ts | 4 +- app/src/api/history.ts | 15 +++++ app/src/assets/pages/chat.less | 1 + app/src/assets/pages/home.less | 15 +++-- app/src/components/FileProvider.tsx | 2 +- app/src/components/Message.tsx | 2 +- .../components/home/ConversationSegment.tsx | 55 +++++++++++++++---- app/src/resources/i18n/cn.json | 2 + app/src/resources/i18n/en.json | 6 +- app/src/resources/i18n/ja.json | 6 +- app/src/resources/i18n/ru.json | 6 +- app/src/store/chat.ts | 13 +++++ globals/variables.go | 16 ++---- manager/conversation/api.go | 40 ++++++++++++++ manager/conversation/router.go | 1 + manager/conversation/storage.go | 8 +++ utils/image.go | 2 +- 19 files changed, 159 insertions(+), 39 deletions(-) diff --git a/adapter/azure/processor.go b/adapter/azure/processor.go index d5c1c17..1f14234 100644 --- a/adapter/azure/processor.go +++ b/adapter/azure/processor.go @@ -9,7 +9,7 @@ import ( ) func formatMessages(props *ChatProps) interface{} { - if globals.IsOpenAIVisionModels(props.Model) { + if globals.IsVisionModel(props.Model) { return utils.Each[globals.Message, Message](props.Message, func(message globals.Message) Message { if message.Role == globals.User { raw, urls := utils.ExtractImages(message.Content, true) diff --git a/adapter/chatgpt/processor.go b/adapter/chatgpt/processor.go index 5b433d4..0f6f729 100644 --- a/adapter/chatgpt/processor.go +++ b/adapter/chatgpt/processor.go @@ -9,7 +9,7 @@ import ( ) func formatMessages(props *ChatProps) interface{} { - if globals.IsOpenAIVisionModels(props.Model) { + if globals.IsVisionModel(props.Model) { return utils.Each[globals.Message, Message](props.Message, func(message globals.Message) Message { if message.Role == globals.User { content, urls := utils.ExtractImages(message.Content, true) diff --git a/app/src/api/common.ts b/app/src/api/common.ts index 8b64e38..c076605 100644 --- a/app/src/api/common.ts +++ b/app/src/api/common.ts @@ -2,6 +2,7 @@ export type CommonResponse = { status: boolean; error?: string; reason?: string; + message?: string; data?: any; }; @@ -17,6 +18,7 @@ export function toastState( else toast({ title: t("error"), - description: state.error ?? state.reason ?? "error occurred", + description: + state.error ?? state.reason ?? state.message ?? "error occurred", }); } diff --git a/app/src/api/history.ts b/app/src/api/history.ts index 279457e..dba4b16 100644 --- a/app/src/api/history.ts +++ b/app/src/api/history.ts @@ -2,6 +2,8 @@ import axios from "axios"; import type { ConversationInstance } from "./types.tsx"; import { setHistory } from "@/store/chat.ts"; import { AppDispatch } from "@/store"; +import { CommonResponse } from "@/api/common.ts"; +import { getErrorMessage } from "@/utils/base.ts"; export async function getConversationList(): Promise { try { @@ -46,6 +48,19 @@ export async function deleteConversation(id: number): Promise { } } +export async function renameConversation( + id: number, + name: string, +): Promise { + try { + const resp = await axios.post("/conversation/rename", { id, name }); + return resp.data as CommonResponse; + } catch (e) { + console.warn(e); + return { status: false, error: getErrorMessage(e) }; + } +} + export async function deleteAllConversations(): Promise { try { const resp = await axios.get("/conversation/clean"); diff --git a/app/src/assets/pages/chat.less b/app/src/assets/pages/chat.less index 33f7fe8..a4e52f4 100644 --- a/app/src/assets/pages/chat.less +++ b/app/src/assets/pages/chat.less @@ -149,6 +149,7 @@ border-radius: var(--radius); text-align: center; font-size: 0.785rem; + padding: 0.125rem; } flex-shrink: 0; diff --git a/app/src/assets/pages/home.less b/app/src/assets/pages/home.less index 509ff40..b5e4101 100644 --- a/app/src/assets/pages/home.less +++ b/app/src/assets/pages/home.less @@ -445,11 +445,14 @@ .more { color: hsl(var(--text-secondary)); - display: none; + scale: 0; + opacity: 1; transition: 0.2s; - opacity: 0; + transition-property: color, opacity; border: 1px solid var(--border); outline: none; + height: 0; + width: 0; &:hover { color: hsl(var(--text)); @@ -461,12 +464,14 @@ border-color: hsl(var(--border-hover)); .id { - display: none; + scale: 0; } .more { - display: block; - opacity: 1; + scale: 1; + height: 1.25rem; + width: 1.25rem; + padding: 0.125rem; } } diff --git a/app/src/components/FileProvider.tsx b/app/src/components/FileProvider.tsx index 93b2369..d187d5c 100644 --- a/app/src/components/FileProvider.tsx +++ b/app/src/components/FileProvider.tsx @@ -117,7 +117,7 @@ function FileProvider({ value, onChange }: FileProviderProps) { ); if ( file.content.length > MaxPromptSize && - isHighContextModel(supportModels, model) + !isHighContextModel(supportModels, model) ) { file.content = file.content.slice(0, MaxPromptSize); toast({ diff --git a/app/src/components/Message.tsx b/app/src/components/Message.tsx index 91c1990..f0b2a8f 100644 --- a/app/src/components/Message.tsx +++ b/app/src/components/Message.tsx @@ -126,7 +126,7 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) { isUser ? ( ) : ( - {``} + {``} ) } > diff --git a/app/src/components/home/ConversationSegment.tsx b/app/src/components/home/ConversationSegment.tsx index abe6741..06c303e 100644 --- a/app/src/components/home/ConversationSegment.tsx +++ b/app/src/components/home/ConversationSegment.tsx @@ -5,6 +5,7 @@ import { Loader2, MessageSquare, MoreHorizontal, + PencilLine, Share2, Trash2, } from "lucide-react"; @@ -20,6 +21,9 @@ import { ConversationInstance } from "@/api/types.tsx"; import { useState } from "react"; import { closeMarket, useConversationActions } from "@/store/chat.ts"; import { cn } from "@/components/ui/lib/utils.ts"; +import PopupDialog, { popupTypes } from "@/components/PopupDialog.tsx"; +import { toastState } from "@/api/common.ts"; +import { useToast } from "@/components/ui/use-toast.ts"; type ConversationSegmentProps = { conversation: ConversationInstance; @@ -37,9 +41,13 @@ function ConversationSegment({ const dispatch = useDispatch(); const { toggle } = useConversationActions(); const { t } = useTranslation(); + const { toast } = useToast(); + const { rename } = useConversationActions(); const [open, setOpen] = useState(false); const [offset, setOffset] = useState(0); + const [editDialog, setEditDialog] = useState(false); + const loading = conversation.id <= 0; return ( @@ -59,13 +67,6 @@ function ConversationSegment({ >
{filterMessage(conversation.name)}
-
- {loading ? ( - - ) : ( - conversation.id - )} -
{ @@ -74,20 +75,54 @@ function ConversationSegment({ }} > { e.preventDefault(); e.stopPropagation(); }} > +
+ {loading ? ( + + ) : ( + conversation.id + )} +
+ { + const resp = await rename(conversation.id, name); + toastState(toast, t, resp, true); + if (!resp.status) return false; + + setEditDialog(false); + return true; + }} + /> { // prevent click event from opening the dropdown menu if (offset + 500 > new Date().getTime()) return; + e.preventDefault(); + e.stopPropagation(); + + setEditDialog(true); + }} + > + + {t("conversation.edit-title")} + + { e.preventDefault(); e.stopPropagation(); operate({ target: conversation, type: "delete" }); @@ -95,7 +130,7 @@ function ConversationSegment({ setOpen(false); }} > - + {t("conversation.delete-conversation")} - + {t("share.share-conversation")} diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index bab7d36..bd3fa8b 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -51,6 +51,7 @@ "model": "模型", "min-quota": "最低余额", "your-quota": "您的余额", + "title": "标题", "auth": { "username": "用户名", "username-placeholder": "请输入用户名", @@ -126,6 +127,7 @@ "remove-all-description": "此操作无法撤消。这将永久删除所有对话,是否继续?", "cancel": "取消", "delete": "删除", + "edit-title": "编辑标题", "delete-conversation": "删除对话", "delete-success": "对话已删除", "delete-success-prompt": "对话已删除。", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index d0311bb..a99e0c6 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -75,7 +75,8 @@ "delete-success": "Conversation deleted", "delete-success-prompt": "Conversation has been deleted.", "delete-failed": "Delete failed", - "delete-failed-prompt": "Failed to delete conversation. Please check your network and try again." + "delete-failed-prompt": "Failed to delete conversation. Please check your network and try again.", + "edit-title": "Edit Title" }, "chat": { "web": "Web Searching", @@ -746,5 +747,6 @@ "exit": "Leave", "model": "Model", "min-quota": "Minimum Balance", - "your-quota": "Your balance" + "your-quota": "Your balance", + "title": "Title" } \ No newline at end of file diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index 6b5264f..aa64b3c 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -75,7 +75,8 @@ "delete-success": "会話が削除されました", "delete-success-prompt": "会話が削除されました。", "delete-failed": "削除失敗", - "delete-failed-prompt": "会話を削除できませんでした。ネットワークを確認して、もう一度お試しください。" + "delete-failed-prompt": "会話を削除できませんでした。ネットワークを確認して、もう一度お試しください。", + "edit-title": "タイトルを編集..." }, "chat": { "web": "インターネット検索", @@ -746,5 +747,6 @@ "exit": "離れる", "model": "モデル", "min-quota": "最低残高", - "your-quota": "残高" + "your-quota": "残高", + "title": "タイトル" } \ No newline at end of file diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index 1d70343..85dab42 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -75,7 +75,8 @@ "delete-success": "Разговор удален", "delete-success-prompt": "Разговор был удален.", "delete-failed": "Ошибка удаления", - "delete-failed-prompt": "Не удалось удалить разговор. Пожалуйста, проверьте свою сеть и попробуйте еще раз." + "delete-failed-prompt": "Не удалось удалить разговор. Пожалуйста, проверьте свою сеть и попробуйте еще раз.", + "edit-title": "Редактировать заголовок" }, "chat": { "web": "Веб-поиск", @@ -746,5 +747,6 @@ "exit": "Закрыть", "model": "модель", "min-quota": "Минимальный баланс", - "your-quota": "Ваш баланс" + "your-quota": "Ваш баланс", + "title": "заглавие" } \ No newline at end of file diff --git a/app/src/store/chat.ts b/app/src/store/chat.ts index 2742698..d9eb3ed 100644 --- a/app/src/store/chat.ts +++ b/app/src/store/chat.ts @@ -22,6 +22,7 @@ import { import { deleteConversation as doDeleteConversation, deleteAllConversations as doDeleteAllConversations, + renameConversation as doRenameConversation, loadConversation, getConversationList, } from "@/api/history.ts"; @@ -257,6 +258,11 @@ const chatSlice = createSlice({ // add a new history at the beginning state.history = [{ id: -1, name, message: [] }, ...state.history]; }, + renameHistory: (state, action) => { + const { id, name } = action.payload as { id: number; name: string }; + const conversation = state.history.find((item) => item.id === id); + if (conversation) conversation.name = name; + }, setModel: (state, action) => { setMemory("model", action.payload as string); state.model = action.payload as string; @@ -355,6 +361,7 @@ const chatSlice = createSlice({ export const { setHistory, + renameHistory, setCurrent, setModel, setWeb, @@ -441,6 +448,12 @@ export function useConversationActions() { dispatch(setCurrent(id)); }, + rename: async (id: number, name: string) => { + const resp = await doRenameConversation(id, name); + resp.status && dispatch(renameHistory({ id, name })); + + return resp; + }, remove: async (id: number) => { const state = await doDeleteConversation(id); state && dispatch(deleteConversation(id)); diff --git a/globals/variables.go b/globals/variables.go index a61909f..c5ea6c1 100644 --- a/globals/variables.go +++ b/globals/variables.go @@ -75,6 +75,7 @@ const ( Claude2 = "claude-1-100k" Claude2100k = "claude-2" Claude2200k = "claude-2.1" + Claude3 = "claude-3" ClaudeSlack = "claude-slack" SparkDesk = "spark-desk-v1.5" SparkDeskV2 = "spark-desk-v2" @@ -109,14 +110,10 @@ var OpenAIDalleModels = []string{ Dalle, Dalle2, Dalle3, } -var OpenAIVisionModels = []string{ - //GPT4Vision, GPT4All, GPT4Dalle, - GPT4VisionPreview, GPT41106VisionPreview, -} - var VisionModels = []string{ - GPT4VisionPreview, GPT41106VisionPreview, - GeminiProVision, + GPT4VisionPreview, GPT41106VisionPreview, // openai + GeminiProVision, // gemini + Claude3, // anthropic } func in(value string, slice []string) bool { @@ -133,11 +130,6 @@ func IsOpenAIDalleModel(model string) bool { return in(model, OpenAIDalleModels) && !strings.Contains(model, "gpt-4-dalle") } -func IsOpenAIVisionModels(model string) bool { - // enable openai image format for gpt-4-vision-preview models - return in(model, OpenAIVisionModels) -} - func IsVisionModel(model string) bool { return in(model, VisionModels) } diff --git a/manager/conversation/api.go b/manager/conversation/api.go index a3ade96..5925276 100644 --- a/manager/conversation/api.go +++ b/manager/conversation/api.go @@ -14,6 +14,11 @@ type ShareForm struct { Refs []int `json:"refs"` } +type RenameConversationForm struct { + Id int64 `json:"id"` + Name string `json:"name"` +} + type DeleteMaskForm struct { Id int `json:"id" binding:"required"` } @@ -116,6 +121,41 @@ func DeleteAPI(c *gin.Context) { }) } +func RenameAPI(c *gin.Context) { + user := auth.GetUser(c) + if user == nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": "user not found", + }) + return + } + + db := utils.GetDBFromContext(c) + var form RenameConversationForm + if err := c.ShouldBindJSON(&form); err != nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": "invalid form", + }) + return + } + + conversation := LoadConversation(db, user.GetID(db), form.Id) + if conversation == nil { + c.JSON(http.StatusOK, gin.H{ + "status": false, + "message": "conversation not found", + }) + return + } + conversation.RenameConversation(db, form.Name) + c.JSON(http.StatusOK, gin.H{ + "status": true, + "message": "", + }) +} + func CleanAPI(c *gin.Context) { user := auth.GetUser(c) if user == nil { diff --git a/manager/conversation/router.go b/manager/conversation/router.go index 4fc7cfa..b2305f7 100644 --- a/manager/conversation/router.go +++ b/manager/conversation/router.go @@ -7,6 +7,7 @@ func Register(app *gin.RouterGroup) { { router.GET("/list", ListAPI) router.GET("/load", LoadAPI) + router.POST("/rename", RenameAPI) router.GET("/delete", DeleteAPI) router.GET("/clean", CleanAPI) diff --git a/manager/conversation/storage.go b/manager/conversation/storage.go index 64c947e..7e2e5df 100644 --- a/manager/conversation/storage.go +++ b/manager/conversation/storage.go @@ -115,6 +115,14 @@ func (c *Conversation) DeleteConversation(db *sql.DB) bool { return true } +func (c *Conversation) RenameConversation(db *sql.DB, name string) bool { + _, err := globals.ExecDb(db, "UPDATE conversation SET conversation_name = ? WHERE user_id = ? AND conversation_id = ?", name, c.UserID, c.Id) + if err != nil { + return false + } + return true +} + func DeleteAllConversations(db *sql.DB, user auth.User) error { _, err := globals.ExecDb(db, "DELETE FROM conversation WHERE user_id = ?", user.GetID(db)) return err diff --git a/utils/image.go b/utils/image.go index e829bd5..c6dfa58 100644 --- a/utils/image.go +++ b/utils/image.go @@ -113,7 +113,7 @@ func (i *Image) GetPixelColor(x int, y int) (int, int, int) { } func (i *Image) CountTokens(model string) int { - if globals.IsOpenAIVisionModels(model) { + if globals.IsVisionModel(model) { // tile size is 512x512 // the max size of image is 2048x2048 // the image that is larger than 2048x2048 will be resized in 16 tiles