diff --git a/web/app/components/base/confirm-ui/index.tsx b/web/app/components/base/confirm-ui/index.tsx index 3b74386d9f..b1b404b5b8 100644 --- a/web/app/components/base/confirm-ui/index.tsx +++ b/web/app/components/base/confirm-ui/index.tsx @@ -24,7 +24,7 @@ const ConfirmUI: FC = ({ }) => { const { t } = useTranslation() return ( -
+
{type === 'info' && ( diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 45f197a7f3..f1b0bfecb9 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -9,7 +9,7 @@ import s from './style.module.css' import Popover from '@/app/components/base/popover' const PinIcon = ( - + ) @@ -43,7 +43,7 @@ const ItemOperation: FC = ({
{isShowDelete && (
- + {t('explore.sidebar.action.delete')}
)} diff --git a/web/app/components/share/chat/hooks/use-conversation.ts b/web/app/components/share/chat/hooks/use-conversation.ts index 9c48efc372..88a702f029 100644 --- a/web/app/components/share/chat/hooks/use-conversation.ts +++ b/web/app/components/share/chat/hooks/use-conversation.ts @@ -1,12 +1,13 @@ import { useState } from 'react' -import type { ConversationItem } from '@/models/share' import produce from 'immer' +import type { ConversationItem } from '@/models/share' const storageConversationIdKey = 'conversationIdInfo' type ConversationInfoType = Omit function useConversation() { const [conversationList, setConversationList] = useState([]) + const [pinnedConversationList, setPinnedConversationList] = useState([]) const [currConversationId, doSetCurrConversationId] = useState('-1') // when set conversation id, we do not have set appId const setCurrConversationId = (id: string, appId: string, isSetToLocalStroge = true, newConversationName = '') => { @@ -29,9 +30,10 @@ function useConversation() { // input can be updated by user const [newConversationInputs, setNewConversationInputs] = useState | null>(null) const resetNewConversationInputs = () => { - if (!newConversationInputs) return - setNewConversationInputs(produce(newConversationInputs, draft => { - Object.keys(draft).forEach(key => { + if (!newConversationInputs) + return + setNewConversationInputs(produce(newConversationInputs, (draft) => { + Object.keys(draft).forEach((key) => { draft[key] = '' }) })) @@ -48,6 +50,8 @@ function useConversation() { return { conversationList, setConversationList, + pinnedConversationList, + setPinnedConversationList, currConversationId, setCurrConversationId, getConversationIdFromStorage, @@ -59,8 +63,8 @@ function useConversation() { setCurrInputs, currConversationInfo, setNewConversationInfo, - setExistConversationInfo + setExistConversationInfo, } } -export default useConversation; \ No newline at end of file +export default useConversation diff --git a/web/app/components/share/chat/index.tsx b/web/app/components/share/chat/index.tsx index 638f2ac23d..d131801247 100644 --- a/web/app/components/share/chat/index.tsx +++ b/web/app/components/share/chat/index.tsx @@ -14,7 +14,7 @@ import { ToastContext } from '@/app/components/base/toast' import Sidebar from '@/app/components/share/chat/sidebar' import ConfigSence from '@/app/components/share/chat/config-scence' import Header from '@/app/components/share/header' -import { fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, fetchSuggestedQuestions, sendChatMessage, stopChatMessageResponding, updateFeedback } from '@/service/share' +import { delConversation, fetchAppInfo, fetchAppParams, fetchChatList, fetchConversations, fetchSuggestedQuestions, pinConversation, sendChatMessage, stopChatMessageResponding, unpinConversation, updateFeedback } from '@/service/share' import type { ConversationItem, SiteInfo } from '@/models/share' import type { PromptConfig, SuggestedQuestionsAfterAnswerConfig } from '@/models/debug' import type { Feedbacktype, IChatItem } from '@/app/components/app/chat' @@ -25,6 +25,7 @@ import Loading from '@/app/components/base/loading' import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel' import { userInputsFormToPromptVariables } from '@/utils/model-config' import type { InstalledApp } from '@/models/explore' +import Confirm from '@/app/components/base/confirm' export type IMainProps = { isInstalledApp?: boolean @@ -65,9 +66,12 @@ const Main: FC = ({ /* * conversation info */ + const [allConversationList, setAllConversationList] = useState([]) const { conversationList, setConversationList, + pinnedConversationList, + setPinnedConversationList, currConversationId, setCurrConversationId, getConversationIdFromStorage, @@ -81,11 +85,50 @@ const Main: FC = ({ setNewConversationInfo, setExistConversationInfo, } = useConversation() - const [hasMore, setHasMore] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [hasPinnedMore, setHasPinnedMore] = useState(true) const onMoreLoaded = ({ data: conversations, has_more }: any) => { setHasMore(has_more) setConversationList([...conversationList, ...conversations]) } + const onPinnedMoreLoaded = ({ data: conversations, has_more }: any) => { + setHasPinnedMore(has_more) + setPinnedConversationList([...pinnedConversationList, ...conversations]) + } + const [controlUpdateConversationList, setControlUpdateConversationList] = useState(0) + const noticeUpdateList = () => { + setConversationList([]) + setHasMore(true) + setPinnedConversationList([]) + setHasPinnedMore(true) + setControlUpdateConversationList(Date.now()) + } + const handlePin = async (id: string) => { + await pinConversation(isInstalledApp, installedAppInfo?.id, id) + notify({ type: 'success', message: t('common.api.success') }) + noticeUpdateList() + } + + const handleUnpin = async (id: string) => { + await unpinConversation(isInstalledApp, installedAppInfo?.id, id) + notify({ type: 'success', message: t('common.api.success') }) + noticeUpdateList() + } + const [isShowConfirm, { setTrue: showConfirm, setFalse: hideConfirm }] = useBoolean(false) + const [toDeleteConversationId, setToDeleteConversationId] = useState('') + const handleDelete = (id: string) => { + setToDeleteConversationId(id) + hideSidebar() // mobile + showConfirm() + } + + const didDelete = async () => { + await delConversation(isInstalledApp, installedAppInfo?.id, toDeleteConversationId) + notify({ type: 'success', message: t('common.api.success') }) + hideConfirm() + noticeUpdateList() + } + const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState(null) const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false) @@ -121,7 +164,7 @@ const Main: FC = ({ let notSyncToStateIntroduction = '' let notSyncToStateInputs: Record | undefined | null = {} if (!isNewConversation) { - const item = conversationList.find(item => item.id === currConversationId) + const item = allConversationList.find(item => item.id === currConversationId) notSyncToStateInputs = item?.inputs || {} setCurrInputs(notSyncToStateInputs) notSyncToStateIntroduction = item?.introduction || '' @@ -229,6 +272,10 @@ const Main: FC = ({ return [] } + const fetchAllConversations = () => { + return fetchConversations(isInstalledApp, installedAppInfo?.id, undefined, undefined, 100) + } + const fetchInitData = () => { return Promise.all([isInstalledApp ? { @@ -240,7 +287,7 @@ const Main: FC = ({ }, plan: 'basic', } - : fetchAppInfo(), fetchConversations(isInstalledApp, installedAppInfo?.id), fetchAppParams(isInstalledApp, installedAppInfo?.id)]) + : fetchAppInfo(), fetchAllConversations(), fetchAppParams(isInstalledApp, installedAppInfo?.id)]) } // init @@ -255,10 +302,10 @@ const Main: FC = ({ setIsPublicVersion(tempIsPublicVersion) const prompt_template = '' // handle current conversation id - const { data: conversations, has_more } = conversationData as { data: ConversationItem[]; has_more: boolean } + const { data: allConversations } = conversationData as { data: ConversationItem[]; has_more: boolean } const _conversationId = getConversationIdFromStorage(appId) - const isNotNewConversation = conversations.some(item => item.id === _conversationId) - setHasMore(has_more) + const isNotNewConversation = allConversations.some(item => item.id === _conversationId) + setAllConversationList(allConversations) // fetch new conversation info const { user_input_form, opening_statement: introduction, suggested_questions_after_answer }: any = appParams const prompt_variables = userInputsFormToPromptVariables(user_input_form) @@ -276,7 +323,7 @@ const Main: FC = ({ } as PromptConfig) setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer) - setConversationList(conversations as ConversationItem[]) + // setConversationList(conversations as ConversationItem[]) if (isNotNewConversation) setCurrConversationId(_conversationId, appId, false) @@ -403,12 +450,10 @@ const Main: FC = ({ if (hasError) return - let currChatList = conversationList if (getConversationIdChangeBecauseOfNew()) { - const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppInfo?.id) - setHasMore(has_more) - setConversationList(conversations as ConversationItem[]) - currChatList = conversations + const { data: allConversations }: any = await fetchAllConversations() + setAllConversationList(allConversations) + noticeUpdateList() } setConversationIdChangeBecauseOfNew(false) resetNewConversationInputs() @@ -451,14 +496,21 @@ const Main: FC = ({ return ( ) } @@ -482,9 +534,6 @@ const Main: FC = ({ /> )} - {/* {isNewConversation ? 'new' : 'exist'} - {JSON.stringify(newConversationInputs ? newConversationInputs : {})} - {JSON.stringify(existConversationInputs ? existConversationInputs : {})} */}
= ({
) } + + {isShowConfirm && ( + + )}
diff --git a/web/app/components/share/chat/sidebar/index.tsx b/web/app/components/share/chat/sidebar/index.tsx index 34a5b5d7a9..ff0e718cb3 100644 --- a/web/app/components/share/chat/sidebar/index.tsx +++ b/web/app/components/share/chat/sidebar/index.tsx @@ -1,32 +1,34 @@ -import React, { useRef } from 'react' +import React, { useEffect, useState } from 'react' import type { FC } from 'react' import { useTranslation } from 'react-i18next' import { - ChatBubbleOvalLeftEllipsisIcon, PencilSquareIcon, } from '@heroicons/react/24/outline' -import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid' -import { useInfiniteScroll } from 'ahooks' +import cn from 'classnames' import Button from '../../../base/button' +import List from './list' import AppInfo from '@/app/components/share/chat/sidebar/app-info' // import Card from './card' import type { ConversationItem, SiteInfo } from '@/models/share' import { fetchConversations } from '@/service/share' -function classNames(...classes: any[]) { - return classes.filter(Boolean).join(' ') -} - export type ISidebarProps = { copyRight: string currentId: string onCurrentIdChange: (id: string) => void list: ConversationItem[] + pinnedList: ConversationItem[] isInstalledApp: boolean installedAppId?: string siteInfo: SiteInfo onMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void + onPinnedMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void isNoMore: boolean + isPinnedNoMore: boolean + onPin: (id: string) => void + onUnpin: (id: string) => void + controlUpdateList: number + onDelete: (id: string) => void } const Sidebar: FC = ({ @@ -34,37 +36,42 @@ const Sidebar: FC = ({ currentId, onCurrentIdChange, list, + pinnedList, isInstalledApp, installedAppId, siteInfo, onMoreLoaded, + onPinnedMoreLoaded, isNoMore, + isPinnedNoMore, + onPin, + onUnpin, + controlUpdateList, + onDelete, }) => { const { t } = useTranslation() - const listRef = useRef(null) + const [hasPinned, setHasPinned] = useState(false) - useInfiniteScroll( - async () => { - if (!isNoMore) { - const lastId = list[list.length - 1].id - const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppId, lastId) - onMoreLoaded({ data: conversations, has_more }) - } - return { list: [] } - }, - { - target: listRef, - isNoMore: () => { - return isNoMore - }, - reloadDeps: [isNoMore], - }, - ) + const checkHasPinned = async () => { + const { data }: any = await fetchConversations(isInstalledApp, installedAppId, undefined, true) + setHasPinned(data.length > 0) + } + + useEffect(() => { + checkHasPinned() + }, []) + + useEffect(() => { + if (controlUpdateList !== 0) + checkHasPinned() + }, [controlUpdateList]) + + const maxListHeight = isInstalledApp ? 'max-h-[30vh]' : 'max-h-[40vh]' return (
= ({ {t('share.chat.newChat')}
- - +
+ {/* pinned list */} + {hasPinned && ( +
+
{t('share.chat.pinnedTitle')}
+ onUnpin(id)} + controlUpdate={controlUpdateList + 1} + onDelete={onDelete} + /> +
+ )} + {/* unpinned list */} +
+ {hasPinned && ( +
{t('share.chat.unpinnedTitle')}
+ )} + onPin(id)} + controlUpdate={controlUpdateList + 1} + onDelete={onDelete} + /> +
+
© {copyRight} {(new Date()).getFullYear()}
diff --git a/web/app/components/share/chat/sidebar/list/index.tsx b/web/app/components/share/chat/sidebar/list/index.tsx new file mode 100644 index 0000000000..756f89061e --- /dev/null +++ b/web/app/components/share/chat/sidebar/list/index.tsx @@ -0,0 +1,115 @@ +'use client' +import type { FC } from 'react' +import React, { useRef } from 'react' +import { + ChatBubbleOvalLeftEllipsisIcon, +} from '@heroicons/react/24/outline' +import { useInfiniteScroll } from 'ahooks' +import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid' +import cn from 'classnames' +import s from './style.module.css' +import type { ConversationItem } from '@/models/share' +import { fetchConversations } from '@/service/share' +import ItemOperation from '@/app/components/explore/item-operation' + +export type IListProps = { + className: string + currentId: string + onCurrentIdChange: (id: string) => void + list: ConversationItem[] + isInstalledApp: boolean + installedAppId?: string + onMoreLoaded: (res: { data: ConversationItem[]; has_more: boolean }) => void + isNoMore: boolean + isPinned: boolean + onPinChanged: (id: string) => void + controlUpdate: number + onDelete: (id: string) => void +} + +const List: FC = ({ + className, + currentId, + onCurrentIdChange, + list, + isInstalledApp, + installedAppId, + onMoreLoaded, + isNoMore, + isPinned, + onPinChanged, + controlUpdate, + onDelete, +}) => { + const listRef = useRef(null) + + useInfiniteScroll( + async () => { + if (!isNoMore) { + const lastId = list[list.length - 1]?.id + const { data: conversations, has_more }: any = await fetchConversations(isInstalledApp, installedAppId, lastId, isPinned) + onMoreLoaded({ data: conversations, has_more }) + } + return { list: [] } + }, + { + target: listRef, + isNoMore: () => { + return isNoMore + }, + reloadDeps: [isNoMore, controlUpdate], + }, + ) + return ( + + ) +} + +export default React.memo(List) diff --git a/web/app/components/share/chat/sidebar/list/style.module.css b/web/app/components/share/chat/sidebar/list/style.module.css new file mode 100644 index 0000000000..50384da747 --- /dev/null +++ b/web/app/components/share/chat/sidebar/list/style.module.css @@ -0,0 +1,7 @@ +.opBtn { + visibility: hidden; +} + +.item:hover .opBtn { + visibility: visible; +} \ No newline at end of file diff --git a/web/i18n/lang/share-app.en.ts b/web/i18n/lang/share-app.en.ts index 3e641222b5..9c030f9142 100644 --- a/web/i18n/lang/share-app.en.ts +++ b/web/i18n/lang/share-app.en.ts @@ -1,45 +1,51 @@ const translation = { common: { - welcome: "Welcome to use", - appUnavailable: "App is unavailable", - appUnkonwError: "App is unavailable" + welcome: 'Welcome to use', + appUnavailable: 'App is unavailable', + appUnkonwError: 'App is unavailable', }, chat: { - newChat: "New chat", - newChatDefaultName: "New conversation", - powerBy: "Powered by", - prompt: "Prompt", - privatePromptConfigTitle: "Conversation settings", - publicPromptConfigTitle: "Initial Prompt", - configStatusDes: "Before start, you can modify conversation settings", + newChat: 'New chat', + pinnedTitle: 'Pinned', + unpinnedTitle: 'Chats', + newChatDefaultName: 'New conversation', + powerBy: 'Powered by', + prompt: 'Prompt', + privatePromptConfigTitle: 'Conversation settings', + publicPromptConfigTitle: 'Initial Prompt', + configStatusDes: 'Before start, you can modify conversation settings', configDisabled: - "Previous session settings have been used for this session.", - startChat: "Start Chat", + 'Previous session settings have been used for this session.', + startChat: 'Start Chat', privacyPolicyLeft: - "Please read the ", + 'Please read the ', privacyPolicyMiddle: - "privacy policy", + 'privacy policy', privacyPolicyRight: - " provided by the app developer.", + ' provided by the app developer.', + deleteConversation: { + title: 'Delete conversation', + content: 'Are you sure you want to delete this conversation?', + }, }, generation: { tabs: { - create: "Create", - saved: "Saved", + create: 'Create', + saved: 'Saved', }, savedNoData: { - title: "You haven't saved a result yet!", + title: 'You haven\'t saved a result yet!', description: 'Start generating content, and find your saved results here.', - startCreateContent: 'Start create content' + startCreateContent: 'Start create content', }, - title: "AI Completion", - queryTitle: "Query content", - queryPlaceholder: "Write your query content...", - run: "RUN", - copy: "Copy", - resultTitle: "AI Completion", - noData: "AI will give you what you want here.", + title: 'AI Completion', + queryTitle: 'Query content', + queryPlaceholder: 'Write your query content...', + run: 'RUN', + copy: 'Copy', + resultTitle: 'AI Completion', + noData: 'AI will give you what you want here.', }, -}; +} -export default translation; +export default translation diff --git a/web/i18n/lang/share-app.zh.ts b/web/i18n/lang/share-app.zh.ts index 031bedf03a..69ef81863d 100644 --- a/web/i18n/lang/share-app.zh.ts +++ b/web/i18n/lang/share-app.zh.ts @@ -1,41 +1,47 @@ const translation = { common: { - welcome: "欢迎使用", - appUnavailable: "应用不可用", - appUnkonwError: "应用不可用", + welcome: '欢迎使用', + appUnavailable: '应用不可用', + appUnkonwError: '应用不可用', }, chat: { - newChat: "新对话", - newChatDefaultName: "新的对话", - powerBy: "Powered by", - prompt: "提示词", - privatePromptConfigTitle: "对话设置", - publicPromptConfigTitle: "对话前提示词", - configStatusDes: "开始前,您可以修改对话设置", - configDisabled: "此次会话已使用上次会话表单", - startChat: "开始对话", - privacyPolicyLeft: "请阅读由该应用开发者提供的", - privacyPolicyMiddle: "隐私政策", - privacyPolicyRight: "。", + newChat: '新对话', + pinnedTitle: '已置顶', + unpinnedTitle: '对话列表', + newChatDefaultName: '新的对话', + powerBy: 'Powered by', + prompt: '提示词', + privatePromptConfigTitle: '对话设置', + publicPromptConfigTitle: '对话前提示词', + configStatusDes: '开始前,您可以修改对话设置', + configDisabled: '此次会话已使用上次会话表单', + startChat: '开始对话', + privacyPolicyLeft: '请阅读由该应用开发者提供的', + privacyPolicyMiddle: '隐私政策', + privacyPolicyRight: '。', + deleteConversation: { + title: '删除对话', + content: '您确定要删除此对话吗?', + }, }, generation: { tabs: { - create: "创建", - saved: "已保存", + create: '创建', + saved: '已保存', }, savedNoData: { - title: "您还没有保存结果!", + title: '您还没有保存结果!', description: '开始生成内容,您可以在这里找到保存的结果。', - startCreateContent: '开始生成内容' + startCreateContent: '开始生成内容', }, - title: "AI 智能书写", - queryTitle: "查询内容", - queryPlaceholder: "请输入文本内容", - run: "运行", - copy: "拷贝", - resultTitle: "AI 书写", - noData: "AI 会在这里给你惊喜。", + title: 'AI 智能书写', + queryTitle: '查询内容', + queryPlaceholder: '请输入文本内容', + run: '运行', + copy: '拷贝', + resultTitle: 'AI 书写', + noData: 'AI 会在这里给你惊喜。', }, -}; +} -export default translation; +export default translation diff --git a/web/service/share.ts b/web/service/share.ts index c7db773956..01abfd9a1a 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -1,16 +1,18 @@ import type { IOnCompleted, IOnData, IOnError } from './base' import { - del as consoleDel, get as consoleGet, post as consolePost, - delPublic as del, getPublic as get, postPublic as post, ssePost, + del as consoleDel, get as consoleGet, patch as consolePatch, post as consolePost, + delPublic as del, getPublic as get, patchPublic as patch, postPublic as post, ssePost, } from './base' import type { Feedbacktype } from '@/app/components/app/chat' -function getAction(action: 'get' | 'post' | 'del', isInstalledApp: boolean) { +function getAction(action: 'get' | 'post' | 'del' | 'patch', isInstalledApp: boolean) { switch (action) { case 'get': return isInstalledApp ? consoleGet : get case 'post': return isInstalledApp ? consolePost : post + case 'patch': + return isInstalledApp ? consolePatch : patch case 'del': return isInstalledApp ? consoleDel : del } @@ -55,8 +57,20 @@ export const fetchAppInfo = async () => { return get('/site') } -export const fetchConversations = async (isInstalledApp: boolean, installedAppId = '', last_id?: string) => { - return getAction('get', isInstalledApp)(getUrl('conversations', isInstalledApp, installedAppId), { params: { ...{ limit: 20 }, ...(last_id ? { last_id } : {}) } }) +export const fetchConversations = async (isInstalledApp: boolean, installedAppId = '', last_id?: string, pinned?: boolean, limit?: number) => { + return getAction('get', isInstalledApp)(getUrl('conversations', isInstalledApp, installedAppId), { params: { ...{ limit: limit || 20 }, ...(last_id ? { last_id } : {}), ...(pinned !== undefined ? { pinned } : {}) } }) +} + +export const pinConversation = async (isInstalledApp: boolean, installedAppId = '', id: string) => { + return getAction('patch', isInstalledApp)(getUrl(`conversations/${id}/pin`, isInstalledApp, installedAppId)) +} + +export const unpinConversation = async (isInstalledApp: boolean, installedAppId = '', id: string) => { + return getAction('patch', isInstalledApp)(getUrl(`conversations/${id}/unpin`, isInstalledApp, installedAppId)) +} + +export const delConversation = async (isInstalledApp: boolean, installedAppId = '', id: string) => { + return getAction('del', isInstalledApp)(getUrl(`conversations/${id}`, isInstalledApp, installedAppId)) } export const fetchChatList = async (conversationId: string, isInstalledApp: boolean, installedAppId = '') => {