diff --git a/README.md b/README.md index f0e2e62..afb27e4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,39 @@ with ChatClient(api_key="fastgpt-xxxxx") as client: ) ``` +### Chat with an Image + +Upload the image to FastGPT's chat file storage first, then reference the returned URL in a chat message. + +```python +from fastgpt_client import ChatClient + +app_id = "your-app-id" +chat_id = "my_conversation_123" + +with ChatClient(api_key="fastgpt-xxxxx", base_url="http://localhost:3000") as client: + image = client.upload_chat_image( + appId=app_id, + chatId=chat_id, + file_path="example.png", + ) + + response = client.create_chat_completion( + chatId=chat_id, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Please describe this image."}, + {"type": "image_url", "image_url": {"url": image["url"]}}, + ], + } + ], + ) + + print(response.json()["choices"][0]["message"]["content"]) +``` + ### Using Variables ```python diff --git a/docs/api/chat_client.md b/docs/api/chat_client.md index 9c381d2..7a23e96 100644 --- a/docs/api/chat_client.md +++ b/docs/api/chat_client.md @@ -58,6 +58,82 @@ print(result['choices'][0]['message']['content']) --- +### upload_chat_image + +Upload an image to FastGPT's chat file storage and return a normalized file reference. + +```python +image = client.upload_chat_image( + appId="app-123", + chatId="chat-123", + file_path="example.png" +) + +response = client.create_chat_completion( + chatId="chat-123", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + {"type": "image_url", "image_url": {"url": image["url"]}}, + ], + } + ], +) +``` + +Returned file reference: + +```python +{ + "type": "img", + "name": "example.png", + "key": "chat/...", + "url": "https://...", + "previewUrl": "https://..." +} +``` + +--- + +### upload_chat_file + +Upload a generic chat file. This is the lower-level helper used by `upload_chat_image`. + +```python +file_ref = client.upload_chat_file( + appId="app-123", + chatId="chat-123", + file_path="document.pdf", + file_type="doc" +) +``` + +--- + +### presign_chat_file_post_url + +Request a presigned upload URL for a chat file. The SDK supports both FastGPT response shapes: + +- Presigned POST: `{ "url": "...", "fields": {...}, "maxSize": ... }` +- Presigned PUT: `{ "url": "...", "headers": {...}, "key": "...", "previewUrl": "..." }` + +--- + +### presign_chat_file_get_url + +Request a temporary preview/download URL by file `key`. + +```python +preview_url = client.presign_chat_file_get_url( + appId="app-123", + key="chat/..." +) +``` + +--- + ### get_chat_histories Get chat histories for an application. diff --git a/examples/chat_tui.py b/examples/chat_tui.py index 6b9208a..4c1c669 100644 --- a/examples/chat_tui.py +++ b/examples/chat_tui.py @@ -7,6 +7,7 @@ Run from the examples directory with .env configured: This example provides: - a full-screen Textual interface - streaming chat updates +- image turns with /image [prompt] - workflow / tool event logging - modal handling for FastGPT interactive nodes """ @@ -15,6 +16,7 @@ from __future__ import annotations import argparse import json +import shlex import sys import uuid from pathlib import Path @@ -44,6 +46,42 @@ from chat_cli import ( from fastgpt_client import ChatClient, FastGPTInteractiveEvent, iter_stream_events +DEFAULT_IMAGE_PROMPT = "Please describe this image." +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} + + +def parse_image_command(content: str) -> Optional[tuple[Path, str]]: + """Parse `/image [prompt]`, including terminal-dragged quoted paths.""" + stripped = content.strip() + if not stripped.startswith("/image"): + return None + + command, _, rest = stripped.partition(" ") + if command != "/image": + return None + if not rest.strip(): + raise ValueError("Usage: /image [prompt]") + + try: + parts = shlex.split(rest) + except ValueError as exc: + raise ValueError(f"Could not parse image command: {exc}") from exc + + if not parts: + raise ValueError("Usage: /image [prompt]") + + path = Path(parts[0]).expanduser() + if not path.exists(): + raise ValueError(f"Image file not found: {path}") + if not path.is_file(): + raise ValueError(f"Image path is not a file: {path}") + if path.suffix.lower() not in IMAGE_EXTENSIONS: + raise ValueError(f"Unsupported image extension: {path.suffix or '(none)'}") + + prompt = " ".join(parts[1:]).strip() or DEFAULT_IMAGE_PROMPT + return path, prompt + + class MessageCard(Static): """Lightweight message block used in the transcript pane.""" @@ -435,7 +473,7 @@ class FastGPTWorkbench(App[None]): yield Static("FastGPT Workbench", id="brand") yield Static("", id="session_panel", classes="panel") yield Static("", id="status_panel", classes="panel") - yield Static("Ctrl+J send\nCtrl+N new chat\nEsc closes modal prompts", classes="panel") + yield Static("Ctrl+J send\nCtrl+N new chat\n/image [prompt]", classes="panel") yield RichLog(id="event_log", wrap=True, highlight=False, markup=False) with Vertical(id="main_panel"): yield Static("Claude-style FastGPT Console", id="chat_title") @@ -486,7 +524,7 @@ class FastGPTWorkbench(App[None]): return content def _default_session_message(self) -> str: - return "Start typing below. FastGPT workflow events will appear in the left rail." + return "Start typing below. Use /image [prompt] to attach a local image." def _initial_session_message(self) -> str: if not APP_ID: @@ -517,7 +555,14 @@ class FastGPTWorkbench(App[None]): self.query_one(f"#{card_id}", MessageCard).set_text("Thinking…") return card_id - def _start_turn(self, content: str, *, title: str = "You", role: str = "user") -> None: + def _start_turn( + self, + content: str, + *, + title: str = "You", + role: str = "user", + messages: Optional[List[Dict[str, Any]]] = None, + ) -> None: if self._busy: self._log_event("[local] Busy streaming. Wait for the current turn to finish.") return @@ -528,7 +573,27 @@ class FastGPTWorkbench(App[None]): self._busy = True self._set_status("Streaming", "Receiving FastGPT output") self._stream_turn( - messages=[{"role": "user", "content": content}], + messages=messages or [{"role": "user", "content": content}], + assistant_card_id=assistant_card_id, + ) + + def _start_image_turn(self, image_path: Path, prompt: str) -> None: + if self._busy: + self._log_event("[local] Busy streaming. Wait for the current turn to finish.") + return + if not APP_ID: + self._log_event("[local] APP_ID is required for image upload.") + self._set_status("Error", "APP_ID is required for image upload") + return + + display_content = f"{prompt}\n\n[image] {image_path}" + self._append_message(role="user", title="You", content=display_content) + assistant_card_id = self._assistant_card() + self._busy = True + self._set_status("Uploading", image_path.name) + self._stream_image_turn( + image_path=image_path, + prompt=prompt, assistant_card_id=assistant_card_id, ) @@ -580,8 +645,19 @@ class FastGPTWorkbench(App[None]): content = composer.text.strip() if not content: return + try: + image_command = parse_image_command(content) + except ValueError as exc: + self._log_event(f"[local] {exc}") + self._set_status("Error", str(exc)) + return + composer.text = "" composer.focus() + if image_command is not None: + image_path, prompt = image_command + self._start_image_turn(image_path, prompt) + return self._start_turn(content) def action_new_chat(self) -> None: @@ -687,6 +763,104 @@ class FastGPTWorkbench(App[None]): waiting_interactive=interactive_event is not None, ) + @work(thread=True, exclusive=True) + def _stream_image_turn(self, image_path: Path, prompt: str, assistant_card_id: str) -> None: + try: + with ChatClient(api_key=API_KEY, base_url=BASE_URL) as client: + self.call_from_thread(self._log_event, f"[image] uploading: {image_path}") + image = client.upload_chat_image( + appId=APP_ID, + chatId=self.chat_id, + file_path=image_path, + ) + image_url = image.get("url") + if not image_url: + raise RuntimeError("FastGPT did not return an image preview URL") + + self.call_from_thread(self._log_event, f"[image] uploaded: {image_path.name}") + self.call_from_thread(self._set_status, "Streaming", "Receiving FastGPT output") + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ] + response = client.create_chat_completion( + messages=messages, + stream=True, + detail=True, + chatId=self.chat_id, + ) + response.raise_for_status() + try: + for event in iter_stream_events(response): + if event.kind in {"data", "answer", "fastAnswer"}: + content = _extract_text_from_event(event.kind, event.data) + if content: + self.call_from_thread(self._append_assistant_chunk, assistant_card_id, content) + continue + + if event.kind == "flowNodeStatus": + if isinstance(event.data, dict): + status = str(event.data.get("status") or "?") + node_name = str(event.data.get("nodeName") or event.data.get("name") or event.data.get("node_id") or "Unknown node") + self.call_from_thread(self._log_event, f"[flow] {status}: {node_name}") + else: + self.call_from_thread(self._log_event, f"[flow] {event.data}") + continue + + if event.kind == "flowResponses": + if isinstance(event.data, dict): + module_name = str(event.data.get("moduleName") or event.data.get("nodeName") or "Unknown module") + self.call_from_thread(self._log_event, f"[flow] response from: {module_name}") + elif isinstance(event.data, list): + self.call_from_thread(self._log_event, f"[flow] response details: {len(event.data)} module record(s)") + else: + self.call_from_thread(self._log_event, f"[flow] response details: {event.data}") + continue + + if event.kind == "toolCall": + tool_name = _tool_name_from_event(event.data) + self.call_from_thread(self._log_event, f"[tool] calling: {tool_name}") + continue + + if event.kind == "toolParams": + self.call_from_thread(self._log_event, f"[tool] params: {event.data}") + continue + + if event.kind == "toolResponse": + self.call_from_thread(self._log_event, f"[tool] response: {event.data}") + continue + + if event.kind == "updateVariables": + self.call_from_thread(self._log_event, f"[vars] updated: {event.data}") + continue + + if event.kind == "interactive": + self.call_from_thread( + self._log_event, + "[interactive] Image turns do not support workflow prompts yet.", + ) + break + + if event.kind == "error": + message = str(event.data.get("message") or event.data.get("error") or "Unknown FastGPT error") + raise RuntimeError(message) + + if event.kind == "done": + break + finally: + response.close() + except Exception as exc: + self.call_from_thread(self._mark_turn_failed, assistant_card_id, str(exc)) + return + + self.call_from_thread(self._complete_turn, assistant_card_id, waiting_interactive=False) + def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Textual FastGPT chat workbench") diff --git a/fastgpt_client/async_client.py b/fastgpt_client/async_client.py index 0260d81..353b1da 100644 --- a/fastgpt_client/async_client.py +++ b/fastgpt_client/async_client.py @@ -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, diff --git a/fastgpt_client/client.py b/fastgpt_client/client.py index 1c35ef6..3ffc2c3 100644 --- a/fastgpt_client/client.py +++ b/fastgpt_client/client.py @@ -1,6 +1,8 @@ """FastGPT Client - Main synchronous client.""" import logging +import mimetypes +from pathlib import Path import weakref from typing import Any, Dict, Literal, Union @@ -295,6 +297,255 @@ class ChatClient(FastGPTClient): stream=stream, ) + 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 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 + + 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 = 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": [], + } + + 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. + + FastGPT versions differ on the endpoint path and response shape. This + method supports both the current local route and the newer `/file/` + route, and returns the unwrapped presign payload. + """ + 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 = 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 + + 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 = 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 + + 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. + + FastGPT may return either presigned POST fields or PUT headers. This + method detects both formats and deliberately avoids FastGPT auth headers. + """ + 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") + + if upload_method == "POST": + with path.open("rb") as file_obj: + response = httpx.post( + upload_url, + data=fields or {}, + files={"file": (path.name, file_obj, content_type)}, + timeout=self.timeout, + ) + elif upload_method == "PUT": + upload_headers = dict(headers or {}) + upload_headers.setdefault("Content-Type", content_type) + with path.open("rb") as file_obj: + response = httpx.put( + upload_url, + content=file_obj, + headers=upload_headers, + timeout=self.timeout, + ) + else: + raise ValueError("method must be 'POST' or 'PUT'") + + response.raise_for_status() + return response + + 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 self._get_chat_file_select_config(appId=appId, chatId=chatId) + or self._default_file_select_config(file_type) + ) + + presigned = 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") + + 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 = 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, + } + + 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 self.upload_chat_file( + appId=appId, + chatId=chatId, + file_path=file_path, + file_type="img", + fileSelectConfig=fileSelectConfig, + outLinkAuthData=outLinkAuthData, + ) + def get_chat_histories( self, appId: str, diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 0ee71bb..e874eb2 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -358,6 +358,64 @@ class TestAsyncChatClient: assert response.status_code == 200 await client.close() + @pytest.mark.asyncio + async def test_presign_chat_file_post_url(self, api_key): + """Test async presigned chat file upload URL creation.""" + client = AsyncChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value={ + "code": 200, + "message": "", + "data": {"url": "https://s3.example/upload", "fields": {"key": "chat/file.png"}}, + }) + + with patch.object(client, "_send_request", AsyncMock(return_value=mock_response)) as mock_send: + result = await client.presign_chat_file_post_url( + appId="app-123", + chatId="chat-123", + filename="file.png", + fileSelectConfig={"canSelectImg": True}, + ) + + assert result["fields"]["key"] == "chat/file.png" + assert mock_send.call_args[0][1] == "/api/core/chat/presignChatFilePostUrl" + await client.close() + + @pytest.mark.asyncio + async def test_upload_chat_image_with_fields_gets_preview_url(self, api_key, tmp_path): + """Test async upload_chat_image normalizes presigned POST uploads.""" + client = AsyncChatClient(api_key) + file_path = tmp_path / "file.png" + file_path.write_bytes(b"png") + + with patch.object(client, "_get_chat_file_select_config", AsyncMock(return_value={"canSelectImg": True})), \ + patch.object(client, "presign_chat_file_post_url", AsyncMock(return_value={ + "url": "https://s3.example/upload", + "fields": {"key": "chat/file.png"}, + "maxSize": 100, + })) as mock_presign, \ + patch.object(client, "upload_to_presigned_url", AsyncMock()) as mock_upload, \ + patch.object(client, "presign_chat_file_get_url", AsyncMock(return_value="https://s3.example/preview.png")) as mock_get: + result = await client.upload_chat_image( + appId="app-123", + chatId="chat-123", + file_path=file_path, + ) + + assert result == { + "type": "img", + "name": "file.png", + "key": "chat/file.png", + "url": "https://s3.example/preview.png", + "previewUrl": "https://s3.example/preview.png", + } + assert mock_presign.call_args[1]["fileSelectConfig"] == {"canSelectImg": True} + mock_upload.assert_awaited_once() + mock_get.assert_awaited_once_with(appId="app-123", key="chat/file.png", outLinkAuthData=None) + await client.close() + @pytest.mark.asyncio async def test_get_chat_histories_basic(self, api_key, sample_chat_histories_response): """Test getting chat histories with basic parameters.""" diff --git a/tests/test_chat_client.py b/tests/test_chat_client.py index b8693e7..5806f75 100644 --- a/tests/test_chat_client.py +++ b/tests/test_chat_client.py @@ -162,6 +162,120 @@ class TestChatClientCreateChatCompletion: mock_send.assert_called_once() +class TestChatClientFileUpload: + """Test suite for ChatClient chat file upload helpers.""" + + def test_presign_chat_file_post_url_uses_local_path(self, api_key): + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value={ + "code": 200, + "message": "", + "data": {"url": "https://s3.example/upload", "fields": {"key": "chat/file.png"}}, + }) + + with patch.object(client, "_send_request", return_value=mock_response) as mock_send: + result = client.presign_chat_file_post_url( + appId="app-123", + chatId="chat-123", + filename="file.png", + fileSelectConfig={"canSelectImg": True}, + ) + + assert result["fields"]["key"] == "chat/file.png" + assert mock_send.call_args[0][1] == "/api/core/chat/presignChatFilePostUrl" + assert mock_send.call_args[1]["json"]["fileSelectConfig"] == {"canSelectImg": True} + + def test_presign_chat_file_get_url_unwraps_string(self, api_key): + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value={ + "code": 200, + "message": "", + "data": "https://s3.example/preview.png", + }) + + with patch.object(client, "_send_request", return_value=mock_response) as mock_send: + result = client.presign_chat_file_get_url(appId="app-123", key="chat/file.png") + + assert result == "https://s3.example/preview.png" + assert mock_send.call_args[0][1] == "/api/core/chat/presignChatFileGetUrl" + + def test_upload_to_presigned_url_with_post_fields(self, api_key, tmp_path): + client = ChatClient(api_key) + file_path = tmp_path / "file.png" + file_path.write_bytes(b"png") + + mock_response = Mock(spec=httpx.Response) + mock_response.raise_for_status = Mock() + + with patch("fastgpt_client.client.httpx.post", return_value=mock_response) as mock_post: + response = client.upload_to_presigned_url( + upload_url="https://s3.example/upload", + file_path=file_path, + fields={"key": "chat/file.png", "policy": "abc"}, + ) + + assert response is mock_response + mock_post.assert_called_once() + assert mock_post.call_args[1]["data"] == {"key": "chat/file.png", "policy": "abc"} + assert "file" in mock_post.call_args[1]["files"] + + def test_upload_to_presigned_url_with_put_headers(self, api_key, tmp_path): + client = ChatClient(api_key) + file_path = tmp_path / "file.png" + file_path.write_bytes(b"png") + + mock_response = Mock(spec=httpx.Response) + mock_response.raise_for_status = Mock() + + with patch("fastgpt_client.client.httpx.put", return_value=mock_response) as mock_put: + response = client.upload_to_presigned_url( + upload_url="https://s3.example/upload", + file_path=file_path, + headers={"x-amz-meta-test": "1"}, + ) + + assert response is mock_response + mock_put.assert_called_once() + assert mock_put.call_args[1]["headers"]["x-amz-meta-test"] == "1" + assert mock_put.call_args[1]["headers"]["Content-Type"] == "image/png" + + def test_upload_chat_image_with_fields_gets_preview_url(self, api_key, tmp_path): + client = ChatClient(api_key) + file_path = tmp_path / "file.png" + file_path.write_bytes(b"png") + + with patch.object(client, "_get_chat_file_select_config", return_value={"canSelectImg": True}), \ + patch.object(client, "presign_chat_file_post_url", return_value={ + "url": "https://s3.example/upload", + "fields": {"key": "chat/file.png"}, + "maxSize": 100, + }) as mock_presign, \ + patch.object(client, "upload_to_presigned_url") as mock_upload, \ + patch.object(client, "presign_chat_file_get_url", return_value="https://s3.example/preview.png") as mock_get: + result = client.upload_chat_image( + appId="app-123", + chatId="chat-123", + file_path=file_path, + ) + + assert result == { + "type": "img", + "name": "file.png", + "key": "chat/file.png", + "url": "https://s3.example/preview.png", + "previewUrl": "https://s3.example/preview.png", + } + assert mock_presign.call_args[1]["fileSelectConfig"] == {"canSelectImg": True} + mock_upload.assert_called_once() + mock_get.assert_called_once_with(appId="app-123", key="chat/file.png", outLinkAuthData=None) + + class TestChatClientGetChatHistories: """Test suite for ChatClient.get_chat_histories method."""