Compare commits

...

13 Commits

Author SHA1 Message Date
yessenia
e93372de48 feat: show tool readme info 2025-09-08 20:20:55 +08:00
Stream
e981bf21a5 feat: add API endpoint to extract plugin assets 2025-08-27 20:12:18 +08:00
Stream
a015f05aea feat: add API endpoint to extract plugin assets 2025-08-27 20:03:59 +08:00
Stream
11f4743624 feat: adapt to plugin_daemon endpoint 2025-08-27 16:12:40 +08:00
Stream
7db77cf9f8 Merge branch 'main' into feat/plugin-readme 2025-08-27 11:35:04 +08:00
quicksand
424fdf4b52 fix: flask_restx namespace path wrong (#24456) 2025-08-25 14:56:20 +08:00
Wu Tianwei
bcf42362e3 feat: Optimize Docker build process by adding script to remove unnecessary files (#24450)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-25 14:44:29 +08:00
lyzno1
a4d17cb585 fix: add backdrop-blur-sm to plugin dropdown filters for consistent dark mode styling (#24454)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
2025-08-25 13:55:41 +08:00
-LAN-
a9e106b17e fix: Fix login error handling by raising exception instead of returning (#24452) 2025-08-25 13:54:25 +08:00
Muke Wang
044ad5100e fix: Update doc word count after delete chunks (#24435)
Co-authored-by: wangmuke <wangmuke@kingsware.cn>
2025-08-25 12:08:34 +08:00
Asuka Minato
3032e6fe59 example for logging (#24441) 2025-08-25 11:41:17 +08:00
yihong
4eba2ee92b docs: better doc for dev in api like Claude.md (#24442)
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2025-08-25 11:14:04 +08:00
Harry
19c10f9075 feat: add PluginReadmeApi to fetch plugin readme information 2025-08-22 16:38:48 +08:00
50 changed files with 918 additions and 173 deletions

View File

@@ -97,8 +97,16 @@ uv run celery -A app.celery beat
uv sync --dev
```
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`
1. Run the tests locally with mocked system environment variables in `tool.pytest_env` section in `pyproject.toml`, more can check [Claude.md](../CLAUDE.md)
```bash
uv run -P api bash dev/pytest/pytest_all_tests.sh
```cli
uv run --project api pytest # Run all tests
uv run --project api pytest tests/unit_tests/ # Unit tests only
uv run --project api pytest tests/integration_tests/ # Integration tests
# Code quality
./dev/reformat # Run all formatters and linters
uv run --project api ruff check --fix ./ # Fix linting issues
uv run --project api ruff format ./ # Format code
uv run --project api mypy . # Type checking
```

View File

@@ -5,6 +5,8 @@ from configs import dify_config
from contexts.wrapper import RecyclableContextVar
from dify_app import DifyApp
logger = logging.getLogger(__name__)
# ----------------------------
# Application Factory Function
@@ -32,7 +34,7 @@ def create_app() -> DifyApp:
initialize_extensions(app)
end_time = time.perf_counter()
if dify_config.DEBUG:
logging.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
return app
@@ -93,14 +95,14 @@ def initialize_extensions(app: DifyApp):
is_enabled = ext.is_enabled() if hasattr(ext, "is_enabled") else True
if not is_enabled:
if dify_config.DEBUG:
logging.info("Skipped %s", short_name)
logger.info("Skipped %s", short_name)
continue
start_time = time.perf_counter()
ext.init_app(app)
end_time = time.perf_counter()
if dify_config.DEBUG:
logging.info("Loaded %s (%s ms)", short_name, round((end_time - start_time) * 1000, 2))
logger.info("Loaded %s (%s ms)", short_name, round((end_time - start_time) * 1000, 2))
def create_migrations_app():

View File

@@ -221,7 +221,7 @@ class EmailCodeLoginApi(Resource):
email=user_email, name=user_email, interface_language=languages[0]
)
except WorkSpaceNotAllowedCreateError:
return NotAllowedCreateWorkspace()
raise NotAllowedCreateWorkspace()
except AccountRegisterError as are:
raise AccountInFreezeError()
except WorkspacesLimitExceededError:

View File

@@ -107,6 +107,22 @@ class PluginIconApi(Resource):
icon_cache_max_age = dify_config.TOOL_ICON_CACHE_MAX_AGE
return send_file(io.BytesIO(icon_bytes), mimetype=mimetype, max_age=icon_cache_max_age)
class PluginAssetApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
req = reqparse.RequestParser()
req.add_argument("plugin_unique_identifier", type=str, required=True, location="args")
req.add_argument("file_name", type=str, required=True, location="args")
args = req.parse_args()
tenant_id = current_user.current_tenant_id
try:
binary = PluginService.extract_asset(tenant_id, args["plugin_unique_identifier"], args["file_name"])
return send_file(io.BytesIO(binary), mimetype="application/octet-stream")
except PluginDaemonClientSideError as e:
raise ValueError(e)
class PluginUploadFromPkgApi(Resource):
@setup_required
@@ -643,11 +659,34 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args["plugin_id"])})
class PluginReadmeApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
tenant_id = current_user.current_tenant_id
parser = reqparse.RequestParser()
parser.add_argument("plugin_unique_identifier", type=str, required=True, location="args")
parser.add_argument("language", type=str, required=False, location="args")
args = parser.parse_args()
return jsonable_encoder(
{
"readme": PluginService.fetch_plugin_readme(
tenant_id,
args["plugin_unique_identifier"],
args.get("language", "en-US")
)
}
)
api.add_resource(PluginDebuggingKeyApi, "/workspaces/current/plugin/debugging-key")
api.add_resource(PluginListApi, "/workspaces/current/plugin/list")
api.add_resource(PluginReadmeApi, "/workspaces/current/plugin/readme")
api.add_resource(PluginListLatestVersionsApi, "/workspaces/current/plugin/list/latest-versions")
api.add_resource(PluginListInstallationsFromIdsApi, "/workspaces/current/plugin/list/installations/ids")
api.add_resource(PluginIconApi, "/workspaces/current/plugin/icon")
api.add_resource(PluginAssetApi, "/workspaces/current/plugin/asset")
api.add_resource(PluginUploadFromPkgApi, "/workspaces/current/plugin/upload/pkg")
api.add_resource(PluginUploadFromGithubApi, "/workspaces/current/plugin/upload/github")
api.add_resource(PluginUploadFromBundleApi, "/workspaces/current/plugin/upload/bundle")

View File

@@ -13,7 +13,7 @@ api = ExternalApi(
doc="/docs", # Enable Swagger UI at /files/docs
)
files_ns = Namespace("files", description="File operations")
files_ns = Namespace("files", description="File operations", path="/")
from . import image_preview, tool_files, upload

View File

@@ -13,7 +13,7 @@ api = ExternalApi(
doc="/docs", # Enable Swagger UI at /mcp/docs
)
mcp_ns = Namespace("mcp", description="MCP operations")
mcp_ns = Namespace("mcp", description="MCP operations", path="/")
from . import mcp

View File

@@ -13,7 +13,7 @@ api = ExternalApi(
doc="/docs", # Enable Swagger UI at /v1/docs
)
service_api_ns = Namespace("service_api", description="Service operations")
service_api_ns = Namespace("service_api", description="Service operations", path="/")
from . import index
from .app import annotation, app, audio, completion, conversation, file, file_preview, message, site, workflow

View File

@@ -196,3 +196,7 @@ class PluginListResponse(BaseModel):
class PluginDynamicSelectOptionsResponse(BaseModel):
options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")
class PluginReadmeResponse(BaseModel):
content: str = Field(description="The readme of the plugin.")
language: str = Field(description="The language of the readme.")

View File

@@ -10,3 +10,9 @@ class PluginAssetManager(BasePluginClient):
if response.status_code != 200:
raise ValueError(f"can not found asset {id}")
return response.content
def extract_asset(self, tenant_id: str, plugin_unique_identifier: str, filename: str) -> bytes:
response = self._request(method="GET", path=f"plugin/{tenant_id}/asset/{plugin_unique_identifier}")
if response.status_code != 200:
raise ValueError(f"can not found asset {plugin_unique_identifier}, {str(response.status_code)}")
return response.content

View File

@@ -1,5 +1,7 @@
from collections.abc import Sequence
from requests import HTTPError
from core.plugin.entities.bundle import PluginBundleDependency
from core.plugin.entities.plugin import (
GenericProviderID,
@@ -14,11 +16,34 @@ from core.plugin.entities.plugin_daemon import (
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
PluginReadmeResponse,
)
from core.plugin.impl.base import BasePluginClient
class PluginInstaller(BasePluginClient):
def fetch_plugin_readme(self, tenant_id: str, plugin_unique_identifier: str, language: str) -> str:
"""
Fetch plugin readme
"""
try:
response = self._request_with_plugin_daemon_response(
"GET",
f"plugin/{tenant_id}/management/fetch/readme",
PluginReadmeResponse,
params={
"tenant_id":tenant_id,
"plugin_unique_identifier": plugin_unique_identifier,
"language": language
}
)
return response.content
except HTTPError as e:
message = e.args[0]
if "404" in message:
return ""
raise e
def fetch_plugin_by_identifier(
self,
tenant_id: str,

View File

@@ -1,10 +1,10 @@
import re
import sys
from collections.abc import Mapping
from typing import Any
from flask import current_app, got_request_exception
from flask_restx import Api
from werkzeug.datastructures import Headers
from werkzeug.exceptions import HTTPException
from werkzeug.http import HTTP_STATUS_CODES
@@ -12,125 +12,97 @@ from core.errors.error import AppInvokeQuotaExceededError
def http_status_message(code):
"""Maps an HTTP status code to the textual status"""
return HTTP_STATUS_CODES.get(code, "")
def register_external_error_handlers(api: Api) -> None:
"""Register error handlers for the API using decorators.
:param api: The Flask-RestX Api instance
"""
@api.errorhandler(HTTPException)
def handle_http_exception(e: HTTPException):
"""Handle HTTP exceptions."""
got_request_exception.send(current_app, exception=e)
if e.response is not None:
return e.get_response()
# If Werkzeug already prepared a Response, just use it.
if getattr(e, "response", None) is not None:
return e.response
headers = Headers()
status_code = e.code
status_code = getattr(e, "code", 500) or 500
# Build a safe, dict-like payload
default_data = {
"code": re.sub(r"(?<!^)(?=[A-Z])", "_", type(e).__name__).lower(),
"message": getattr(e, "description", http_status_message(status_code)),
"status": status_code,
}
if (
default_data["message"]
and default_data["message"] == "Failed to decode JSON object: Expecting value: line 1 column 1 (char 0)"
):
if default_data["message"] == "Failed to decode JSON object: Expecting value: line 1 column 1 (char 0)":
default_data["message"] = "Invalid JSON payload received or JSON payload is empty."
headers = e.get_response().headers
# Use headers on the exception if present; otherwise none.
headers = {}
exc_headers = getattr(e, "headers", None)
if exc_headers:
headers.update(exc_headers)
# Handle specific status codes
# Payload per status
if status_code == 406 and api.default_mediatype is None:
supported_mediatypes = list(api.representations.keys())
fallback_mediatype = supported_mediatypes[0] if supported_mediatypes else "text/plain"
data = {"code": "not_acceptable", "message": default_data.get("message")}
resp = api.make_response(data, status_code, headers, fallback_mediatype=fallback_mediatype)
data = {"code": "not_acceptable", "message": default_data["message"], "status": status_code}
return data, status_code, headers
elif status_code == 400:
if isinstance(default_data.get("message"), dict):
param_key, param_value = list(default_data.get("message", {}).items())[0]
data = {"code": "invalid_param", "message": param_value, "params": param_key}
msg = default_data["message"]
if isinstance(msg, Mapping) and msg:
# Convert param errors like {"field": "reason"} into a friendly shape
param_key, param_value = next(iter(msg.items()))
data = {
"code": "invalid_param",
"message": str(param_value),
"params": param_key,
"status": status_code,
}
else:
data = default_data
if "code" not in data:
data["code"] = "unknown"
resp = api.make_response(data, status_code, headers)
data = {**default_data}
data.setdefault("code", "unknown")
return data, status_code, headers
else:
data = default_data
if "code" not in data:
data["code"] = "unknown"
resp = api.make_response(data, status_code, headers)
if status_code == 401:
resp = api.unauthorized(resp)
# Remove duplicate Content-Length header
remove_headers = ("Content-Length",)
for header in remove_headers:
headers.pop(header, None)
return resp
data = {**default_data}
data.setdefault("code", "unknown")
# If you need WWW-Authenticate for 401, add it to headers
if status_code == 401:
headers["WWW-Authenticate"] = 'Bearer realm="api"'
return data, status_code, headers
@api.errorhandler(ValueError)
def handle_value_error(e: ValueError):
"""Handle ValueError exceptions."""
got_request_exception.send(current_app, exception=e)
status_code = 400
data = {
"code": "invalid_param",
"message": str(e),
"status": status_code,
}
return api.make_response(data, status_code)
data = {"code": "invalid_param", "message": str(e), "status": status_code}
return data, status_code
@api.errorhandler(AppInvokeQuotaExceededError)
def handle_quota_exceeded(e: AppInvokeQuotaExceededError):
"""Handle AppInvokeQuotaExceededError exceptions."""
got_request_exception.send(current_app, exception=e)
status_code = 429
data = {
"code": "too_many_requests",
"message": str(e),
"status": status_code,
}
return api.make_response(data, status_code)
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
return data, status_code
@api.errorhandler(Exception)
def handle_general_exception(e: Exception):
"""Handle general exceptions."""
got_request_exception.send(current_app, exception=e)
headers = Headers()
status_code = 500
default_data = {
"message": http_status_message(status_code),
}
data: dict[str, Any] = getattr(e, "data", {"message": http_status_message(status_code)})
data = getattr(e, "data", default_data)
# 🔒 Normalize non-mapping data (e.g., if someone set e.data = Response)
if not isinstance(data, Mapping):
data = {"message": str(e)}
# Log server errors
data.setdefault("code", "unknown")
data.setdefault("status", status_code)
# Log stack
exc_info: Any = sys.exc_info()
if exc_info[1] is None:
exc_info = None
current_app.log_exception(exc_info)
if "code" not in data:
data["code"] = "unknown"
# Remove duplicate Content-Length header
remove_headers = ("Content-Length",)
for header in remove_headers:
headers.pop(header, None)
return api.make_response(data, status_code, headers)
return data, status_code
class ExternalApi(Api):

View File

@@ -2344,13 +2344,9 @@ class SegmentService:
@classmethod
def delete_segments(cls, segment_ids: list, document: Document, dataset: Dataset):
# Check if segment_ids is not empty to avoid WHERE false condition
if not segment_ids or len(segment_ids) == 0:
return
index_node_ids = (
db.session.query(DocumentSegment)
.with_entities(DocumentSegment.index_node_id)
.where(
segments = (
db.session.query(DocumentSegment.index_node_id, DocumentSegment.word_count)
.filter(
DocumentSegment.id.in_(segment_ids),
DocumentSegment.dataset_id == dataset.id,
DocumentSegment.document_id == document.id,
@@ -2358,7 +2354,15 @@ class SegmentService:
)
.all()
)
index_node_ids = [index_node_id[0] for index_node_id in index_node_ids]
if not segments:
return
index_node_ids = [seg.index_node_id for seg in segments]
total_words = sum(seg.word_count for seg in segments)
document.word_count -= total_words
db.session.add(document)
delete_segment_from_index_task.delay(index_node_ids, dataset.id, document.id)
db.session.query(DocumentSegment).where(DocumentSegment.id.in_(segment_ids)).delete()

View File

@@ -186,6 +186,11 @@ class PluginService:
mime_type, _ = guess_type(asset_file)
return manager.fetch_asset(tenant_id, asset_file), mime_type or "application/octet-stream"
@staticmethod
def extract_asset(tenant_id: str, plugin_unique_identifier: str, file_name: str) -> bytes:
manager = PluginAssetManager()
return manager.extract_asset(tenant_id, plugin_unique_identifier, file_name)
@staticmethod
def check_plugin_unique_identifier(tenant_id: str, plugin_unique_identifier: str) -> bool:
"""
@@ -492,3 +497,11 @@ class PluginService:
"""
manager = PluginInstaller()
return manager.check_tools_existence(tenant_id, provider_ids)
@staticmethod
def fetch_plugin_readme(tenant_id: str, plugin_unique_identifier: str, language: str) -> str:
"""
Fetch plugin readme
"""
manager = PluginInstaller()
return manager.fetch_plugin_readme(tenant_id, plugin_unique_identifier, language)

View File

@@ -410,18 +410,18 @@ class TestAnnotationService:
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create annotations with specific keywords
unique_keyword = fake.word()
unique_keyword = f"unique_{fake.uuid4()[:8]}"
annotation_args = {
"question": f"Question with {unique_keyword} keyword",
"answer": f"Answer with {unique_keyword} keyword",
}
AppAnnotationService.insert_app_annotation_directly(annotation_args, app.id)
# Create another annotation without the keyword
other_args = {
"question": "Question without keyword",
"answer": "Answer without keyword",
"question": "Different question without special term",
"answer": "Different answer without special content",
}
AppAnnotationService.insert_app_annotation_directly(other_args, app.id)
# Search with keyword

View File

@@ -34,7 +34,7 @@ COPY --from=packages /app/web/ .
COPY . .
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN pnpm build
RUN pnpm build:docker
# production stage

View File

@@ -8,6 +8,7 @@ import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
import { ReadmePanelProvider } from '@/app/components/plugins/readme-panel/context'
import GotoAnything from '@/app/components/goto-anything'
const Layout = ({ children }: { children: ReactNode }) => {
@@ -19,11 +20,13 @@ const Layout = ({ children }: { children: ReactNode }) => {
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<GotoAnything />
<ReadmePanelProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<GotoAnything />
</ReadmePanelProvider>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>

View File

@@ -28,6 +28,7 @@ import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
type Props = {
showBackButton?: boolean
@@ -212,6 +213,7 @@ const SettingBuiltInTool: FC<Props> = ({
pluginPayload={{
provider: collection.name,
category: AuthCategory.tool,
detail: collection as any,
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
@@ -241,13 +243,14 @@ const SettingBuiltInTool: FC<Props> = ({
)}
<div className='h-0 grow overflow-y-auto px-4'>
{isInfoActive ? infoUI : settingUI}
{!readonly && !isInfoActive && (
<div className='flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2'>
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium ' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium' variant='primary' disabled={!isValid} onClick={() => onSave?.(addDefaultValue(tempSetting, formSchemas))}>{t('common.operation.save')}</Button>
</div>
)}
</div>
{!readonly && !isInfoActive && (
<div className='mt-2 flex shrink-0 justify-end space-x-2 rounded-b-[10px] border-t border-divider-regular bg-components-panel-bg px-6 py-4'>
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium ' onClick={onHide}>{t('common.operation.cancel')}</Button>
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium' variant='primary' disabled={!isValid} onClick={() => onSave?.(addDefaultValue(tempSetting, formSchemas))}>{t('common.operation.save')}</Button>
</div>
)}
<ReadmeEntrance detail={collection as any} className='mt-auto' />
</div>
</div>
</>

View File

@@ -10,6 +10,7 @@ export type IDrawerProps = {
description?: string
dialogClassName?: string
dialogBackdropClassName?: string
containerClassName?: string
panelClassName?: string
children: React.ReactNode
footer?: React.ReactNode
@@ -22,6 +23,7 @@ export type IDrawerProps = {
onCancel?: () => void
onOk?: () => void
unmount?: boolean
noOverlay?: boolean
}
export default function Drawer({
@@ -29,6 +31,7 @@ export default function Drawer({
description = '',
dialogClassName = '',
dialogBackdropClassName = '',
containerClassName = '',
panelClassName = '',
children,
footer,
@@ -41,6 +44,7 @@ export default function Drawer({
onCancel,
onOk,
unmount = false,
noOverlay = false,
}: IDrawerProps) {
const { t } = useTranslation()
return (
@@ -50,14 +54,14 @@ export default function Drawer({
onClose={() => !clickOutsideNotOpen && onClose()}
className={cn('fixed inset-0 z-[30] overflow-y-auto', dialogClassName)}
>
<div className={cn('flex h-screen w-screen justify-end', positionCenter && '!justify-center')}>
<div className={cn('flex h-screen w-screen justify-end', positionCenter && '!justify-center', containerClassName)}>
{/* mask */}
<DialogBackdrop
{!noOverlay && <DialogBackdrop
className={cn('fixed inset-0 z-[40]', mask && 'bg-black/30', dialogBackdropClassName)}
onClick={() => {
!clickOutsideNotOpen && onClose()
}}
/>
/>}
<div className={cn('relative z-[50] flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)}>
<>
<div className='flex justify-between'>

View File

@@ -3,11 +3,34 @@
* Extracted from the main markdown renderer for modularity.
* Uses the ImageGallery component to display images.
*/
import React from 'react'
import React, { useEffect, useMemo } from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
import { getMarkdownImageURL } from './utils'
import { usePluginReadmeAsset } from '@/service/use-plugins'
const Img = ({ src }: any) => {
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
const Img = ({ src, pluginUniqueIdentifier }: { src: string, pluginUniqueIdentifier?: string }) => {
const imgURL = getMarkdownImageURL(src, pluginUniqueIdentifier)
const { data: asset } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src })
const blobUrl = useMemo(() => {
if (asset)
return URL.createObjectURL(asset)
return imgURL
}, [asset, imgURL])
useEffect(() => {
return () => {
if (blobUrl && asset)
URL.revokeObjectURL(blobUrl)
}
}, [blobUrl])
return (
<div className='markdown-img-wrapper'>
<ImageGallery srcs={[blobUrl]} />
</div>
)
}
export default Img

View File

@@ -3,25 +3,43 @@
* Extracted from the main markdown renderer for modularity.
* Handles special rendering for paragraphs that directly contain an image.
*/
import React from 'react'
import React, { useEffect, useMemo } from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
// import { getMarkdownImageURL } from './utils'
import { usePluginReadmeAsset } from '@/service/use-plugins'
const Paragraph = (paragraph: any) => {
const { node }: any = paragraph
const Paragraph = (props: { pluginUniqueIdentifier?: string, node?: any, children?: any }) => {
const { node, pluginUniqueIdentifier, children } = props
const children_node = node.children
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
const { data: asset } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: children_node[0].properties?.src })
const blobUrl = useMemo(() => {
if (asset)
return URL.createObjectURL(asset)
return ''
}, [asset])
useEffect(() => {
return () => {
if (blobUrl && asset)
URL.revokeObjectURL(blobUrl)
}
}, [blobUrl])
if (children_node?.[0]?.tagName === 'img') {
// const imageURL = getMarkdownImageURL(children_node[0].properties?.src, pluginUniqueIdentifier)
return (
<div className="markdown-img-wrapper">
<ImageGallery srcs={[children_node[0].properties.src]} />
<ImageGallery srcs={[blobUrl]} />
{
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
<div className="mt-2">{paragraph.children.slice(1)}</div>
Array.isArray(children) && children.length > 1 && (
<div className="mt-2">{children.slice(1)}</div>
)
}
</div>
)
}
return <p>{paragraph.children}</p>
return <p>{children}</p>
}
export default Paragraph

View File

@@ -1,7 +1,14 @@
import { ALLOW_UNSAFE_DATA_SCHEME } from '@/config'
import { ALLOW_UNSAFE_DATA_SCHEME, MARKETPLACE_API_PREFIX } from '@/config'
export const isValidUrl = (url: string): boolean => {
const validPrefixes = ['http:', 'https:', '//', 'mailto:']
if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:')
return validPrefixes.some(prefix => url.startsWith(prefix))
}
export const getMarkdownImageURL = (url: string, pathname?: string) => {
const regex = /(^\.\/_assets|^_assets)/
if (regex.test(url))
return `${MARKETPLACE_API_PREFIX}${pathname ?? ''}${url.replace(regex, '/_assets')}`
return url
}

View File

@@ -33,10 +33,11 @@ export type MarkdownProps = {
className?: string
customDisallowedElements?: string[]
customComponents?: Record<string, React.ComponentType<any>>
pluginUniqueIdentifier?: string
}
export const Markdown = (props: MarkdownProps) => {
const { customComponents = {} } = props
const { customComponents = {}, pluginUniqueIdentifier } = props
const latexContent = flow([
preprocessThinkTag,
preprocessLaTeX,
@@ -76,11 +77,11 @@ export const Markdown = (props: MarkdownProps) => {
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
components={{
code: CodeBlock,
img: Img,
img: props => <Img {...{ pluginUniqueIdentifier, ...props, src: props.src as string }} />,
video: VideoBlock,
audio: AudioBlock,
a: Link,
p: Paragraph,
p: props => <Paragraph {...{ pluginUniqueIdentifier, ...props }} />,
button: MarkdownButton,
form: MarkdownForm,
script: ScriptBlock as any,

View File

@@ -8,6 +8,7 @@ import { noop } from 'lodash-es'
type IModal = {
className?: string
wrapperClassName?: string
containerClassName?: string
isShow: boolean
onClose?: () => void
title?: React.ReactNode
@@ -16,11 +17,13 @@ type IModal = {
closable?: boolean
overflowVisible?: boolean
highPriority?: boolean // For modals that need to appear above dropdowns
noOverlay?: boolean
}
export default function Modal({
className,
wrapperClassName,
containerClassName,
isShow,
onClose = noop,
title,
@@ -29,18 +32,19 @@ export default function Modal({
closable = false,
overflowVisible = false,
highPriority = false,
noOverlay = false,
}: IModal) {
return (
<Transition appear show={isShow} as={Fragment}>
<Dialog as="div" className={classNames('relative', highPriority ? 'z-[1100]' : 'z-[60]', wrapperClassName)} onClose={onClose}>
<TransitionChild>
{!noOverlay && <TransitionChild>
<div className={classNames(
'fixed inset-0 bg-background-overlay',
'duration-300 ease-in data-[closed]:opacity-0',
'data-[enter]:opacity-100',
'data-[leave]:opacity-0',
)} />
</TransitionChild>
</TransitionChild>}
<div
className="fixed inset-0 overflow-y-auto"
@@ -49,7 +53,7 @@ export default function Modal({
e.stopPropagation()
}}
>
<div className="flex min-h-full items-center justify-center p-4 text-center">
<div className={classNames('flex min-h-full items-center justify-center p-4 text-center', containerClassName)}>
<TransitionChild>
<DialogPanel className={classNames(
'relative w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all',

View File

@@ -26,6 +26,7 @@ type ModalProps = {
footerSlot?: React.ReactNode
bottomSlot?: React.ReactNode
disabled?: boolean
containerClassName?: string
}
const Modal = ({
onClose,
@@ -44,6 +45,7 @@ const Modal = ({
footerSlot,
bottomSlot,
disabled,
containerClassName,
}: ModalProps) => {
const { t } = useTranslation()
@@ -79,7 +81,7 @@ const Modal = ({
</div>
{
children && (
<div className='px-6 py-3'>{children}</div>
<div className={cn('px-6 py-3', containerClassName)}>{children}</div>
)
}
<div className='flex justify-between p-6 pt-5'>

View File

@@ -66,7 +66,7 @@ const TagsFilter = ({
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon

View File

@@ -20,6 +20,8 @@ import {
useGetPluginCredentialSchemaHook,
useUpdatePluginCredentialHook,
} from '../hooks/use-credential'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/context'
export type ApiKeyModalProps = {
pluginPayload: PluginPayload
@@ -142,6 +144,7 @@ const ApiKeyModal = ({
onExtraButtonClick={onRemove}
disabled={disabled || isLoading || doingAction}
>
<ReadmeEntrance detail={pluginPayload.detail} showType={ReadmeShowType.modal} />
{
isLoading && (
<div className='flex h-40 items-center justify-center'>

View File

@@ -23,6 +23,8 @@ import type {
} from '@/app/components/base/form/types'
import { useToastContext } from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/context'
type OAuthClientSettingsProps = {
pluginPayload: PluginPayload
@@ -154,16 +156,16 @@ const OAuthClientSettings = ({
</div>
)
}
containerClassName='pt-0'
>
<>
<AuthForm
formFromProps={form}
ref={formRef}
formSchemas={schemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
</>
<ReadmeEntrance detail={pluginPayload.detail} showType={ReadmeShowType.modal} />
<AuthForm
formFromProps={form}
ref={formRef}
formSchemas={schemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
</Modal>
)
}

View File

@@ -1,3 +1,5 @@
import type { PluginDetail } from '../types'
export enum AuthCategory {
tool = 'tool',
datasource = 'datasource',
@@ -7,6 +9,7 @@ export enum AuthCategory {
export type PluginPayload = {
category: AuthCategory
provider: string
detail: PluginDetail
}
export enum CredentialTypeEnum {

View File

@@ -61,7 +61,7 @@ const DetailHeader = ({
onUpdate,
}: Props) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const { userProfile: { timezone } } = useAppContext()
const { theme } = useTheme()
const locale = useGetLanguage()
@@ -128,13 +128,13 @@ const DetailHeader = ({
return false
if (!autoUpgradeInfo || !isFromMarketplace)
return false
if(autoUpgradeInfo.strategy_setting === 'disabled')
if (autoUpgradeInfo.strategy_setting === 'disabled')
return false
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
return true
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
return true
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
return true
return false
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
@@ -331,6 +331,7 @@ const DetailHeader = ({
pluginPayload={{
provider: provider?.name || '',
category: AuthCategory.tool,
detail,
}}
/>
)

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import { RiClipboardLine, RiDeleteBinLine, RiEditLine, RiLoginCircleLine } from '@remixicon/react'
import type { EndpointListItem } from '../types'
import type { EndpointListItem, PluginDetail } from '../types'
import EndpointModal from './endpoint-modal'
import { NAME_FIELD } from './utils'
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
@@ -22,11 +22,13 @@ import {
} from '@/service/use-endpoints'
type Props = {
pluginDetail: PluginDetail
data: EndpointListItem
handleChange: () => void
}
const EndpointCard = ({
pluginDetail,
data,
handleChange,
}: Props) => {
@@ -210,6 +212,7 @@ const EndpointCard = ({
defaultValues={formValue}
onCancel={hideEndpointModalConfirm}
onSaved={handleUpdate}
pluginDetail={pluginDetail}
/>
)}
</div>

View File

@@ -102,6 +102,7 @@ const EndpointList = ({ detail }: Props) => {
key={index}
data={item}
handleChange={() => invalidateEndpointList(detail.plugin_id)}
pluginDetail={detail}
/>
))}
</div>
@@ -110,6 +111,7 @@ const EndpointList = ({ detail }: Props) => {
formSchemas={formSchemas}
onCancel={hideEndpointModal}
onSaved={handleCreate}
pluginDetail={detail}
/>
)}
</div>

View File

@@ -10,12 +10,15 @@ import Form from '@/app/components/header/account-setting/model-provider-page/mo
import Toast from '@/app/components/base/toast'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import cn from '@/utils/classnames'
import { ReadmeEntrance } from '../readme-panel/entrance'
import type { PluginDetail } from '../types'
type Props = {
formSchemas: any
defaultValues?: any
onCancel: () => void
onSaved: (value: Record<string, any>) => void
pluginDetail: PluginDetail
}
const extractDefaultValues = (schemas: any[]) => {
@@ -32,6 +35,7 @@ const EndpointModal: FC<Props> = ({
defaultValues = {},
onCancel,
onSaved,
pluginDetail,
}) => {
const getValueFromI18nObject = useRenderI18nObject()
const { t } = useTranslation()
@@ -55,9 +59,9 @@ const EndpointModal: FC<Props> = ({
const value = processedCredential[field.name]
if (typeof value === 'string')
processedCredential[field.name] = value === 'true' || value === '1' || value === 'True'
else if (typeof value === 'number')
else if (typeof value === 'number')
processedCredential[field.name] = value === 1
else if (typeof value === 'boolean')
else if (typeof value === 'boolean')
processedCredential[field.name] = value
}
})
@@ -84,6 +88,7 @@ const EndpointModal: FC<Props> = ({
</ActionButton>
</div>
<div className='system-xs-regular mt-0.5 text-text-tertiary'>{t('plugin.detailPanel.endpointModalDesc')}</div>
<ReadmeEntrance detail={pluginDetail} className='px-0 pt-3' />
</div>
<div className='grow overflow-y-auto'>
<div className='px-4 py-2'>

View File

@@ -1,5 +1,5 @@
'use client'
import React from 'react'
import React, { useCallback } from 'react'
import type { FC } from 'react'
import DetailHeader from './detail-header'
import EndpointList from './endpoint-list'
@@ -9,6 +9,7 @@ import AgentStrategyList from './agent-strategy-list'
import Drawer from '@/app/components/base/drawer'
import type { PluginDetail } from '@/app/components/plugins/types'
import cn from '@/utils/classnames'
import { ReadmeEntrance } from '../readme-panel/entrance'
type Props = {
detail?: PluginDetail
@@ -21,11 +22,11 @@ const PluginDetailPanel: FC<Props> = ({
onUpdate,
onHide,
}) => {
const handleUpdate = (isDelete = false) => {
const handleUpdate = useCallback((isDelete = false) => {
if (isDelete)
onHide()
onUpdate()
}
}, [onHide, onUpdate])
if (!detail)
return null
@@ -42,16 +43,17 @@ const PluginDetailPanel: FC<Props> = ({
>
{detail && (
<>
<DetailHeader
detail={detail}
onHide={onHide}
onUpdate={handleUpdate}
/>
<DetailHeader detail={detail} onUpdate={handleUpdate} onHide={onHide} />
<div className='grow overflow-y-auto'>
{!!detail.declaration.tool && <ActionList detail={detail} />}
{!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />}
{!!detail.declaration.endpoint && <EndpointList detail={detail} />}
{!!detail.declaration.model && <ModelList detail={detail} />}
<div className='flex min-h-full flex-col'>
<div className='flex-1'>
{!!detail.declaration.tool && <ActionList detail={detail} />}
{!!detail.declaration.agent_strategy && <AgentStrategyList detail={detail} />}
{!!detail.declaration.endpoint && <EndpointList detail={detail} />}
{!!detail.declaration.model && <ModelList detail={detail} />}
</div>
<ReadmeEntrance detail={detail} className='mt-auto' />
</div>
</div>
</>
)}

View File

@@ -40,6 +40,7 @@ import {
AuthCategory,
PluginAuthInAgent,
} from '@/app/components/plugins/plugin-auth'
import { ReadmeEntrance } from '../../readme-panel/entrance'
type Props = {
disabled?: boolean
@@ -272,7 +273,10 @@ const ToolSelector: FC<Props> = ({
{/* base form */}
<div className='flex flex-col gap-3 px-4 py-2'>
<div className='flex flex-col gap-1'>
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>{t('plugin.detailPanel.toolSelector.toolLabel')}</div>
<div className='system-sm-semibold flex h-6 items-center justify-between text-text-secondary'>
{t('plugin.detailPanel.toolSelector.toolLabel')}
<ReadmeEntrance detail={currentProvider as any} showShortTip className='pb-0' />
</div>
<ToolPicker
placement='bottom'
offset={offset}
@@ -314,6 +318,7 @@ const ToolSelector: FC<Props> = ({
pluginPayload={{
provider: currentProvider.name,
category: AuthCategory.tool,
detail: currentProvider as any,
}}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}

View File

@@ -90,7 +90,7 @@ const CategoriesFilter = ({
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon

View File

@@ -85,7 +85,7 @@ const TagsFilter = ({
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg'>
<div className='w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon

View File

@@ -0,0 +1,119 @@
'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'
type PluginInfoProps = {
detail: PluginDetail
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,
label,
author,
name,
verified,
} = detail.declaration || detail
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={`${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}
/>
</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

View File

@@ -0,0 +1,6 @@
export const BUILTIN_TOOLS_ARRAY = [
'code',
'audio',
'time',
'webscraper',
]

View File

@@ -0,0 +1,63 @@
'use client'
import React, { createContext, useContext, useState } from 'react'
import type { FC, ReactNode } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import ReadmeDrawer from './index'
type ReadmePanelContextValue = {
openReadme: (detail: PluginDetail, showType?: ReadmeShowType) => void
closeReadme: () => void
currentDetailInfo?: {
detail: PluginDetail
showType: ReadmeShowType
}
}
const ReadmePanelContext = createContext<ReadmePanelContextValue | null>(null)
export const useReadmePanel = (): ReadmePanelContextValue => {
const context = useContext(ReadmePanelContext)
if (!context)
throw new Error('useReadmePanel must be used within ReadmePanelProvider')
return context
}
type ReadmePanelProviderProps = {
children: ReactNode
}
export enum ReadmeShowType {
drawer = 'drawer',
modal = 'modal',
}
export const ReadmePanelProvider: FC<ReadmePanelProviderProps> = ({ children }) => {
const [currentDetailInfo, setCurrentDetailInfo] = useState<{
detail: PluginDetail
showType: ReadmeShowType
} | undefined>()
const openReadme = (detail: PluginDetail, showType?: ReadmeShowType) => {
setCurrentDetailInfo({
detail,
showType: showType || ReadmeShowType.drawer,
})
}
const closeReadme = () => {
setCurrentDetailInfo(undefined)
}
// todo: use zustand
return (
<ReadmePanelContext.Provider value={{
openReadme,
closeReadme,
currentDetailInfo,
}}>
{children}
<ReadmeDrawer />
</ReadmePanelContext.Provider>
)
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RiBookReadLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import type { PluginDetail } from '../types'
import { ReadmeShowType, useReadmePanel } from './context'
import { BUILTIN_TOOLS_ARRAY } from './constants'
export const ReadmeEntrance = ({
detail,
showType = ReadmeShowType.drawer,
className,
showShortTip = false,
}: {
detail: PluginDetail
showType?: ReadmeShowType
className?: string
showShortTip?: boolean
}) => {
const { t } = useTranslation()
const { openReadme } = useReadmePanel()
const handleReadmeClick = () => {
if (detail)
openReadme(detail, showType)
}
if (BUILTIN_TOOLS_ARRAY.includes(detail.id))
return null
return (
<div className={cn('flex flex-col items-start justify-center gap-2 pb-4 pt-0', showType === ReadmeShowType.drawer && 'px-4', className)}>
{!showShortTip && <div className="relative h-1 w-8 shrink-0">
<div className="h-px w-full bg-divider-regular"></div>
</div>}
<button
onClick={handleReadmeClick}
className="flex w-full items-center justify-start gap-1 text-text-tertiary transition-opacity hover:text-text-accent-light-mode-only"
>
<div className="relative flex h-3 w-3 items-center justify-center overflow-hidden">
<RiBookReadLine className="h-3 w-3" />
</div>
<span className="text-xs font-normal leading-4">
{!showShortTip ? t('plugin.readmeInfo.needHelpCheckReadme') : t('plugin.readmeInfo.title')}
</span>
</button>
</div>
)
}

View File

@@ -0,0 +1,123 @@
'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 { ReadmeShowType, useReadmePanel } from './context'
const ReadmePanel: FC = () => {
const { currentDetailInfo, closeReadme: onClose } = useReadmePanel()
const detail = currentDetailInfo?.detail
const showType = currentDetailInfo?.showType
const { t } = useTranslation()
const language = useLanguage()
const pluginUniqueIdentifier = detail?.plugin_unique_identifier || ''
const readmeLanguage = language === 'zh-Hans' ? undefined : language
const { data: readmeData, isLoading, error } = usePluginReadme(
{ plugin_unique_identifier: pluginUniqueIdentifier, language: readmeLanguage },
)
if (!detail) return null
const children = (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-background-body px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
<RiBookReadLine className="h-3 w-3 text-text-tertiary" />
<span className="text-xs font-medium uppercase text-text-tertiary">
{t('plugin.readmeInfo.title')}
</span>
</div>
<ActionButton onClick={onClose}>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
<PluginTitleInfo detail={detail} size="large" />
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{(() => {
if (isLoading) {
return (
<div className="flex h-40 items-center justify-center">
<Loading type="area" />
</div>
)
}
if (error) {
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('plugin.readmeInfo.noReadmeAvailable')}</p>
</div>
)
}
if (readmeData?.readme) {
return (
<Markdown
content={readmeData.readme}
className="prose-sm prose max-w-none"
pluginUniqueIdentifier={pluginUniqueIdentifier}
/>
)
}
return (
<div className="py-8 text-center text-text-tertiary">
<p>{t('plugin.readmeInfo.noReadmeAvailable')}</p>
</div>
)
})()}
</div>
</div>
)
return (
showType === ReadmeShowType.drawer ? (
<Drawer
isOpen={!!detail}
onClose={onClose}
footer={null}
positionCenter={false}
showClose={false}
panelClassName={cn(
'mb-2 ml-2 mt-16 !w-[600px] !max-w-[600px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl',
'!z-[9999]',
)}
dialogClassName={cn('!z-[9998]')}
containerClassName='!justify-start'
noOverlay
>
{children}
</Drawer>
) : (
<Modal
isShow={!!detail}
onClose={onClose}
noOverlay
className='h-[calc(100vh-16px)] max-w-[800px] p-0'
wrapperClassName='!z-[10000]'
containerClassName='p-2'
>
{children}
</Modal>
)
)
}
export default ReadmePanel

View File

@@ -88,7 +88,7 @@ const PluginVersionPicker: FC<Props> = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className="relative w-[209px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
<div className="relative w-[209px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
<div className='system-xs-medium-uppercase px-3 pb-0.5 pt-1 text-text-tertiary'>
{t('plugin.detailPanel.switchVersion')}
</div>

View File

@@ -65,6 +65,7 @@ import {
} from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { canFindTool } from '@/utils'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
type BasePanelProps = {
children: ReactNode
@@ -169,11 +170,11 @@ const BasePanel: FC<BasePanelProps> = ({
const [isPaused, setIsPaused] = useState(false)
useEffect(() => {
if(data._singleRunningStatus === NodeRunningStatus.Running) {
if (data._singleRunningStatus === NodeRunningStatus.Running) {
hasClickRunning.current = true
setIsPaused(false)
}
else if(data._isSingleRun && data._singleRunningStatus === undefined && hasClickRunning) {
else if (data._isSingleRun && data._singleRunningStatus === undefined && hasClickRunning) {
setIsPaused(true)
hasClickRunning.current = false
}
@@ -248,9 +249,9 @@ const BasePanel: FC<BasePanelProps> = ({
})
}, [handleNodeDataUpdateWithSyncDraft, id])
if(logParams.showSpecialResultPanel) {
if (logParams.showSpecialResultPanel) {
return (
<div className={cn(
<div className={cn(
'relative mr-1 h-full',
)}>
<div
@@ -340,7 +341,7 @@ const BasePanel: FC<BasePanelProps> = ({
<div
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover'
onClick={() => {
if(isSingleRunning) {
if (isSingleRunning) {
handleNodeDataUpdate({
id,
data: {
@@ -356,7 +357,7 @@ const BasePanel: FC<BasePanelProps> = ({
>
{
isSingleRunning ? <Stop className='h-4 w-4 text-text-tertiary' />
: <RiPlayLargeLine className='h-4 w-4 text-text-tertiary' />
: <RiPlayLargeLine className='h-4 w-4 text-text-tertiary' />
}
</div>
</Tooltip>
@@ -387,6 +388,7 @@ const BasePanel: FC<BasePanelProps> = ({
pluginPayload={{
provider: currCollection?.name || '',
category: AuthCategory.tool,
detail: currCollection as any,
}}
>
<div className='flex items-center justify-between pl-4 pr-3'>
@@ -398,6 +400,7 @@ const BasePanel: FC<BasePanelProps> = ({
pluginPayload={{
provider: currCollection?.name || '',
category: AuthCategory.tool,
detail: currCollection as any,
}}
onAuthorizationItemClick={handleAuthorizationItemClick}
credentialId={data.credential_id}
@@ -483,6 +486,8 @@ const BasePanel: FC<BasePanelProps> = ({
{...passedLogParams}
/>
)}
{data.type === BlockEnum.Tool && <ReadmeEntrance detail={currCollection as any} className='mt-auto' />}
</div>
</div>
)

View File

@@ -298,6 +298,11 @@ const translation = {
clientInfo: 'As no system client secrets found for this tool provider, setup it manually is required, for redirect_uri, please use',
oauthClient: 'OAuth Client',
},
readmeInfo: {
title: 'README',
needHelpCheckReadme: 'Need help? Check the README.',
noReadmeAvailable: 'No README available',
},
}
export default translation

View File

@@ -298,6 +298,11 @@ const translation = {
clientInfo: '由于未找到此工具提供者的系统客户端密钥,因此需要手动设置,对于 redirect_uri请使用',
oauthClient: 'OAuth 客户端',
},
readmeInfo: {
title: 'README',
needHelpCheckReadme: '需要帮助?查看 README。',
noReadmeAvailable: 'README 文档不可用',
},
}
export default translation

View File

@@ -27,7 +27,10 @@ const nextConfig = {
basePath,
assetPrefix,
webpack: (config, { dev, isServer }) => {
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
if (dev) {
config.plugins.push(codeInspectorPlugin({ bundler: 'webpack' }))
}
return config
},
productionBrowserSourceMaps: false, // enable browser source map generation during the production build

View File

@@ -21,6 +21,7 @@
"scripts": {
"dev": "cross-env NODE_OPTIONS='--inspect' next dev",
"build": "next build",
"build:docker": "next build && node scripts/optimize-standalone.js",
"start": "cp -r .next/static .next/standalone/.next/static && cp -r public .next/standalone/public && cross-env PORT=$npm_config_port HOSTNAME=$npm_config_host node .next/standalone/server.js",
"lint": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache",
"lint-only-show-error": "pnpx oxlint && pnpm eslint --cache --cache-location node_modules/.cache/eslint/.eslint-cache --quiet",

38
web/scripts/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Production Build Optimization Scripts
## optimize-standalone.js
This script removes unnecessary development dependencies from the Next.js standalone build output to reduce the production Docker image size.
### What it does
The script specifically targets and removes `jest-worker` packages that are bundled with Next.js but not needed in production. These packages are included because:
1. Next.js includes jest-worker in its compiled dependencies
1. terser-webpack-plugin (used by Next.js for minification) depends on jest-worker
1. pnpm's dependency resolution creates symlinks to jest-worker in various locations
### Usage
The script is automatically run during Docker builds via the `build:docker` npm script:
```bash
# Docker build (removes jest-worker after build)
pnpm build:docker
```
To run the optimization manually:
```bash
node scripts/optimize-standalone.js
```
### What gets removed
- `node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker`
- `node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker` (symlinks)
- `node_modules/.pnpm/jest-worker@*` (actual packages)
### Impact
Removing jest-worker saves approximately 36KB per instance from the production image. While this may seem small, it helps ensure production images only contain necessary runtime dependencies.

View File

@@ -0,0 +1,149 @@
/**
* Script to optimize Next.js standalone output for production
* Removes unnecessary files like jest-worker that are bundled with Next.js
*/
const fs = require('fs');
const path = require('path');
console.log('🔧 Optimizing standalone output...');
const standaloneDir = path.join(__dirname, '..', '.next', 'standalone');
// Check if standalone directory exists
if (!fs.existsSync(standaloneDir)) {
console.error('❌ Standalone directory not found. Please run "next build" first.');
process.exit(1);
}
// List of paths to remove (relative to standalone directory)
const pathsToRemove = [
// Remove jest-worker from Next.js compiled dependencies
'node_modules/.pnpm/next@*/node_modules/next/dist/compiled/jest-worker',
// Remove jest-worker symlinks from terser-webpack-plugin
'node_modules/.pnpm/terser-webpack-plugin@*/node_modules/jest-worker',
// Remove actual jest-worker packages (directories only, not symlinks)
'node_modules/.pnpm/jest-worker@*',
];
// Function to safely remove a path
function removePath(basePath, relativePath) {
const fullPath = path.join(basePath, relativePath);
// Handle wildcard patterns
if (relativePath.includes('*')) {
const parts = relativePath.split('/');
let currentPath = basePath;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.includes('*')) {
// Find matching directories
if (fs.existsSync(currentPath)) {
const entries = fs.readdirSync(currentPath);
// replace '*' with '.*'
const regexPattern = part.replace(/\*/g, '.*');
const regex = new RegExp(`^${regexPattern}$`);
for (const entry of entries) {
if (regex.test(entry)) {
const remainingPath = parts.slice(i + 1).join('/');
const matchedPath = path.join(currentPath, entry, remainingPath);
try {
// Use lstatSync to check if path exists (works for both files and symlinks)
const stats = fs.lstatSync(matchedPath);
if (stats.isSymbolicLink()) {
// Remove symlink
fs.unlinkSync(matchedPath);
console.log(`✅ Removed symlink: ${path.relative(basePath, matchedPath)}`);
} else {
// Remove directory/file
fs.rmSync(matchedPath, { recursive: true, force: true });
console.log(`✅ Removed: ${path.relative(basePath, matchedPath)}`);
}
} catch (error) {
// Silently ignore ENOENT (path not found) errors
if (error.code !== 'ENOENT') {
console.error(`❌ Failed to remove ${matchedPath}: ${error.message}`);
}
}
}
}
}
return;
} else {
currentPath = path.join(currentPath, part);
}
}
} else {
// Direct path removal
if (fs.existsSync(fullPath)) {
try {
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`✅ Removed: ${relativePath}`);
} catch (error) {
console.error(`❌ Failed to remove ${fullPath}: ${error.message}`);
}
}
}
}
// Remove unnecessary paths
console.log('🗑️ Removing unnecessary files...');
for (const pathToRemove of pathsToRemove) {
removePath(standaloneDir, pathToRemove);
}
// Calculate size reduction
console.log('\n📊 Optimization complete!');
// Optional: Display the size of remaining jest-related files (if any)
const checkForJest = (dir) => {
const jestFiles = [];
function walk(currentPath) {
if (!fs.existsSync(currentPath)) return;
try {
const entries = fs.readdirSync(currentPath);
for (const entry of entries) {
const fullPath = path.join(currentPath, entry);
try {
const stat = fs.lstatSync(fullPath); // Use lstatSync to handle symlinks
if (stat.isDirectory() && !stat.isSymbolicLink()) {
// Skip node_modules subdirectories to avoid deep traversal
if (entry === 'node_modules' && currentPath !== standaloneDir) {
continue;
}
walk(fullPath);
} else if (stat.isFile() && entry.includes('jest')) {
jestFiles.push(path.relative(standaloneDir, fullPath));
}
} catch (err) {
// Skip files that can't be accessed
continue;
}
}
} catch (err) {
// Skip directories that can't be read
return;
}
}
walk(dir);
return jestFiles;
};
const remainingJestFiles = checkForJest(standaloneDir);
if (remainingJestFiles.length > 0) {
console.log('\n⚠ Warning: Some jest-related files still remain:');
remainingJestFiles.forEach(file => console.log(` - ${file}`));
} else {
console.log('\n✨ No jest-related files found in standalone output!');
}

View File

@@ -626,3 +626,19 @@ 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 } }),
enabled: !!plugin_unique_identifier,
})
}
export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => {
return useQuery({
queryKey: ['pluginReadmeAsset', plugin_unique_identifier, file_name],
queryFn: () => get<Blob>('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name } }),
enabled: !!plugin_unique_identifier && !!file_name && /(^\.\/_assets|^_assets)/.test(file_name),
})
}