From 9ecaf9fcd120068f7aec63b3f63fa26046744113 Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Tue, 12 Mar 2024 23:18:54 +0800 Subject: [PATCH] feat: support storage dalle images in backend (#88) --- adapter/openai/image.go | 3 +- app/src/admin/api/system.ts | 3 ++ app/src/admin/channel.ts | 15 ++++-- app/src/admin/datasets/charge.ts | 2 +- app/src/assets/admin/menu.less | 4 +- app/src/assets/pages/home.less | 4 +- app/src/components/Broadcast.tsx | 20 ++++---- app/src/components/Message.tsx | 14 +++++- app/src/components/admin/ChargeWidget.tsx | 6 ++- app/src/components/ui/use-toast.ts | 2 + app/src/resources/i18n/cn.json | 3 ++ app/src/resources/i18n/en.json | 5 +- app/src/resources/i18n/ja.json | 5 +- app/src/resources/i18n/ru.json | 5 +- app/src/routes/Auth.tsx | 12 +++-- app/src/routes/admin/System.tsx | 56 ++++++++++++++++++++--- app/src/store/chat.ts | 4 -- channel/controller.go | 7 +++ channel/router.go | 1 + channel/system.go | 11 +++++ globals/variables.go | 1 + manager/manager.go | 2 - utils/config.go | 1 + utils/image.go | 46 +++++++++++++++++++ 24 files changed, 192 insertions(+), 40 deletions(-) diff --git a/adapter/openai/image.go b/adapter/openai/image.go index 91dce0bf..d77dc048 100644 --- a/adapter/openai/image.go +++ b/adapter/openai/image.go @@ -48,7 +48,7 @@ func (c *ChatInstance) CreateImageRequest(props ImageProps) (string, error) { // CreateImage will create a dalle image from prompt, return markdown of image func (c *ChatInstance) CreateImage(props *adaptercommon.ChatProps) (string, error) { - url, err := c.CreateImageRequest(ImageProps{ + original, err := c.CreateImageRequest(ImageProps{ Model: props.Model, Prompt: c.GetLatestPrompt(props), }) @@ -59,5 +59,6 @@ func (c *ChatInstance) CreateImage(props *adaptercommon.ChatProps) (string, erro return "", err } + url := utils.StoreImage(original) return utils.GetImageMarkdown(url), nil } diff --git a/app/src/admin/api/system.ts b/app/src/admin/api/system.ts index 0087e392..decf5155 100644 --- a/app/src/admin/api/system.ts +++ b/app/src/admin/api/system.ts @@ -49,6 +49,8 @@ export type CommonState = { article: string[]; generation: string[]; + + image_store: boolean; }; export type SystemProps = { @@ -152,5 +154,6 @@ export const initialSystemState: SystemProps = { cache: [], expire: 3600, size: 1, + image_store: false, }, }; diff --git a/app/src/admin/channel.ts b/app/src/admin/channel.ts index 2f5b00bf..720595ec 100644 --- a/app/src/admin/channel.ts +++ b/app/src/admin/channel.ts @@ -65,7 +65,7 @@ export const ShortChannelTypes: Record = { groq: "Groq", bing: "Bing", slack: "Slack", -} +}; export const ChannelInfos: Record = { openai: { @@ -148,7 +148,12 @@ export const ChannelInfos: Record = { sparkdesk: { endpoint: "wss://spark-api.xf-yun.com", format: "||", - models: ["spark-desk-v1.5", "spark-desk-v2", "spark-desk-v3", "spark-desk-v3.5"], + models: [ + "spark-desk-v1.5", + "spark-desk-v2", + "spark-desk-v3", + "spark-desk-v3.5", + ], }, chatglm: { endpoint: "https://open.bigmodel.cn", @@ -233,11 +238,11 @@ export const ChannelInfos: Record = { format: "", models: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"], }, - groq: { + groq: { endpoint: "https://api.groq.com/openai", format: "", - models: ["llama2-70b-4096", "mixtral-8x7b-32768", "gemma-7b-it"] - } + models: ["llama2-70b-4096", "mixtral-8x7b-32768", "gemma-7b-it"], + }, }; export const defaultChannelModels: string[] = getUniqueList( diff --git a/app/src/admin/datasets/charge.ts b/app/src/admin/datasets/charge.ts index a6513b2c..de0acb04 100644 --- a/app/src/admin/datasets/charge.ts +++ b/app/src/admin/datasets/charge.ts @@ -220,7 +220,7 @@ export const pricing: PricingDataset = [ models: ["llama2-70b-4096", "mixtral-8x7b-32768", "gemma-7b-it"], output: 0.001, // free marked as $0.001 currency: Currency.USD, - } + }, ]; const countPricing = ( diff --git a/app/src/assets/admin/menu.less b/app/src/assets/admin/menu.less index 04771a6b..bfc1c8c9 100644 --- a/app/src/assets/admin/menu.less +++ b/app/src/assets/admin/menu.less @@ -9,14 +9,16 @@ background: hsl(var(--background)); transition: 0.225s ease-in-out; min-height: calc(100% - 56px); - transition-property: width, background, box-shadow; + transition-property: width, background, box-shadow, opacity; border-right: 0; + opacity: 0; pointer-events: none; overflow-x: hidden; &.open { width: 260px; border-right: 1px solid hsl(var(--border)); + opacity: 1; pointer-events: all; } diff --git a/app/src/assets/pages/home.less b/app/src/assets/pages/home.less index 07da8478..d421a764 100644 --- a/app/src/assets/pages/home.less +++ b/app/src/assets/pages/home.less @@ -337,15 +337,17 @@ margin: 0; background: hsl(var(--background)); transition: 0.2s ease-in-out; - transition-property: width, background, box-shadow; + transition-property: width, background, box-shadow, border-right, opacity; border-right: 0; pointer-events: none; + opacity: 0; overflow-x: hidden; &.open { width: 260px; border-right: 1px solid hsl(var(--border)); pointer-events: auto; + opacity: 1; } &.hidden { diff --git a/app/src/components/Broadcast.tsx b/app/src/components/Broadcast.tsx index de3f5bdf..3f410e7e 100644 --- a/app/src/components/Broadcast.tsx +++ b/app/src/components/Broadcast.tsx @@ -16,15 +16,17 @@ function Broadcast() { const content = await getBroadcast(); if (content.length === 0) return; - toast({ - title: t("broadcast"), - description: ( - - {content} - - ), - duration: 30000, - }); + toast( + { + title: t("broadcast"), + description: ( + + {content} + + ), + }, + 30000, + ); }, [init]); return
; diff --git a/app/src/components/Message.tsx b/app/src/components/Message.tsx index ed211be6..17e5bd3d 100644 --- a/app/src/components/Message.tsx +++ b/app/src/components/Message.tsx @@ -150,7 +150,7 @@ function MessageMenu({ {children} - {isAssistant && end && ( + {isAssistant && end ? ( { onEvent && onEvent(message.end !== false ? "restart" : "stop"); @@ -169,6 +169,18 @@ function MessageMenu({ )} + ) : ( + message.end !== false && ( + { + onEvent && onEvent("restart"); + setDropdown(false); + }} + > + + {t("message.restart")} + + ) )} copyClipboard(filterMessage(message.content))} diff --git a/app/src/components/admin/ChargeWidget.tsx b/app/src/components/admin/ChargeWidget.tsx index 7b002f2a..96b93f19 100644 --- a/app/src/components/admin/ChargeWidget.tsx +++ b/app/src/components/admin/ChargeWidget.tsx @@ -686,7 +686,11 @@ function ChargeTable({ data, dispatch, onRefresh }: ChargeTableProps) { dispatch({ type: "set", payload: props }); // scroll to top - scrollUp(getQuerySelector(".admin-content > .scrollarea-viewport")!); + scrollUp( + getQuerySelector( + ".admin-content > .scrollarea-viewport", + )!, + ); }} > diff --git a/app/src/components/ui/use-toast.ts b/app/src/components/ui/use-toast.ts index 6b3ec36b..ab76b309 100644 --- a/app/src/components/ui/use-toast.ts +++ b/app/src/components/ui/use-toast.ts @@ -12,6 +12,7 @@ type ToasterToast = ToastProps & { description?: React.ReactNode; action?: ToastActionElement; created?: number; + duration?: number; }; const actionTypes = { @@ -176,6 +177,7 @@ function toast({ ...props }: Toast, timeout?: number) { id, created: Date.now(), open: true, + duration: timeout, onOpenChange: (open) => { if (!open) dismiss(); }, diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index 40fa80e7..531fae59 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -727,6 +727,9 @@ "cache": "可缓存的模型", "cacheTip": "可缓存的模型,勾选后当前模型可被缓存并击中缓存", "cachePlaceholder": "已选 {{length}} 个模型", + "image_store": "图片存储", + "image_storeTip": "OpenAI 渠道 DALL-E 生成的图片将存储于服务端以防止图片失效", + "image_storeNoBackend": "未配置后端域名,无法启用图片存储", "cacheAll": "设为全部可缓存", "cacheFree": "设为免费模型可缓存", "cacheNone": "设为全部不缓存", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index 279c0907..1d3a4d09 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -561,7 +561,10 @@ "relayPlan": "Subscription Quota Support Staging API", "relayPlanTip": "Subscription quota supports the transit API, after opening the transit API billing will give priority to the use of user subscription quota\n(Tip: Subscription is a quota of times, the model of billing for tokens may affect the cost)", "searchQueryTip": "Maximum number of search results, default is 5", - "searchPlaceholder": "DuckDuckGo Access Point (Format only https://example.com)" + "searchPlaceholder": "DuckDuckGo Access Point (Format only https://example.com)", + "image_store": "Picture storage", + "image_storeTip": "Images generated by the OpenAI channel DALL-E will be stored on the server to prevent invalidation of the images", + "image_storeNoBackend": "No backend domain configured, cannot enable image storage" }, "user": "Users", "invitation-code": "Invitation Code", diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index 0b428391..1d63d572 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -561,7 +561,10 @@ "relayPlan": "サブスクリプションクォータサポートステージングAPI", "relayPlanTip": "サブスクリプションクォータはトランジットAPIをサポートしています。トランジットAPI請求を開いた後、ユーザーサブスクリプションクォータの使用が優先されます\n(ヒント:サブスクリプションは時間のクォータであり、トークンの請求モデルはコストに影響する可能性があります)", "searchQueryTip": "検索結果の最大数、デフォルトは5です", - "searchPlaceholder": "DuckDuckGoアクセスポイント(フォーマットのみhttps://example.com )" + "searchPlaceholder": "DuckDuckGoアクセスポイント(フォーマットのみhttps://example.com )", + "image_store": "画像ストレージ", + "image_storeTip": "OpenAIチャンネルDALL - Eによって生成された画像は、画像の無効化を防ぐためにサーバーに保存されます", + "image_storeNoBackend": "バックエンドドメインが設定されていません。画像ストレージを有効にできません" }, "user": "ユーザー管理", "invitation-code": "招待コード", diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index d651635b..b15206b4 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -561,7 +561,10 @@ "relayPlan": "API промежуточной поддержки квот подписки", "relayPlanTip": "Квота подписки поддерживает транзитный API, после открытия транзитного API биллинг будет отдавать приоритет использованию пользовательской квоты подписки\n(Совет: Подписка - это квота раз, модель биллинга для токенов может повлиять на стоимость)", "searchQueryTip": "Максимальное количество результатов поиска, по умолчанию 5", - "searchPlaceholder": "Точка доступа DuckDuckGo (только в формате https://example.com)" + "searchPlaceholder": "Точка доступа DuckDuckGo (только в формате https://example.com)", + "image_store": "Хранение изображений", + "image_storeTip": "Изображения, сгенерированные каналом OpenAI DALL-E, будут храниться на сервере, чтобы предотвратить недействительность изображений", + "image_storeNoBackend": "Нет настроенного внутреннего домена, невозможно включить хранение изображений" }, "user": "Управление пользователями", "invitation-code": "Код приглашения", diff --git a/app/src/routes/Auth.tsx b/app/src/routes/Auth.tsx index fd193fd2..1a905601 100644 --- a/app/src/routes/Auth.tsx +++ b/app/src/routes/Auth.tsx @@ -123,11 +123,13 @@ function Login() { form.username.trim() === "root" && form.password.trim() === "chatnio123456" ) { - toast({ - title: t("admin.default-password"), - description: t("admin.default-password-prompt"), - duration: 30000, - }); + toast( + { + title: t("admin.default-password"), + description: t("admin.default-password-prompt"), + }, + 15000, + ); } validateToken(globalDispatch, resp.token); diff --git a/app/src/routes/admin/System.tsx b/app/src/routes/admin/System.tsx index a1f0e7f4..bbba4903 100644 --- a/app/src/routes/admin/System.tsx +++ b/app/src/routes/admin/System.tsx @@ -58,6 +58,7 @@ import { JSONEditorProvider } from "@/components/EditorProvider.tsx"; type CompProps = { data: T; + form: SystemProps; dispatch: (action: any) => void; onChange: (doToast?: boolean) => Promise; }; @@ -606,7 +607,7 @@ function Site({ data, dispatch, onChange }: CompProps) { ); } -function Common({ data, dispatch, onChange }: CompProps) { +function Common({ form, data, dispatch, onChange }: CompProps) { const { t } = useTranslation(); const { channelModels } = useChannelModels(); @@ -618,6 +619,24 @@ function Common({ data, dispatch, onChange }: CompProps) { configParagraph={true} isCollapsed={true} > + + + { + dispatch({ type: "update:common.image_store", value }); + }} + /> + + {data.image_store && form.general.backend.length === 0 && ( + + {t("admin.system.image_storeNoBackend")} + + )} +
diff --git a/app/src/store/chat.ts b/app/src/store/chat.ts index aaf1719b..42aa46ea 100644 --- a/app/src/store/chat.ts +++ b/app/src/store/chat.ts @@ -187,10 +187,6 @@ const chatSlice = createSlice({ const conversation = state.conversations[id]; if (!conversation || conversation.messages.length === 0) return; - const last = conversation.messages[conversation.messages.length - 1]; - if (last.role !== AssistantRole) return; - conversation.messages.pop(); - conversation.messages.push({ role: AssistantRole, content: "", diff --git a/channel/controller.go b/channel/controller.go index babd2500..51f2ce1a 100644 --- a/channel/controller.go +++ b/channel/controller.go @@ -2,6 +2,7 @@ package channel import ( "chat/utils" + "fmt" "github.com/gin-gonic/gin" "net/http" ) @@ -15,6 +16,12 @@ func GetInfo(c *gin.Context) { c.JSON(http.StatusOK, SystemInstance.AsInfo()) } +func AttachmentService(c *gin.Context) { + // /attachments/:hash -> ~/storage/attachments/:hash + hash := c.Param("hash") + c.File(fmt.Sprintf("storage/attachments/%s", hash)) +} + func DeleteChannel(c *gin.Context) { id := c.Param("id") state := ConduitInstance.DeleteChannel(utils.ParseInt(id)) diff --git a/channel/router.go b/channel/router.go index ff10f9bc..b499c4d8 100644 --- a/channel/router.go +++ b/channel/router.go @@ -4,6 +4,7 @@ import "github.com/gin-gonic/gin" func Register(app *gin.RouterGroup) { app.GET("/info", GetInfo) + app.GET("/attachments/:hash", AttachmentService) app.GET("/admin/channel/list", GetChannelList) app.POST("/admin/channel/create", CreateChannel) diff --git a/channel/system.go b/channel/system.go index c389084c..3ae0d6fd 100644 --- a/channel/system.go +++ b/channel/system.go @@ -70,6 +70,7 @@ type commonState struct { Cache []string `json:"cache" mapstructure:"cache"` Expire int64 `json:"expire" mapstructure:"expire"` Size int64 `json:"size" mapstructure:"size"` + ImageStore bool `json:"image_store" mapstructure:"imagestore"` } type SystemConfig struct { @@ -99,6 +100,7 @@ func (c *SystemConfig) Load() { globals.CacheAcceptedExpire = c.GetCacheAcceptedExpire() globals.CacheAcceptedSize = c.GetCacheAcceptedSize() + globals.AcceptImageStore = c.AcceptImageStore() if c.General.PWAManifest == "" { c.General.PWAManifest = utils.ReadPWAManifest() @@ -271,6 +273,15 @@ func (c *SystemConfig) GetCacheAcceptedSize() int64 { return c.Common.Size } +func (c *SystemConfig) AcceptImageStore() bool { + // if notify url is empty, then image store is not allowed + if len(strings.TrimSpace(globals.NotifyUrl)) == 0 { + return false + } + + return c.Common.ImageStore +} + func (c *SystemConfig) IsCloseRegister() bool { return c.Site.CloseRegister } diff --git a/globals/variables.go b/globals/variables.go index dd91f97b..b79922a7 100644 --- a/globals/variables.go +++ b/globals/variables.go @@ -17,6 +17,7 @@ var GenerationPermissionGroup []string var CacheAcceptedModels []string var CacheAcceptedExpire int64 var CacheAcceptedSize int64 +var AcceptImageStore bool func OriginIsAllowed(uri string) bool { if len(AllowedOrigins) == 0 { diff --git a/manager/manager.go b/manager/manager.go index d2c6a995..add65412 100644 --- a/manager/manager.go +++ b/manager/manager.go @@ -2,7 +2,6 @@ package manager import ( "chat/auth" - "chat/globals" "chat/manager/conversation" "chat/utils" "fmt" @@ -92,7 +91,6 @@ func ChatAPI(c *gin.Context) { case ShareType: instance.LoadSharing(db, form.Message) case RestartType: - instance.RemoveLatestMessageWithRole(globals.Assistant) response := ChatHandler(buf, user, instance) instance.SaveResponse(db, response) case MaskType: diff --git a/utils/config.go b/utils/config.go index 26af4998..ab613d1e 100644 --- a/utils/config.go +++ b/utils/config.go @@ -15,6 +15,7 @@ var configExampleFile = "config.example.yaml" var redirectRoutes = []string{ "/v1", "/mj", + "/attachments", } func ReadConf() { diff --git a/utils/image.go b/utils/image.go index c6dfa587..c5bb8719 100644 --- a/utils/image.go +++ b/utils/image.go @@ -2,6 +2,7 @@ package utils import ( "chat/globals" + "fmt" "github.com/chai2010/webp" "image" "image/gif" @@ -9,6 +10,7 @@ import ( "io" "math" "net/http" + "os" "path" "strings" ) @@ -127,3 +129,47 @@ func (i *Image) CountTokens(model string) int { return 0 } + +func DownloadImage(url string, path string) error { + res, err := http.Get(url) + if err != nil { + return err + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + globals.Debug("[utils] close file error: %s (path: %s)", err.Error(), path) + } + }(res.Body) + + file, err := os.Create(path) + if err != nil { + return err + } + + defer func(file *os.File) { + err := file.Close() + if err != nil { + globals.Debug("[utils] close file error: %s (path: %s)", err.Error(), path) + } + }(file) + + _, err = io.Copy(file, res.Body) + return err +} + +func StoreImage(url string) string { + if globals.AcceptImageStore { + hash := Md5Encrypt(url) + + if err := DownloadImage(url, fmt.Sprintf("storage/attachments/%s", hash)); err != nil { + globals.Warn(fmt.Sprintf("[utils] save image error: %s", err.Error())) + return url + } + + return fmt.Sprintf("%s/attachments/%s", globals.NotifyUrl, hash) + } + + return url +}