diff --git a/web/app/components/base/markdown-blocks/img.tsx b/web/app/components/base/markdown-blocks/img.tsx index fa7ff11441..fe20bad6b1 100644 --- a/web/app/components/base/markdown-blocks/img.tsx +++ b/web/app/components/base/markdown-blocks/img.tsx @@ -3,33 +3,46 @@ * Extracted from the main markdown renderer for modularity. * Uses the ImageGallery component to display images. */ -import React, { useEffect, useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import ImageGallery from '@/app/components/base/image-gallery' import { getMarkdownImageURL } from './utils' import { usePluginReadmeAsset } from '@/service/use-plugins' import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' -const Img = ({ src, pluginInfo }: { src: string, pluginInfo?: SimplePluginInfo }) => { +type ImgProps = { + src: string + pluginInfo?: SimplePluginInfo +} + +const Img: React.FC = ({ src, pluginInfo }) => { const { plugin_unique_identifier, plugin_id } = pluginInfo || {} const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier, file_name: src }) - - const blobUrl = useMemo(() => { - if (assetData) - return URL.createObjectURL(assetData) - - return getMarkdownImageURL(src, plugin_id) - }, [assetData, plugin_id, src]) + const [blobUrl, setBlobUrl] = useState() useEffect(() => { - return () => { - if (blobUrl && assetData) - URL.revokeObjectURL(blobUrl) + if (!assetData) { + setBlobUrl(undefined) + return } - }, [blobUrl, assetData]) + + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + return getMarkdownImageURL(src, plugin_id) + }, [blobUrl, plugin_id, src]) return ( -
- +
+
) } diff --git a/web/app/components/base/markdown-blocks/paragraph.tsx b/web/app/components/base/markdown-blocks/paragraph.tsx index 0c56a28172..cb654118fd 100644 --- a/web/app/components/base/markdown-blocks/paragraph.tsx +++ b/web/app/components/base/markdown-blocks/paragraph.tsx @@ -3,44 +3,65 @@ * Extracted from the main markdown renderer for modularity. * Handles special rendering for paragraphs that directly contain an image. */ -import React, { useEffect, useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import ImageGallery from '@/app/components/base/image-gallery' import { getMarkdownImageURL } from './utils' import { usePluginReadmeAsset } from '@/service/use-plugins' import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper' -const Paragraph = (props: { pluginInfo?: SimplePluginInfo, node?: any, children?: any }) => { - const { node, pluginInfo, children } = props +type ParagraphProps = { + pluginInfo?: SimplePluginInfo + node?: any + children?: React.ReactNode +} + +const Paragraph: React.FC = ({ pluginInfo, node, children }) => { const { plugin_unique_identifier, plugin_id } = pluginInfo || {} - const children_node = node.children - const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier, file_name: children_node?.[0]?.tagName !== 'img' ? '' : children_node[0].properties?.src }) + const childrenNode = node?.children as Array | undefined + const firstChild = childrenNode?.[0] + const isImageParagraph = firstChild?.tagName === 'img' + const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined - const blobUrl = useMemo(() => { - if (assetData) - return URL.createObjectURL(assetData) + const { data: assetData } = usePluginReadmeAsset({ + plugin_unique_identifier, + file_name: isImageParagraph && imageSrc ? imageSrc : '', + }) - if (children_node?.[0]?.tagName === 'img' && children_node[0].properties?.src) - return getMarkdownImageURL(children_node[0].properties.src, plugin_id) - - return '' - }, [assetData, children_node, plugin_id]) + const [blobUrl, setBlobUrl] = useState() useEffect(() => { - return () => { - if (blobUrl && assetData) - URL.revokeObjectURL(blobUrl) + if (!assetData) { + setBlobUrl(undefined) + return } - }, [blobUrl, assetData]) - if (children_node?.[0]?.tagName === 'img') { + const objectUrl = URL.createObjectURL(assetData) + setBlobUrl(objectUrl) + + return () => { + URL.revokeObjectURL(objectUrl) + } + }, [assetData]) + + const imageUrl = useMemo(() => { + if (blobUrl) + return blobUrl + + if (isImageParagraph && imageSrc) + return getMarkdownImageURL(imageSrc, plugin_id) + + return '' + }, [blobUrl, imageSrc, isImageParagraph, plugin_id]) + + if (isImageParagraph) { + const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined + return (
- - { - Array.isArray(children) && children.length > 1 && ( -
{children.slice(1)}
- ) - } + + {remainingChildren && ( +
{remainingChildren}
+ )}
) } diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 941e91fee8..be19c343f9 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -1,7 +1,26 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { useTheme } from 'next-themes' -import { useTranslation } from 'react-i18next' -import { useBoolean } from 'ahooks' +import ActionButton from '@/app/components/base/action-button' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +import Confirm from '@/app/components/base/confirm' +import { Github } from '@/app/components/base/icons/src/public/common' +import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' +import Toast from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' +import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' +import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' +import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' +import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' +import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' +import { API_PREFIX } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useGetLanguage, useI18N } from '@/context/i18n' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { uninstallPlugin } from '@/service/plugins' +import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools' +import cn from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' import { RiArrowLeftRightLine, RiBugLine, @@ -9,54 +28,35 @@ import { RiHardDrive3Line, RiVerifiedBadgeLine, } from '@remixicon/react' -import type { PluginDetail } from '../types' -import { PluginCategoryEnum, PluginSource } from '../types' -import Description from '../card/base/description' -import Icon from '../card/base/card-icon' -import Title from '../card/base/title' -import OrgInfo from '../card/base/org-info' -import { useGitHubReleases } from '../install-plugin/hooks' -import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' -import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' -import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' -import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' -import ActionButton from '@/app/components/base/action-button' -import Button from '@/app/components/base/button' -import Badge from '@/app/components/base/badge' -import Confirm from '@/app/components/base/confirm' -import Tooltip from '@/app/components/base/tooltip' -import Toast from '@/app/components/base/toast' -import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' -import { Github } from '@/app/components/base/icons/src/public/common' -import { uninstallPlugin } from '@/service/plugins' -import { useGetLanguage, useI18N } from '@/context/i18n' -import { useModalContext } from '@/context/modal-context' -import { useProviderContext } from '@/context/provider-context' -import { useInvalidateAllToolProviders } from '@/service/use-tools' -import { API_PREFIX } from '@/config' -import cn from '@/utils/classnames' -import { getMarketplaceUrl } from '@/utils/var' -import { PluginAuth } from '@/app/components/plugins/plugin-auth' -import { AuthCategory } from '@/app/components/plugins/plugin-auth' -import { useAllToolProviders } from '@/service/use-tools' -import DeprecationNotice from '../base/deprecation-notice' +import { useBoolean } from 'ahooks' +import { useTheme } from 'next-themes' +import React, { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { AutoUpdateLine } from '../../base/icons/src/vender/system' -import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' +import DeprecationNotice from '../base/deprecation-notice' +import Icon from '../card/base/card-icon' +import Description from '../card/base/description' +import OrgInfo from '../card/base/org-info' +import Title from '../card/base/title' +import { useGitHubReleases } from '../install-plugin/hooks' import useReferenceSetting from '../plugin-page/use-reference-setting' import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types' -import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' +import type { PluginDetail } from '../types' +import { PluginCategoryEnum, PluginSource } from '../types' const i18nPrefix = 'plugin.action' type Props = { detail: PluginDetail - onHide: () => void - onUpdate: (isDelete?: boolean) => void + isReadmeView?: boolean + onHide?: () => void + onUpdate?: (isDelete?: boolean) => void } const DetailHeader = ({ detail, + isReadmeView = false, onHide, onUpdate, }: Props) => { @@ -156,7 +156,7 @@ const DetailHeader = ({ if (needUpdate) { setShowUpdatePluginModal({ onSaveCallback: () => { - onUpdate() + onUpdate?.() }, payload: { type: PluginSource.github, @@ -176,7 +176,7 @@ const DetailHeader = ({ } const handleUpdatedFromMarketplace = () => { - onUpdate() + onUpdate?.() hideUpdateModal() } @@ -201,7 +201,7 @@ const DetailHeader = ({ hideDeleting() if (res.success) { hideDeleteConfirm() - onUpdate(true) + onUpdate?.(true) if (PluginCategoryEnum.model.includes(category)) refreshModelProviders() if (PluginCategoryEnum.tool.includes(category)) @@ -210,17 +210,17 @@ const DetailHeader = ({ }, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) return ( -
+
-
+
- {verified && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} + {verified && !isReadmeView && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} <PluginVersionPicker - disabled={!isFromMarketplace} + disabled={!isFromMarketplace || isReadmeView} isShow={isShow} onShowChange={setIsShow} pluginID={plugin_id} @@ -240,7 +240,7 @@ const DetailHeader = ({ text={ <> <div>{isFromGitHub ? meta!.version : version}</div> - {isFromMarketplace && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />} + {isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className='ml-1 h-3 w-3 text-text-tertiary' />} </> } hasRedCornerMark={hasNewVersion} @@ -248,7 +248,7 @@ const DetailHeader = ({ } /> {/* Auto update info */} - {isAutoUpgradeEnabled && ( + {isAutoUpgradeEnabled && !isReadmeView && ( <Tooltip popupContent={t('plugin.autoUpdate.nextUpdateTime', { time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}> {/* add a a div to fix tooltip hover not show problem */} <div> @@ -302,18 +302,19 @@ const DetailHeader = ({ </div> </div> </div> - <div className='flex gap-1'> - <OperationDropdown - source={detail.source} - onInfo={showPluginInfo} - onCheckVersion={handleUpdate} - onRemove={showDeleteConfirm} - detailUrl={detailUrl} - /> - <ActionButton onClick={onHide}> - <RiCloseLine className='h-4 w-4' /> - </ActionButton> - </div> + {!isReadmeView && ( + <div className='flex gap-1'> + <OperationDropdown + source={detail.source} + onInfo={showPluginInfo} + onCheckVersion={handleUpdate} + onRemove={showDeleteConfirm} + detailUrl={detailUrl} + /> + <ActionButton onClick={onHide}> + <RiCloseLine className='h-4 w-4' /> + </ActionButton> + </div>)} </div> {isFromMarketplace && ( <DeprecationNotice @@ -324,9 +325,9 @@ const DetailHeader = ({ className='mt-3' /> )} - <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description> + {!isReadmeView && <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>} { - category === PluginCategoryEnum.tool && ( + category === PluginCategoryEnum.tool && !isReadmeView && ( <PluginAuth pluginPayload={{ provider: provider?.name || '', diff --git a/web/app/components/plugins/plugin-title-info/index.tsx b/web/app/components/plugins/plugin-title-info/index.tsx deleted file mode 100644 index d09c9cc60a..0000000000 --- a/web/app/components/plugins/plugin-title-info/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -'use client' -import React from 'react' -import type { FC } from 'react' -import { useTranslation } from 'react-i18next' -import { - RiBugLine, - RiHardDrive3Line, - RiVerifiedBadgeLine, -} from '@remixicon/react' -import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' -import { Github } from '@/app/components/base/icons/src/public/common' -import Tooltip from '@/app/components/base/tooltip' -import Badge from '@/app/components/base/badge' -import { API_PREFIX } from '@/config' -import { useAppContext } from '@/context/app-context' -import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import type { PluginDetail } from '@/app/components/plugins/types' -import { PluginSource } from '@/app/components/plugins/types' -import OrgInfo from '@/app/components/plugins/card/base/org-info' -import Icon from '@/app/components/plugins/card/base/card-icon' -import type { TypeWithI18N } from '../../base/form/types' - -type PluginInfoProps = { - detail: PluginDetail & { icon?: string, label?: TypeWithI18N<string>, author?: string, name?: string, verified?: boolean } - size?: 'default' | 'large' -} - -const PluginInfo: FC<PluginInfoProps> = ({ - detail, - size = 'default', -}) => { - const { t } = useTranslation() - const { currentWorkspace } = useAppContext() - const locale = useLanguage() - - const tenant_id = currentWorkspace?.id - const { - version, - source, - } = detail - - const icon = detail.declaration?.icon || detail?.icon - const label = detail.declaration?.label || detail?.label - const author = detail.declaration?.author || detail?.author - const name = detail.declaration?.name || detail?.name - const verified = detail.declaration?.verified || detail?.verified - - const isLarge = size === 'large' - const iconSize = isLarge ? 'h-10 w-10' : 'h-8 w-8' - const titleSize = isLarge ? 'text-sm' : 'text-xs' - - return ( - <div className={`flex items-center ${isLarge ? 'gap-3' : 'gap-2'}`}> - {/* Plugin Icon */} - <div className={`shrink-0 overflow-hidden rounded-lg border border-components-panel-border-subtle ${iconSize}`}> - <Icon src={icon?.startsWith('http') ? icon : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`} /> - </div> - - {/* Plugin Details */} - <div className="min-w-0 flex-1"> - {/* Name and Version */} - <div className="mb-0.5 flex items-center gap-1"> - <h3 className={`truncate font-semibold text-text-secondary ${titleSize}`}> - {label?.[locale]} - </h3> - {verified && <RiVerifiedBadgeLine className="h-3 w-3 shrink-0 text-text-accent" />} - <Badge - className="mx-1" - uppercase={false} - text={version ?? detail.plugin_unique_identifier.split(':')[1]?.split('@')?.[0]} - /> - </div> - - {/* Organization and Source */} - <div className="flex items-center text-xs"> - <OrgInfo - packageNameClassName="w-auto" - orgName={author} - packageName={name} - /> - <div className="ml-1 mr-0.5 text-text-quaternary">·</div> - - {/* Source Icon */} - {source === PluginSource.marketplace && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.marketplace')}> - <div> - <BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" /> - </div> - </Tooltip> - )} - {source === PluginSource.github && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.github')}> - <div> - <Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" /> - </div> - </Tooltip> - )} - {source === PluginSource.local && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.local')}> - <div> - <RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" /> - </div> - </Tooltip> - )} - {source === PluginSource.debugging && ( - <Tooltip popupContent={t('plugin.detailPanel.categoryTip.debugging')}> - <div> - <RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" /> - </div> - </Tooltip> - )} - </div> - </div> - </div> - ) -} - -export default PluginInfo diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx index c1fc50ee5a..8257d848f7 100644 --- a/web/app/components/plugins/readme-panel/index.tsx +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -1,18 +1,17 @@ 'use client' -import React from 'react' -import type { FC } from 'react' -import { useTranslation } from 'react-i18next' -import { RiBookReadLine, RiCloseLine } from '@remixicon/react' -import cn from '@/utils/classnames' -import Drawer from '@/app/components/base/drawer' -import { Markdown } from '@/app/components/base/markdown' -import { usePluginReadme } from '@/service/use-plugins' -// import type { PluginDetail } from '@/app/components/plugins/types' -import Loading from '@/app/components/base/loading' -import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import PluginTitleInfo from '@/app/components/plugins/plugin-title-info' -import Modal from '@/app/components/base/modal' import ActionButton from '@/app/components/base/action-button' +import Drawer from '@/app/components/base/drawer' +import Loading from '@/app/components/base/loading' +import { Markdown } from '@/app/components/base/markdown' +import Modal from '@/app/components/base/modal' +import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' +import { usePluginReadme } from '@/service/use-plugins' +import cn from '@/utils/classnames' +import { RiBookReadLine, RiCloseLine } from '@remixicon/react' +import type { FC } from 'react' +import React from 'react' +import { useTranslation } from 'react-i18next' +import DetailHeader from '../plugin-detail-panel/detail-header' import { ReadmeShowType, useReadmePanelStore } from './store' const ReadmePanel: FC = () => { @@ -47,8 +46,7 @@ const ReadmePanel: FC = () => { <RiCloseLine className='h-4 w-4' /> </ActionButton> </div> - - <PluginTitleInfo detail={detail} size="large" /> + <DetailHeader detail={detail} isReadmeView={true} /> </div> <div className="flex-1 overflow-y-auto px-4 py-3"> @@ -64,7 +62,7 @@ const ReadmePanel: FC = () => { if (error) { return ( <div className="py-8 text-center text-text-tertiary"> - <p>{t('plugin.readmeInfo.noReadmeAvailable')}</p> + <p>{t('plugin.readmeInfo.failedToFetch')}</p> </div> ) } @@ -73,7 +71,6 @@ const ReadmePanel: FC = () => { return ( <Markdown content={readmeData.readme} - className="prose-sm prose max-w-none" pluginInfo={{ plugin_unique_identifier: pluginUniqueIdentifier, plugin_id: detail.plugin_id }} /> ) diff --git a/web/i18n/en-US/plugin.ts b/web/i18n/en-US/plugin.ts index b84c9cabdb..62a5f35c0b 100644 --- a/web/i18n/en-US/plugin.ts +++ b/web/i18n/en-US/plugin.ts @@ -313,6 +313,7 @@ const translation = { title: 'README', needHelpCheckReadme: 'Need help? Check the README.', noReadmeAvailable: 'No README available', + failedToFetch: 'Failed to fetch README', }, } diff --git a/web/i18n/zh-Hans/plugin.ts b/web/i18n/zh-Hans/plugin.ts index d07881a2bc..d648bccb85 100644 --- a/web/i18n/zh-Hans/plugin.ts +++ b/web/i18n/zh-Hans/plugin.ts @@ -313,6 +313,7 @@ const translation = { title: 'README', needHelpCheckReadme: '需要帮助?查看 README。', noReadmeAvailable: 'README 文档不可用', + failedToFetch: '获取 README 文档失败', }, } diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 6ba85d3f74..c3a408e3a4 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -693,15 +693,17 @@ export const useFetchDynamicOptions = (plugin_id: string, provider: string, acti export const usePluginReadme = ({ plugin_unique_identifier, language }: { plugin_unique_identifier: string, language?: string }) => { return useQuery({ queryKey: ['pluginReadme', plugin_unique_identifier, language], - queryFn: () => get<{ readme: string }>('/workspaces/current/plugin/readme', { params: { plugin_unique_identifier, language } }), + queryFn: () => get<{ readme: string }>('/workspaces/current/plugin/readme', { params: { plugin_unique_identifier, language } }, { silent: true }), enabled: !!plugin_unique_identifier, + retry: 0, }) } export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => { + const normalizedFileName = file_name?.replace(/(^\.\/_assets\/|^_assets\/)/, '') return useQuery({ queryKey: ['pluginReadmeAsset', plugin_unique_identifier, file_name], - queryFn: () => get<Blob>('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name } }, { silent: true }), + queryFn: () => get<Blob>('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name: normalizedFileName } }, { silent: true }), enabled: !!plugin_unique_identifier && !!file_name && /(^\.\/_assets|^_assets)/.test(file_name), }) }