diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 68a0c7e8b0e..e974d290414 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -47,7 +47,9 @@ from controllers.console.explore.error import ( NotWorkflowAppError, ) from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable -from controllers.console.wraps import with_current_user +from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request +from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request +from controllers.console.wraps import cloud_edition_billing_resource_check, with_current_user from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.base_app_queue_manager import AppQueueManager @@ -61,12 +63,13 @@ from extensions.ext_database import db from extensions.ext_redis import redis_client from fields.base import ResponseModel from fields.conversation_variable_fields import WorkflowConversationVariableResponse +from fields.file_fields import FileResponse, FileWithSignedUrl from fields.message_fields import SuggestedQuestionsResponse from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.helper import dump_response, to_timestamp, uuid_value -from models import Account +from models import Account, App from models.account import TenantStatus from models.model import AppMode, Site, load_annotation_reply_config from models.workflow import Workflow @@ -428,6 +431,36 @@ register_response_schema_models( simple_account_model = console_ns.models[TrialSimpleAccount.__name__] +class TrialAppFileUploadApi(TrialAppResource): + @trial_feature_enable + @cloud_edition_billing_resource_check("documents") + @console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS) + @console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__]) + @with_current_user + def post(self, current_user: Account, app_model: App): + """Upload a file into the tenant that owns the trial app.""" + upload_file = upload_file_from_request( + current_user=current_user, + resource_tenant_id=app_model.tenant_id, + ) + return dump_response(FileResponse, upload_file), 201 + + +class TrialAppRemoteFileUploadApi(TrialAppResource): + @trial_feature_enable + @cloud_edition_billing_resource_check("documents") + @console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__]) + @console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__]) + @with_current_user + def post(self, current_user: Account, app_model: App): + """Upload a remote file into the tenant that owns the trial app.""" + remote_file = upload_remote_file_from_request( + current_user=current_user, + resource_tenant_id=app_model.tenant_id, + ) + return remote_file.model_dump(mode="json"), 201 + + class TrialAppWorkflowRunApi(TrialAppResource): @trial_feature_enable @console_ns.expect(console_ns.models[WorkflowRunRequest.__name__]) @@ -887,6 +920,18 @@ class DatasetListApi(Resource): console_ns.add_resource(TrialChatApi, "/trial-apps//chat-messages", endpoint="trial_app_chat_completion") +console_ns.add_resource( + TrialAppFileUploadApi, + "/trial-apps//files/upload", + endpoint="trial_app_file_upload", +) + +console_ns.add_resource( + TrialAppRemoteFileUploadApi, + "/trial-apps//remote-files/upload", + endpoint="trial_app_remote_file_upload", +) + console_ns.add_resource( TrialMessageSuggestedQuestionApi, "/trial-apps//messages//suggested-questions", diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 5e680c83702..a2df1a07845 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -29,7 +29,7 @@ from extensions.ext_database import db from fields.file_fields import FileResponse, UploadConfig from libs.helper import dump_response from libs.login import login_required -from models import Account +from models import Account, UploadFile from services.file_service import FileService from . import console_ns @@ -39,7 +39,7 @@ register_response_schema_models(console_ns, AllowedExtensionsResponse, TextConte PREVIEW_WORDS_LIMIT = 3000 -_FILE_UPLOAD_PARAMS = { +FILE_UPLOAD_PARAMS = { "file": { "description": "File to upload", "in": "formData", @@ -56,6 +56,43 @@ _FILE_UPLOAD_PARAMS = { } +def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile: + """Validate the multipart request and persist the file under the requested resource tenant.""" + source_str = request.form.get("source") + source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None + + if "file" not in request.files: + raise NoFileUploadedError() + + if len(request.files) > 1: + raise TooManyFilesError() + file = request.files["file"] + + if not file.filename: + raise FilenameNotExistsError + if source == "datasets" and not current_user.is_dataset_editor: + raise Forbidden() + + if source not in ("datasets", None): + source = None + + try: + return FileService(db.engine).upload_file( + filename=file.filename, + content=file.stream.read(), + mimetype=file.mimetype, + user=current_user, + tenant_id=resource_tenant_id, + source=source, + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as blocked_extension_error: + raise BlockedFileExtensionError(blocked_extension_error.description) + + @console_ns.route("/files/upload") class FileApi(Resource): @setup_required @@ -81,42 +118,11 @@ class FileApi(Resource): @login_required @account_initialization_required @cloud_edition_billing_resource_check("documents") - @console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS) + @console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS) @console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__]) @with_current_user def post(self, current_user: Account): - source_str = request.form.get("source") - source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None - - if "file" not in request.files: - raise NoFileUploadedError() - - if len(request.files) > 1: - raise TooManyFilesError() - file = request.files["file"] - - if not file.filename: - raise FilenameNotExistsError - if source == "datasets" and not current_user.is_dataset_editor: - raise Forbidden() - - if source not in ("datasets", None): - source = None - - try: - upload_file = FileService(db.engine).upload_file( - filename=file.filename, - content=file.stream.read(), - mimetype=file.mimetype, - user=current_user, - source=source, - ) - except services.errors.file.FileTooLargeError as file_too_large_error: - raise FileTooLargeError(file_too_large_error.description) - except services.errors.file.UnsupportedFileTypeError: - raise UnsupportedFileTypeError() - except services.errors.file.BlockedFileExtensionError as blocked_extension_error: - raise BlockedFileExtensionError(blocked_extension_error.description) + upload_file = upload_file_from_request(current_user=current_user) return dump_response(FileResponse, upload_file), 201 diff --git a/api/controllers/console/remote_files.py b/api/controllers/console/remote_files.py index 20f96072e70..c771f4489ac 100644 --- a/api/controllers/console/remote_files.py +++ b/api/controllers/console/remote_files.py @@ -46,6 +46,61 @@ class GetRemoteFileInfo(Resource): ).model_dump(mode="json") +def upload_remote_file_from_request( + *, + current_user: Account, + resource_tenant_id: str | None = None, +) -> FileWithSignedUrl: + """Validate the JSON request, fetch its remote file, and persist it under the requested tenant.""" + payload = RemoteFileUploadPayload.model_validate(console_ns.payload) + url = payload.url + + # Try to fetch remote file metadata/content first + try: + resp = remote_fetcher.make_request("HEAD", url=url) + if resp.status_code != httpx.codes.OK: + resp = remote_fetcher.make_request("GET", url=url, timeout=3, follow_redirects=True) + if resp.status_code != httpx.codes.OK: + # Normalize into a user-friendly error message expected by tests + raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}") + except httpx.RequestError as e: + raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(e)}") + + file_info = helpers.guess_file_info_from_response(resp) + + # Enforce file size limit with 400 (Bad Request) per tests' expectation + if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size): + raise FileTooLargeError() + + # Load content if needed + content = resp.content if resp.request.method == "GET" else remote_fetcher.make_request("GET", url).content + + try: + upload_file = FileService(db.engine).upload_file( + filename=file_info.filename, + content=content, + mimetype=file_info.mimetype, + user=current_user, + tenant_id=resource_tenant_id, + source_url=url, + ) + except services.errors.file.FileTooLargeError as file_too_large_error: + raise FileTooLargeError(file_too_large_error.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + + return FileWithSignedUrl( + id=upload_file.id, + name=upload_file.name, + size=upload_file.size, + extension=upload_file.extension, + url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id), + mime_type=upload_file.mime_type, + created_by=upload_file.created_by, + created_at=int(upload_file.created_at.timestamp()), + ) + + @console_ns.route("/remote-files/upload") class RemoteFileUpload(Resource): @console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__]) @@ -53,53 +108,8 @@ class RemoteFileUpload(Resource): @login_required @with_current_user def post(self, current_user: Account): - payload = RemoteFileUploadPayload.model_validate(console_ns.payload) - url = payload.url - - # Try to fetch remote file metadata/content first - try: - resp = remote_fetcher.make_request("HEAD", url=url) - if resp.status_code != httpx.codes.OK: - resp = remote_fetcher.make_request("GET", url=url, timeout=3, follow_redirects=True) - if resp.status_code != httpx.codes.OK: - # Normalize into a user-friendly error message expected by tests - raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}") - except httpx.RequestError as e: - raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(e)}") - - file_info = helpers.guess_file_info_from_response(resp) - - # Enforce file size limit with 400 (Bad Request) per tests' expectation - if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size): - raise FileTooLargeError() - - # Load content if needed - content = resp.content if resp.request.method == "GET" else remote_fetcher.make_request("GET", url).content - - try: - upload_file = FileService(db.engine).upload_file( - filename=file_info.filename, - content=content, - mimetype=file_info.mimetype, - user=current_user, - source_url=url, - ) - except services.errors.file.FileTooLargeError as file_too_large_error: - raise FileTooLargeError(file_too_large_error.description) - except services.errors.file.UnsupportedFileTypeError: - raise UnsupportedFileTypeError() - - # Success: return created resource with 201 status + remote_file = upload_remote_file_from_request(current_user=current_user) return ( - FileWithSignedUrl( - id=upload_file.id, - name=upload_file.name, - size=upload_file.size, - extension=upload_file.extension, - url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id), - mime_type=upload_file.mime_type, - created_by=upload_file.created_by, - created_at=int(upload_file.created_at.timestamp()), - ).model_dump(mode="json"), + remote_file.model_dump(mode="json"), 201, ) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ecc7ef3c9e5..d1760c1c762 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9639,6 +9639,27 @@ Bedrock retrieval test (internal use only) | ---- | ----------- | ------ | | 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)
| +### [POST] /trial-apps/{app_id}/files/upload +**Upload a file into the tenant that owns the trial app** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary, **"source"**: string,
**Available values:** "datasets" }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| + ### [GET] /trial-apps/{app_id}/messages/{message_id}/suggested-questions #### Parameters @@ -9668,6 +9689,27 @@ Bedrock retrieval test (internal use only) | ---- | ----------- | ------ | | 200 | Success | **application/json**: [Parameters](#parameters)
| +### [POST] /trial-apps/{app_id}/remote-files/upload +**Upload a remote file into the tenant that owns the trial app** + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RemoteFileUploadPayload](#remotefileuploadpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | **application/json**: [FileWithSignedUrl](#filewithsignedurl)
| + ### [GET] /trial-apps/{app_id}/site **Retrieve app site info** diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 9d84e4c6fad..4d5b54345fa 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -189,6 +189,44 @@ def test_trial_app_detail_serializes_with_explicit_session(app: Flask, monkeypat module.TrialAppDetailResponse.model_validate.assert_called_once_with(response_view, from_attributes=True) +class TestTrialAppFileUploadApi: + def test_upload_uses_trial_app_tenant(self, app: Flask, account: Account) -> None: + api = module.TrialAppFileUploadApi() + method = unwrap(api.post) + app_model = SimpleNamespace(tenant_id="app-tenant-id") + upload_file = MagicMock() + + with ( + app.test_request_context("/", method="POST"), + patch.object(module, "upload_file_from_request", return_value=upload_file) as upload, + patch.object(module, "dump_response", return_value={"id": "upload-file-id"}), + ): + response, status = method(api, account, app_model) + + assert status == 201 + assert response == {"id": "upload-file-id"} + upload.assert_called_once_with(current_user=account, resource_tenant_id="app-tenant-id") + + +class TestTrialAppRemoteFileUploadApi: + def test_upload_uses_trial_app_tenant(self, app: Flask, account: Account) -> None: + api = module.TrialAppRemoteFileUploadApi() + method = unwrap(api.post) + app_model = SimpleNamespace(tenant_id="app-tenant-id") + remote_file = MagicMock() + remote_file.model_dump.return_value = {"id": "upload-file-id"} + + with ( + app.test_request_context("/", method="POST", json={"url": "https://example.com/file.txt"}), + patch.object(module, "upload_remote_file_from_request", return_value=remote_file) as upload, + ): + response, status = method(api, account, app_model) + + assert status == 201 + assert response == {"id": "upload-file-id"} + upload.assert_called_once_with(current_user=account, resource_tenant_id="app-tenant-id") + + class TestTrialAppWorkflowRunApi: def test_not_workflow_app(self, app: Flask, account: Account) -> None: api = module.TrialAppWorkflowRunApi() diff --git a/api/tests/unit_tests/controllers/console/test_files.py b/api/tests/unit_tests/controllers/console/test_files.py index e62af24c082..c36b6395291 100644 --- a/api/tests/unit_tests/controllers/console/test_files.py +++ b/api/tests/unit_tests/controllers/console/test_files.py @@ -18,6 +18,7 @@ from controllers.console.files import ( FileApi, FilePreviewApi, FileSupportTypeApi, + upload_file_from_request, ) from models import Account from models.account import AccountStatus, TenantAccountRole @@ -181,6 +182,22 @@ class TestFileApiPost: assert response["id"] == "file-id-123" assert response["name"] == "test.txt" + def test_upload_with_resource_tenant(self, app: Flask, mock_account_context, mock_file_service): + upload_file = MagicMock() + mock_file_service.upload_file.return_value = upload_file + + with app.test_request_context( + method="POST", + data={"file": (io.BytesIO(b"hello"), "test.txt")}, + ): + result = upload_file_from_request( + current_user=mock_account_context, + resource_tenant_id="app-tenant-id", + ) + + assert result is upload_file + assert mock_file_service.upload_file.call_args.kwargs["tenant_id"] == "app-tenant-id" + def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service): """Test that invalid source parameter gets normalized to None""" api = FileApi() diff --git a/api/tests/unit_tests/controllers/console/test_remote_files.py b/api/tests/unit_tests/controllers/console/test_remote_files.py index 25245e743c5..f50d60e390d 100644 --- a/api/tests/unit_tests/controllers/console/test_remote_files.py +++ b/api/tests/unit_tests/controllers/console/test_remote_files.py @@ -186,6 +186,39 @@ def test_remote_file_upload_success_when_fetch_falls_back_to_get(app: Flask, mon content=b"fallback-content", mimetype="text/plain", user=current_user, + tenant_id=None, + source_url=url, + ) + + +def test_remote_file_upload_assigns_resource_tenant(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + url = "https://example.com/report.txt" + response = _FakeResponse(status_code=200, method="GET", content=b"content") + monkeypatch.setattr(remote_files_module.remote_fetcher, "make_request", MagicMock(return_value=response)) + + file_service_cls, current_user = _mock_upload_dependencies(monkeypatch) + file_service_cls.return_value.upload_file.return_value = SimpleNamespace( + id="file-1", + name="report.txt", + size=7, + extension=".txt", + mime_type="text/plain", + created_by="u1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + with app.test_request_context(method="POST", json={"url": url}): + remote_files_module.upload_remote_file_from_request( + current_user=current_user, + resource_tenant_id="app-tenant-id", + ) + + file_service_cls.return_value.upload_file.assert_called_once_with( + filename="report.txt", + content=b"content", + mimetype="text/plain", + user=current_user, + tenant_id="app-tenant-id", source_url=url, ) diff --git a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts index 69617d3c90e..99e3cd70b61 100644 --- a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts @@ -24,6 +24,12 @@ import { zPostTrialAppsByAppIdCompletionMessagesBody, zPostTrialAppsByAppIdCompletionMessagesPath, zPostTrialAppsByAppIdCompletionMessagesResponse, + zPostTrialAppsByAppIdFilesUploadBody, + zPostTrialAppsByAppIdFilesUploadPath, + zPostTrialAppsByAppIdFilesUploadResponse, + zPostTrialAppsByAppIdRemoteFilesUploadBody, + zPostTrialAppsByAppIdRemoteFilesUploadPath, + zPostTrialAppsByAppIdRemoteFilesUploadResponse, zPostTrialAppsByAppIdTextToAudioBody, zPostTrialAppsByAppIdTextToAudioPath, zPostTrialAppsByAppIdTextToAudioResponse, @@ -109,6 +115,35 @@ export const datasets = { get, } +/** + * Upload a file into the tenant that owns the trial app + */ +export const post4 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postTrialAppsByAppIdFilesUpload', + path: '/trial-apps/{app_id}/files/upload', + successStatus: 201, + summary: 'Upload a file into the tenant that owns the trial app', + tags: ['console'], + }) + .input( + z.object({ + body: zPostTrialAppsByAppIdFilesUploadBody, + params: zPostTrialAppsByAppIdFilesUploadPath, + }), + ) + .output(zPostTrialAppsByAppIdFilesUploadResponse) + +export const upload = { + post: post4, +} + +export const files = { + upload, +} + export const get2 = oc .route({ inputStructure: 'detailed', @@ -151,6 +186,35 @@ export const parameters = { get: get3, } +/** + * Upload a remote file into the tenant that owns the trial app + */ +export const post5 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postTrialAppsByAppIdRemoteFilesUpload', + path: '/trial-apps/{app_id}/remote-files/upload', + successStatus: 201, + summary: 'Upload a remote file into the tenant that owns the trial app', + tags: ['console'], + }) + .input( + z.object({ + body: zPostTrialAppsByAppIdRemoteFilesUploadBody, + params: zPostTrialAppsByAppIdRemoteFilesUploadPath, + }), + ) + .output(zPostTrialAppsByAppIdRemoteFilesUploadResponse) + +export const upload2 = { + post: post5, +} + +export const remoteFiles = { + upload: upload2, +} + /** * Retrieve app site info * @@ -174,7 +238,7 @@ export const site = { get: get4, } -export const post4 = oc +export const post6 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -191,13 +255,13 @@ export const post4 = oc .output(zPostTrialAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post4, + post: post6, } /** * Run workflow */ -export const post5 = oc +export const post7 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -215,13 +279,13 @@ export const post5 = oc .output(zPostTrialAppsByAppIdWorkflowsRunResponse) export const run = { - post: post5, + post: post7, } /** * Stop workflow task */ -export const post6 = oc +export const post8 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -234,7 +298,7 @@ export const post6 = oc .output(zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse) export const stop = { - post: post6, + post: post8, } export const byTaskId = { @@ -287,8 +351,10 @@ export const byAppId = { chatMessages, completionMessages, datasets, + files, messages, parameters, + remoteFiles, site, textToAudio, workflows, diff --git a/packages/contracts/generated/api/console/trial-apps/types.gen.ts b/packages/contracts/generated/api/console/trial-apps/types.gen.ts index ce2da03f7c9..07505c849ee 100644 --- a/packages/contracts/generated/api/console/trial-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/types.gen.ts @@ -64,6 +64,24 @@ export type TrialDatasetListResponse = { total: number } +export type FileResponse = { + conversation_id?: string | null + created_at?: number | null + created_by?: string | null + extension?: string | null + file_key?: string | null + id: string + mime_type?: string | null + name: string + original_url?: string | null + preview_url?: string | null + reference?: string | null + size: number + source_url?: string | null + tenant_id?: string | null + user_id?: string | null +} + export type SuggestedQuestionsResponse = { data: Array } @@ -83,6 +101,21 @@ export type Parameters = { user_input_form: Array } +export type RemoteFileUploadPayload = { + url: string +} + +export type FileWithSignedUrl = { + created_at: number | null + created_by: string | null + extension: string | null + id: string + mime_type: string | null + name: string + size: number + url: string | null +} + export type Site = { chat_color_theme?: string | null chat_color_theme_inverted: boolean @@ -378,6 +411,25 @@ export type GetTrialAppsByAppIdDatasetsResponses = { export type GetTrialAppsByAppIdDatasetsResponse = GetTrialAppsByAppIdDatasetsResponses[keyof GetTrialAppsByAppIdDatasetsResponses] +export type PostTrialAppsByAppIdFilesUploadData = { + body: { + file: Blob | File + source?: 'datasets' + } + path: { + app_id: string + } + query?: never + url: '/trial-apps/{app_id}/files/upload' +} + +export type PostTrialAppsByAppIdFilesUploadResponses = { + 201: FileResponse +} + +export type PostTrialAppsByAppIdFilesUploadResponse = + PostTrialAppsByAppIdFilesUploadResponses[keyof PostTrialAppsByAppIdFilesUploadResponses] + export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsData = { body?: never path: { @@ -411,6 +463,22 @@ export type GetTrialAppsByAppIdParametersResponses = { export type GetTrialAppsByAppIdParametersResponse = GetTrialAppsByAppIdParametersResponses[keyof GetTrialAppsByAppIdParametersResponses] +export type PostTrialAppsByAppIdRemoteFilesUploadData = { + body: RemoteFileUploadPayload + path: { + app_id: string + } + query?: never + url: '/trial-apps/{app_id}/remote-files/upload' +} + +export type PostTrialAppsByAppIdRemoteFilesUploadResponses = { + 201: FileWithSignedUrl +} + +export type PostTrialAppsByAppIdRemoteFilesUploadResponse = + PostTrialAppsByAppIdRemoteFilesUploadResponses[keyof PostTrialAppsByAppIdRemoteFilesUploadResponses] + export type GetTrialAppsByAppIdSiteData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 53ab356f350..14d8bed2c92 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -32,6 +32,27 @@ export const zCompletionRequest = z.object({ retriever_from: z.string().optional().default('explore_app'), }) +/** + * FileResponse + */ +export const zFileResponse = z.object({ + conversation_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + extension: z.string().nullish(), + file_key: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + original_url: z.string().nullish(), + preview_url: z.string().nullish(), + reference: z.string().nullish(), + size: z.int(), + source_url: z.string().nullish(), + tenant_id: z.string().nullish(), + user_id: z.string().nullish(), +}) + /** * SuggestedQuestionsResponse */ @@ -39,6 +60,27 @@ export const zSuggestedQuestionsResponse = z.object({ data: z.array(z.string()), }) +/** + * RemoteFileUploadPayload + */ +export const zRemoteFileUploadPayload = z.object({ + url: z.string(), +}) + +/** + * FileWithSignedUrl + */ +export const zFileWithSignedUrl = z.object({ + created_at: z.int().nullable(), + created_by: z.string().nullable(), + extension: z.string().nullable(), + id: z.string(), + mime_type: z.string().nullable(), + name: z.string(), + size: z.int(), + url: z.string().nullable(), +}) + /** * Site */ @@ -414,6 +456,20 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ */ export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetListResponse +export const zPostTrialAppsByAppIdFilesUploadBody = z.object({ + file: z.custom(), + source: z.enum(['datasets']).optional(), +}) + +export const zPostTrialAppsByAppIdFilesUploadPath = z.object({ + app_id: z.uuid(), +}) + +/** + * File uploaded successfully + */ +export const zPostTrialAppsByAppIdFilesUploadResponse = zFileResponse + export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath = z.object({ app_id: z.uuid(), message_id: z.uuid(), @@ -434,6 +490,17 @@ export const zGetTrialAppsByAppIdParametersPath = z.object({ */ export const zGetTrialAppsByAppIdParametersResponse = zParameters +export const zPostTrialAppsByAppIdRemoteFilesUploadBody = zRemoteFileUploadPayload + +export const zPostTrialAppsByAppIdRemoteFilesUploadPath = z.object({ + app_id: z.uuid(), +}) + +/** + * File uploaded successfully + */ +export const zPostTrialAppsByAppIdRemoteFilesUploadResponse = zFileWithSignedUrl + export const zGetTrialAppsByAppIdSitePath = z.object({ app_id: z.uuid(), }) diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index eb3ed279ffa..dad8afc8668 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -9,12 +9,20 @@ const mockNavigationState = vi.hoisted(() => ({ params: {} as { token?: string }, pathname: '/chat', })) +const mockFileUploadContext = vi.hoisted(() => ({ + localUploadUrl: undefined as string | undefined, + remoteUploadUrl: undefined as string | undefined, +})) vi.mock('@/next/navigation', () => ({ useParams: () => mockNavigationState.params, usePathname: () => mockNavigationState.pathname, })) +vi.mock('../upload-context', () => ({ + useFileUploadContext: () => mockFileUploadContext, +})) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (message: string) => mockNotify({ type: 'error', message }), @@ -124,6 +132,8 @@ describe('useFile', () => { mockStoreFiles = [] mockNavigationState.params = {} mockNavigationState.pathname = '/chat' + mockFileUploadContext.localUploadUrl = undefined + mockFileUploadContext.remoteUploadUrl = undefined mockIsAllowedFileExtension.mockReturnValue(true) mockGetSupportFileType.mockReturnValue('document') }) @@ -369,6 +379,21 @@ describe('useFile', () => { expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false) }) + it('should upload a remote file through the configured resource-scoped endpoint', () => { + mockFileUploadContext.remoteUploadUrl = '/trial-apps/app-id/remote-files/upload' + mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLoadFileFromLink('https://example.com/file.txt') + + expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith( + 'https://example.com/file.txt', + false, + undefined, + '/trial-apps/app-id/remote-files/upload', + ) + }) + it('should use human input form remote upload on form page', () => { mockNavigationState.params = { token: 'form-token' } mockNavigationState.pathname = '/form/form-token' @@ -782,6 +807,20 @@ describe('useFile', () => { expect(mockSetFiles).toHaveBeenCalled() }) + it('should upload through the configured resource-scoped endpoint', () => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + mockFileUploadContext.localUploadUrl = '/trial-apps/app-id/files/upload' + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) + + expect(mockFileUpload).toHaveBeenCalledWith( + expect.any(Object), + false, + '/trial-apps/app-id/files/upload', + ) + }) + it('should use human input form local upload on form page', () => { mockNavigationState.params = { token: 'form-token' } mockNavigationState.pathname = '/form/form-token' diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 89da65db598..0f8ad3c646e 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -22,6 +22,7 @@ import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } fro import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' import { useFileStore } from './store' +import { useFileUploadContext } from './upload-context' import { fileUpload, getFileUploadErrorMessage, @@ -54,6 +55,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { const fileStore = useFileStore() const params = useParams() const pathname = usePathname() + const { localUploadUrl, remoteUploadUrl } = useFileUploadContext() const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit( fileConfig.fileUploadConfig, ) @@ -196,11 +198,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { ...uploadParams, }) } else { - fileUpload(uploadParams, !!params.token) + fileUpload(uploadParams, !!params.token, localUploadUrl) } } }, - [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token], + [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token, localUploadUrl], ) const startProgressTimer = useCallback( @@ -234,9 +236,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { handleAddFile(uploadingFile) startProgressTimer(uploadingFile.id) - const remoteUpload = isHumanInputFormPage - ? uploadHumanInputFormRemoteFileInfo(formToken!, url) - : uploadRemoteFileInfo(url, !!params.token) + let remoteUpload + if (isHumanInputFormPage) remoteUpload = uploadHumanInputFormRemoteFileInfo(formToken!, url) + else if (remoteUploadUrl) + remoteUpload = uploadRemoteFileInfo(url, !!params.token, undefined, remoteUploadUrl) + else remoteUpload = uploadRemoteFileInfo(url, !!params.token) remoteUpload .then((res) => { @@ -287,6 +291,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { isHumanInputFormPage, formToken, params.token, + remoteUploadUrl, ], ) @@ -374,7 +379,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { ...uploadParams, }) } else { - fileUpload(uploadParams, !!params.token) + fileUpload(uploadParams, !!params.token, localUploadUrl) } }, false, @@ -397,6 +402,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { isHumanInputFormPage, formToken, params.token, + localUploadUrl, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled, diff --git a/web/app/components/base/file-uploader/upload-context.ts b/web/app/components/base/file-uploader/upload-context.ts new file mode 100644 index 00000000000..098f3c3d265 --- /dev/null +++ b/web/app/components/base/file-uploader/upload-context.ts @@ -0,0 +1,10 @@ +import { createContext, use } from 'react' + +export const FileUploadContext = createContext<{ + localUploadUrl?: string + remoteUploadUrl?: string +}>({}) + +export function useFileUploadContext() { + return use(FileUploadContext) +} diff --git a/web/app/components/explore/try-app/app/index.tsx b/web/app/components/explore/try-app/app/index.tsx index 76741603a47..ffea69b8a60 100644 --- a/web/app/components/explore/try-app/app/index.tsx +++ b/web/app/components/explore/try-app/app/index.tsx @@ -1,8 +1,8 @@ 'use client' -import type { FC } from 'react' import type { AppData } from '@/models/share' import type { TryAppInfo } from '@/service/try-app' -import * as React from 'react' +import { memo } from 'react' +import { FileUploadContext } from '@/app/components/base/file-uploader/upload-context' import useDocumentTitle from '@/hooks/use-document-title' import Chat from './chat' import TextGeneration from './text-generation' @@ -12,30 +12,37 @@ type Props = Readonly<{ appDetail: TryAppInfo }> -const TryApp: FC = ({ appId, appDetail }) => { +function TryApp({ appId, appDetail }: Props) { const mode = appDetail?.mode const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) const isCompletion = !isChat useDocumentTitle(appDetail?.site?.title || '') return ( -
- {isChat && } - {isCompletion && ( - - )} -
+ +
+ {isChat && } + {isCompletion && ( + + )} +
+
) } -export default React.memo(TryApp) +export default memo(TryApp) diff --git a/web/service/common.ts b/web/service/common.ts index 89dff9fddaa..b14bbed4340 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -223,9 +223,10 @@ export const uploadRemoteFileInfo = ( url: string, isPublic?: boolean, silent?: boolean, + uploadEndpoint?: string, ): Promise<{ id: string; name: string; size: number; mime_type: string; url: string }> => { return post<{ id: string; name: string; size: number; mime_type: string; url: string }>( - '/remote-files/upload', + uploadEndpoint || '/remote-files/upload', { body: { url } }, { isPublicAPI: isPublic, silent }, )