fix(api, web): scope trial app file uploads to the app tenant (#39149)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
林玮 (Jade Lin) 2026-07-20 15:00:45 +08:00 committed by GitHub
parent 6022939bf0
commit f93a5b95c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 573 additions and 118 deletions

View File

@ -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/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
console_ns.add_resource(
TrialAppFileUploadApi,
"/trial-apps/<uuid:app_id>/files/upload",
endpoint="trial_app_file_upload",
)
console_ns.add_resource(
TrialAppRemoteFileUploadApi,
"/trial-apps/<uuid:app_id>/remote-files/upload",
endpoint="trial_app_remote_file_upload",
)
console_ns.add_resource(
TrialMessageSuggestedQuestionApi,
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",

View File

@ -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

View File

@ -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,
)

View File

@ -9639,6 +9639,27 @@ Bedrock retrieval test (internal use only)
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)<br> |
### [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, <br>**Available values:** "datasets" }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)<br> |
### [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)<br> |
### [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)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | File uploaded successfully | **application/json**: [FileWithSignedUrl](#filewithsignedurl)<br> |
### [GET] /trial-apps/{app_id}/site
**Retrieve app site info**

View File

@ -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()

View File

@ -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()

View File

@ -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,
)

View File

@ -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,

View File

@ -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<string>
}
@ -83,6 +101,21 @@ export type Parameters = {
user_input_form: Array<JsonObject>
}
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: {

View File

@ -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<Blob | File>(),
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(),
})

View File

@ -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'

View File

@ -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,

View File

@ -0,0 +1,10 @@
import { createContext, use } from 'react'
export const FileUploadContext = createContext<{
localUploadUrl?: string
remoteUploadUrl?: string
}>({})
export function useFileUploadContext() {
return use(FileUploadContext)
}

View File

@ -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<Props> = ({ 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 (
<div className="flex size-full">
{isChat && <Chat appId={appId} appDetail={appDetail} className="h-full grow" />}
{isCompletion && (
<TextGeneration
appId={appId}
className="h-full grow"
isWorkflow={mode === 'workflow'}
appData={
{
app_id: appId,
custom_config: {},
...appDetail,
} as AppData
}
/>
)}
</div>
<FileUploadContext
value={{
localUploadUrl: `/trial-apps/${appId}/files/upload`,
remoteUploadUrl: `/trial-apps/${appId}/remote-files/upload`,
}}
>
<div className="flex size-full">
{isChat && <Chat appId={appId} appDetail={appDetail} className="h-full grow" />}
{isCompletion && (
<TextGeneration
appId={appId}
className="h-full grow"
isWorkflow={mode === 'workflow'}
appData={
{
app_id: appId,
custom_config: {},
...appDetail,
} as AppData
}
/>
)}
</div>
</FileUploadContext>
)
}
export default React.memo(TryApp)
export default memo(TryApp)

View File

@ -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 },
)