Skip to content

Commit

Permalink
feat(wip): custom app icon
Browse files Browse the repository at this point in the history
  • Loading branch information
xuzuodong committed Aug 12, 2024
1 parent 1d022a4 commit a938836
Show file tree
Hide file tree
Showing 34 changed files with 541 additions and 116 deletions.
9 changes: 9 additions & 0 deletions api/controllers/console/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from libs.login import login_required
from services.app_dsl_service import AppDslService
from services.app_service import AppService
from core.file.upload_file_parser import UploadFileParser


ALLOW_CREATE_APP_MODES = ['chat', 'agent-chat', 'advanced-chat', 'workflow', 'completion']

Expand Down Expand Up @@ -78,6 +80,12 @@ def post(self):
return app, 201


class AppIconPreviewUrlApi(Resource):
def get(self, file_id):
file_id = str(file_id)
return UploadFileParser.get_signed_temp_image_url(file_id)


class AppImportApi(Resource):
@setup_required
@login_required
Expand Down Expand Up @@ -363,6 +371,7 @@ def post(self, app_id):


api.add_resource(AppListApi, '/apps')
api.add_resource(AppIconPreviewUrlApi, '/apps/icon-preview-url/<uuid:file_id>')
api.add_resource(AppImportApi, '/apps/import')
api.add_resource(AppImportFromUrlApi, '/apps/import/url')
api.add_resource(AppApi, '/apps/<uuid:app_id>')
Expand Down
3 changes: 3 additions & 0 deletions api/fields/app_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
'max_active_requests': fields.Raw(),
'description': fields.String(attribute='desc_or_prompt'),
'mode': fields.String(attribute='mode_compatible_with_agent'),
'icon_type': fields.String,
'icon': fields.String,
'icon_background': fields.String,
'model_config': fields.Nested(model_config_partial_fields, attribute='app_model_config', allow_null=True),
Expand Down Expand Up @@ -109,6 +110,7 @@
'code': fields.String,
'title': fields.String,
'icon': fields.String,
'icon_type': fields.String,
'icon_background': fields.String,
'description': fields.String,
'default_language': fields.String,
Expand All @@ -130,6 +132,7 @@
'description': fields.String,
'mode': fields.String(attribute='mode_compatible_with_agent'),
'icon': fields.String,
'icon_type': fields.String,
'icon_background': fields.String,
'enable_site': fields.Boolean,
'enable_api': fields.Boolean,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""add icon_type
Revision ID: 44d0f8e91926
Revises: eeb2e349e6ac
Create Date: 2024-08-09 03:15:39.557824
"""
from alembic import op
import models as models
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '44d0f8e91926'
down_revision = 'eeb2e349e6ac'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('apps', schema=None) as batch_op:
batch_op.add_column(sa.Column('icon_type', sa.String(length=255), nullable=True))

with op.batch_alter_table('sites', schema=None) as batch_op:
batch_op.add_column(sa.Column('icon_type', sa.String(length=255), nullable=True))

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('sites', schema=None) as batch_op:
batch_op.drop_column('icon_type')

with op.batch_alter_table('apps', schema=None) as batch_op:
batch_op.drop_column('icon_type')

# ### end Alembic commands ###
8 changes: 7 additions & 1 deletion api/models/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from flask import request
from flask_login import UserMixin
from sqlalchemy import Float, func, text
from sqlalchemy import Float, func, text, Enum as DBEnum

from configs import dify_config
from core.file.tool_file_parser import ToolFileParser
Expand Down Expand Up @@ -50,6 +50,10 @@ def value_of(cls, value: str) -> 'AppMode':
raise ValueError(f'invalid mode value {value}')


class IconType(Enum):
IMAGE = "image"
EMOJI = "emoji"

class App(db.Model):
__tablename__ = 'apps'
__table_args__ = (
Expand All @@ -62,6 +66,7 @@ class App(db.Model):
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=False, server_default=db.text("''::character varying"))
mode = db.Column(db.String(255), nullable=False)
icon_type = db.Column(db.String(255), nullable=True)
icon = db.Column(db.String(255))
icon_background = db.Column(db.String(255))
app_model_config_id = db.Column(StringUUID, nullable=True)
Expand Down Expand Up @@ -1086,6 +1091,7 @@ class Site(db.Model):
id = db.Column(StringUUID, server_default=db.text('uuid_generate_v4()'))
app_id = db.Column(StringUUID, nullable=False)
title = db.Column(db.String(255), nullable=False)
icon_type = db.Column(db.String(255), nullable=True)
icon = db.Column(db.String(255))
icon_background = db.Column(db.String(255))
description = db.Column(db.Text)
Expand Down
29 changes: 15 additions & 14 deletions web/app/components/app/create-app-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import cn from '@/utils/classnames'
import AppsContext, { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { ToastContext } from '@/app/components/base/toast'
import type { AppMode } from '@/types/app'
import type { AppIconType, AppMode } from '@/types/app'
import { createApp } from '@/service/apps'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import AppIcon from '@/app/components/base/app-icon'
import EmojiPicker from '@/app/components/base/emoji-picker'
import AppIconPicker from "../../base/app-icon-picker"
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { AiText, ChatBot, CuteRobote } from '@/app/components/base/icons/src/vender/solid/communication'
import { Route } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
Expand All @@ -40,8 +40,8 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {

const [appMode, setAppMode] = useState<AppMode>('chat')
const [showChatBotType, setShowChatBotType] = useState<boolean>(true)
const [emoji, setEmoji] = useState({ icon: '🤖', icon_background: '#FFEAD5' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [appIcon, setAppIcon] = useState({ type: 'emoji' as AppIconType, icon: '🤖', background: '#FFEAD5' as string | undefined })
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')

Expand All @@ -63,11 +63,12 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
return
isCreatingRef.current = true
try {
if (appIcon.type === 'image') throw new Error('unimplemented')
const app = await createApp({
name,
description,
icon: emoji.icon,
icon_background: emoji.icon_background,
icon: appIcon.icon,
icon_background: appIcon.background!,
mode: appMode,
})
notify({ type: 'success', message: t('app.newApp.appCreated') })
Expand All @@ -81,7 +82,7 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
}
isCreatingRef.current = false
}, [name, notify, t, appMode, emoji.icon, emoji.icon_background, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor])
}, [name, notify, t, appMode, appIcon.icon, appIcon.background, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor])

return (
<Modal
Expand Down Expand Up @@ -269,22 +270,22 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
<div className='pt-2 px-8'>
<div className='py-2 text-sm font-medium leading-[20px] text-gray-900'>{t('app.newApp.captionName')}</div>
<div className='flex items-center justify-between space-x-2'>
<AppIcon size='large' onClick={() => { setShowEmojiPicker(true) }} className='cursor-pointer' icon={emoji.icon} background={emoji.icon_background} />
<AppIcon size='large' onClick={() => { setShowAppIconPicker(true) }} className='cursor-pointer' iconType={appIcon.type} icon={appIcon.icon} background={appIcon.background} />
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('app.newApp.appNamePlaceholder') || ''}
className='grow h-10 px-3 text-sm font-normal bg-gray-100 rounded-lg border border-transparent outline-none appearance-none caret-primary-600 placeholder:text-gray-400 hover:bg-gray-50 hover:border hover:border-gray-300 focus:bg-gray-50 focus:border focus:border-gray-300 focus:shadow-xs'
/>
</div>
{showEmojiPicker && <EmojiPicker
onSelect={(icon, icon_background) => {
setEmoji({ icon, icon_background })
setShowEmojiPicker(false)
{showAppIconPicker && <AppIconPicker
onSelect={(iconType, icon, background) => {
setAppIcon({ type: iconType, icon, background })
setShowAppIconPicker(false)
}}
onClose={() => {
setEmoji({ icon: '🤖', icon_background: '#FFEAD5' })
setShowEmojiPicker(false)
setAppIcon({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
setShowAppIconPicker(false)
}}
/>}
</div>
Expand Down
92 changes: 92 additions & 0 deletions web/app/components/base/app-icon-picker/Uploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use client'

import { FC, ChangeEvent, useState, useEffect, createRef } from 'react'
import Cropper, { Area } from 'react-easy-crop'
import classNames from "classnames"

import { useDraggableUploader } from "./hooks"
import { ImagePlus } from "../icons/src/vender/line/images"
import { ALLOW_FILE_EXTENSIONS, ImageFile } from "@/types/app"

interface UploaderProps {
className?: string
onImageCropped?: (tempUrl: string, croppedAreaPixels: Area, fileName: string) => void
}

const Uploader: FC<UploaderProps> = ({
className,
onImageCropped,
}) => {
const [inputImage, setInputImage] = useState<{ file: File, url: string }>()
useEffect(() => {
return () => {
if (inputImage) URL.revokeObjectURL(inputImage.url);
}
}, [inputImage])

const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)

const onCropComplete = async (_: Area, croppedAreaPixels: Area) => {
if (!inputImage) return
onImageCropped?.(inputImage.url, croppedAreaPixels, inputImage.file.name)
}

const handleLocalFileInput = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) setInputImage({ file, url: URL.createObjectURL(file) })
}

const {
isDragActive,
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop
} = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) }))

const inputRef = createRef<HTMLInputElement>()

return (
<div className="w-full px-3 py-1.5">
<div
className={classNames(className,
isDragActive && 'border-primary-600',
'relative aspect-square bg-gray-50 border-[1.5px] border-gray-200 border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{
!inputImage
? <>
<ImagePlus className="w-[30px] h-[30px] mb-3 pointer-events-none" />
<div className="text-sm font-medium mb-[2px]">
<span className="pointer-events-none">Drop your image here, or&nbsp;</span>
<button className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>browse</button>
<input
ref={inputRef} type="file" className="hidden"
onClick={e => ((e.target as HTMLInputElement).value = '')}
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
onChange={handleLocalFileInput}
/>
</div>
<div className="text-xs pointer-events-none">Supports PNG, JPG, JPEG, WEBP and GIF</div>
</>
: <Cropper
image={inputImage.url}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
}
</div>
</div>
)
}

export default Uploader
43 changes: 43 additions & 0 deletions web/app/components/base/app-icon-picker/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useCallback, useState } from "react"

export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
const [isDragActive, setIsDragActive] = useState(false)

const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(true)
}, [])

const handleDragOver = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
}, [])

const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}, [])

const handleDrop = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)

const file = e.dataTransfer.files[0]

if (!file)
return

setImageFn(file)
}, [])

return {
handleDragEnter,
handleDragOver,
handleDragLeave,
handleDrop,
isDragActive,
}
}
Loading

0 comments on commit a938836

Please sign in to comment.