Add chat image upload support

This commit is contained in:
Xin Wang
2026-06-01 13:39:22 +08:00
parent a55ca37c39
commit 96d685da91
7 changed files with 951 additions and 4 deletions

View File

@@ -2,6 +2,8 @@
import asyncio
import logging
import mimetypes
from pathlib import Path
import weakref
from typing import Any, Dict, Literal, Union
@@ -351,6 +353,245 @@ class AsyncChatClient(AsyncFastGPTClient):
stream=stream,
)
async def _send_first_available_request(
self,
method: str,
endpoints: list[str],
json: Dict[str, Any] | None = None,
params: Dict[str, Any] | None = None,
):
"""Try compatible FastGPT endpoint variants and return the first non-404 response."""
last_error = None
for endpoint in endpoints:
try:
return await self._send_request(method, endpoint, json=json, params=params)
except APIError as exc:
last_error = exc
if exc.status_code != 404:
raise
if last_error is not None:
raise last_error
raise APIError("No endpoint candidates provided")
@staticmethod
def _unwrap_response_data(data: Any) -> Any:
"""Unwrap FastGPT's standard envelope while preserving raw OpenAI-style responses."""
if isinstance(data, dict) and "data" in data and {"code", "message", "statusText"} & set(data):
return data.get("data")
return data
async def _get_chat_file_select_config(self, appId: str, chatId: str) -> dict[str, Any] | None:
"""Best-effort lookup for the app's chat file selection config."""
try:
response = await self.get_chat_init(appId=appId, chatId=chatId)
data = self._unwrap_response_data(response.json())
except Exception:
return None
if not isinstance(data, dict):
return None
app = data.get("app")
if not isinstance(app, dict):
return None
chat_config = app.get("chatConfig")
if not isinstance(chat_config, dict):
return None
file_select_config = chat_config.get("fileSelectConfig")
return file_select_config if isinstance(file_select_config, dict) else None
@staticmethod
def _default_file_select_config(file_type: str) -> dict[str, Any]:
"""Fallback config for servers that require fileSelectConfig in presign requests."""
is_image = file_type == "img"
return {
"canSelectFile": not is_image,
"canSelectImg": is_image,
"canSelectVideo": False,
"canSelectAudio": False,
"canSelectCustomFileExtension": False,
"customFileExtensionList": [],
}
async def presign_chat_file_post_url(
self,
*,
appId: str,
chatId: str,
filename: str,
fileSelectConfig: dict[str, Any] | None = None,
outLinkAuthData: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Request a presigned URL for uploading a chat file."""
payload: dict[str, Any] = {
"appId": appId,
"chatId": chatId,
"filename": filename,
"fileSelectConfig": fileSelectConfig or self._default_file_select_config("img"),
}
if outLinkAuthData:
payload["outLinkAuthData"] = outLinkAuthData
response = await self._send_first_available_request(
"POST",
[
"/api/core/chat/presignChatFilePostUrl",
"/api/core/chat/file/presignChatFilePostUrl",
],
json=payload,
)
data = self._unwrap_response_data(response.json())
if not isinstance(data, dict):
raise APIError("Invalid presigned upload response")
return data
async def presign_chat_file_get_url(
self,
*,
appId: str,
key: str,
outLinkAuthData: dict[str, Any] | None = None,
) -> str:
"""Request a temporary preview/download URL for an uploaded chat file."""
payload: dict[str, Any] = {
"appId": appId,
"key": key,
}
if outLinkAuthData:
payload["outLinkAuthData"] = outLinkAuthData
response = await self._send_first_available_request(
"POST",
[
"/api/core/chat/presignChatFileGetUrl",
"/api/core/chat/file/presignChatFileGetUrl",
],
json=payload,
)
data = self._unwrap_response_data(response.json())
if not isinstance(data, str):
raise APIError("Invalid presigned preview response")
return data
async def upload_to_presigned_url(
self,
*,
upload_url: str,
file_path: str | Path,
headers: dict[str, str] | None = None,
fields: dict[str, Any] | None = None,
method: Literal["POST", "PUT"] | None = None,
) -> httpx.Response:
"""Upload a file to a presigned S3/MinIO URL."""
path = Path(file_path)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
upload_method = method or ("POST" if fields else "PUT")
async with httpx.AsyncClient(timeout=self.timeout) as client:
if upload_method == "POST":
with path.open("rb") as file_obj:
response = await client.post(
upload_url,
data=fields or {},
files={"file": (path.name, file_obj, content_type)},
)
elif upload_method == "PUT":
upload_headers = dict(headers or {})
upload_headers.setdefault("Content-Type", content_type)
with path.open("rb") as file_obj:
response = await client.put(
upload_url,
content=file_obj,
headers=upload_headers,
)
else:
raise ValueError("method must be 'POST' or 'PUT'")
response.raise_for_status()
return response
async def upload_chat_file(
self,
*,
appId: str,
chatId: str,
file_path: str | Path,
file_type: Literal["img", "doc"] = "doc",
fileSelectConfig: dict[str, Any] | None = None,
outLinkAuthData: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Upload a chat file and return a normalized file reference."""
path = Path(file_path)
selected_config = (
fileSelectConfig
or await self._get_chat_file_select_config(appId=appId, chatId=chatId)
or self._default_file_select_config(file_type)
)
presigned = await self.presign_chat_file_post_url(
appId=appId,
chatId=chatId,
filename=path.name,
fileSelectConfig=selected_config,
outLinkAuthData=outLinkAuthData,
)
max_size = presigned.get("maxSize")
if max_size is not None and path.stat().st_size > max_size:
raise ValueError(f"File too large: {path.stat().st_size} bytes > {max_size} bytes")
await self.upload_to_presigned_url(
upload_url=presigned["url"],
file_path=path,
headers=presigned.get("headers"),
fields=presigned.get("fields"),
)
fields = presigned.get("fields") if isinstance(presigned.get("fields"), dict) else {}
key = presigned.get("key") or fields.get("key")
preview_url = presigned.get("previewUrl")
if not preview_url and key:
preview_url = await self.presign_chat_file_get_url(
appId=appId,
key=key,
outLinkAuthData=outLinkAuthData,
)
return {
"type": file_type,
"name": path.name,
"key": key,
"url": preview_url,
"previewUrl": preview_url,
}
async def upload_chat_image(
self,
*,
appId: str,
chatId: str,
file_path: str | Path,
fileSelectConfig: dict[str, Any] | None = None,
outLinkAuthData: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Upload an image for use in chat `image_url` content parts."""
return await self.upload_chat_file(
appId=appId,
chatId=chatId,
file_path=file_path,
file_type="img",
fileSelectConfig=fileSelectConfig,
outLinkAuthData=outLinkAuthData,
)
async def get_chat_histories(
self,
appId: str,