From a55ca37c39c743cbd54b337b1d97d6986352d9cf Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 10 Mar 2026 23:55:53 +0800 Subject: [PATCH] feat: enhance chat CLI and TUI with initial opener handling and improved prompt logic --- examples/chat_cli.py | 101 +++++++++++++++++++++--- examples/chat_tui.py | 32 +++++++- fastgpt_client/async_client.py | 12 +-- tests/test_async_client.py | 20 +++-- tests/test_chat_examples.py | 139 +++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 tests/test_chat_examples.py diff --git a/examples/chat_cli.py b/examples/chat_cli.py index 0074ce4..ce0f057 100644 --- a/examples/chat_cli.py +++ b/examples/chat_cli.py @@ -32,6 +32,7 @@ load_dotenv(Path(__file__).with_name(".env")) API_KEY = os.getenv("API_KEY") BASE_URL = os.getenv("BASE_URL") +APP_ID = os.getenv("APP_ID") for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): @@ -144,16 +145,85 @@ def _resolve_option_token(token: str, options: List[Dict[str, str]]) -> Optional return None +def _coerce_text(value: Any) -> str: + return str(value or "").strip() + + +def _first_nonempty_text(*values: Any) -> str: + for value in values: + text = _coerce_text(value) + if text: + return text + return "" + + +def _merge_prompt_parts(*values: Any) -> str: + parts: List[str] = [] + seen = set() + for value in values: + text = _coerce_text(value) + if not text or text in seen: + continue + seen.add(text) + parts.append(text) + return "\n".join(parts) + + def _interactive_prompt_text(payload: Dict[str, Any], default_text: str) -> str: params = payload.get("params") if isinstance(payload.get("params"), dict) else {} - return str( - payload.get("prompt") - or payload.get("title") - or payload.get("text") - or payload.get("description") - or params.get("description") - or default_text - ).strip() + opener = _first_nonempty_text( + payload.get("opener"), + params.get("opener"), + payload.get("intro"), + params.get("intro"), + ) + prompt = _first_nonempty_text( + payload.get("prompt"), + params.get("prompt"), + payload.get("text"), + params.get("text"), + ) + title = _first_nonempty_text( + payload.get("title"), + params.get("title"), + payload.get("nodeName"), + payload.get("label"), + ) + description = _first_nonempty_text( + payload.get("description"), + payload.get("desc"), + params.get("description"), + params.get("desc"), + ) + return _merge_prompt_parts(opener, prompt) or title or description or default_text + + +def _extract_chat_init_opener(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + + data = payload.get("data") if isinstance(payload.get("data"), dict) else payload + app = data.get("app") if isinstance(data.get("app"), dict) else {} + chat_config = app.get("chatConfig") if isinstance(app.get("chatConfig"), dict) else {} + + return _first_nonempty_text( + chat_config.get("welcomeText"), + app.get("welcomeText"), + data.get("welcomeText"), + data.get("opener"), + app.get("opener"), + data.get("intro"), + app.get("intro"), + ) + + +def _get_initial_app_opener(client: ChatClient, chat_id: str) -> str: + if not APP_ID: + return "" + + response = client.get_chat_init(appId=APP_ID, chatId=chat_id) + response.raise_for_status() + return _extract_chat_init_opener(response.json()) def _prompt_user_select(event: FastGPTInteractiveEvent) -> Optional[str]: @@ -165,7 +235,8 @@ def _prompt_user_select(event: FastGPTInteractiveEvent) -> Optional[str]: options = [item for index, raw in enumerate(raw_options, start=1) if (item := _normalize_option(raw, index))] print() - print(f"[INTERACTIVE] {prompt_text}") + print("[INTERACTIVE]") + print(prompt_text) for index, option in enumerate(options, start=1): print(f" {index}. {option['label']}") if option["description"]: @@ -212,7 +283,8 @@ def _prompt_user_input(event: FastGPTInteractiveEvent) -> Optional[str]: form_fields = params.get("inputForm") if isinstance(params.get("inputForm"), list) else [] print() - print(f"[INTERACTIVE] {prompt_text}") + print("[INTERACTIVE]") + print(prompt_text) if not form_fields: value = input("Input (/cancel to stop): ").strip() @@ -384,6 +456,15 @@ def main() -> None: print(f"Using chatId: {chat_id}\n") with ChatClient(api_key=API_KEY, base_url=BASE_URL) as client: + try: + opener = _get_initial_app_opener(client, chat_id) + except Exception as exc: + print(f"[INIT] Failed to load app opener: {exc}\n") + opener = "" + + if opener: + print(f"Assistant: {opener}\n") + while True: try: user_input = input("You: ").strip() diff --git a/examples/chat_tui.py b/examples/chat_tui.py index 6f2d9c2..6b9208a 100644 --- a/examples/chat_tui.py +++ b/examples/chat_tui.py @@ -32,8 +32,10 @@ if str(EXAMPLES_DIR) not in sys.path: sys.path.insert(0, str(EXAMPLES_DIR)) from chat_cli import ( + APP_ID, API_KEY, BASE_URL, + _extract_chat_init_opener, _extract_text_from_event, _interactive_prompt_text, _normalize_option, @@ -451,10 +453,11 @@ class FastGPTWorkbench(App[None]): raise RuntimeError("Set API_KEY and BASE_URL in examples/.env before starting chat_tui.py") self._refresh_sidebar() self._set_status("Ready", "Fresh session") + initial_message = self._initial_session_message() self._append_message( role="system", title="Session", - content="Start typing below. FastGPT workflow events will appear in the left rail.", + content=initial_message, ) self.query_one("#composer", TextArea).focus() @@ -464,8 +467,9 @@ class FastGPTWorkbench(App[None]): def _refresh_sidebar(self) -> None: session_panel = self.query_one("#session_panel", Static) base_url = BASE_URL or "" + app_id = APP_ID or "(not set)" session_panel.update( - f"Session\n\nchatId: {self.chat_id}\nbaseUrl: {base_url}" + f"Session\n\nchatId: {self.chat_id}\nappId: {app_id}\nbaseUrl: {base_url}" ) def _set_status(self, heading: str, detail: str) -> None: @@ -481,6 +485,24 @@ class FastGPTWorkbench(App[None]): except Exception: return content + def _default_session_message(self) -> str: + return "Start typing below. FastGPT workflow events will appear in the left rail." + + def _initial_session_message(self) -> str: + if not APP_ID: + return self._default_session_message() + + try: + with ChatClient(api_key=API_KEY, base_url=BASE_URL) as client: + response = client.get_chat_init(appId=APP_ID, chatId=self.chat_id) + response.raise_for_status() + opener = _extract_chat_init_opener(response.json()) + except Exception as exc: + self._log_event(f"[init] Failed to load app opener: {exc}") + return self._default_session_message() + + return opener or self._default_session_message() + def _append_message(self, role: str, title: str, content: str) -> str: self._message_counter += 1 widget_id = f"message-{self._message_counter}" @@ -546,7 +568,8 @@ class FastGPTWorkbench(App[None]): self._start_turn(result, title="Workflow Input", role="workflow") def _present_interactive(self, event: FastGPTInteractiveEvent) -> None: - self._log_event(f"[interactive] {event.interaction_type}") + prompt_summary = _interactive_prompt_text(event.data, event.interaction_type).replace("\n", " / ") + self._log_event(f"[interactive] {event.interaction_type}: {prompt_summary}") if event.interaction_type == "userInput": self.push_screen(InteractiveInputScreen(event), self._handle_interactive_result) return @@ -569,10 +592,11 @@ class FastGPTWorkbench(App[None]): self.query_one("#messages", VerticalScroll).remove_children() self._refresh_sidebar() self._set_status("Ready", "Started a new random session") + initial_message = self._initial_session_message() self._append_message( role="system", title="Session", - content="New chat created. Start typing below.", + content=initial_message, ) self._log_event(f"[local] Started new chatId {self.chat_id}") diff --git a/fastgpt_client/async_client.py b/fastgpt_client/async_client.py index 10c5b4b..0260d81 100644 --- a/fastgpt_client/async_client.py +++ b/fastgpt_client/async_client.py @@ -127,8 +127,9 @@ class AsyncFastGPTClient(BaseClientMixin): response._stream_context = stream_context response._stream_context_closed = False - # Override close() to also close the stream context - original_close = response.close + # Preserve the native async response closer and make both + # `await response.close()` and `await response.aclose()` safe. + original_aclose = response.aclose async def close_with_context(): """Close both the response and the stream context.""" @@ -136,10 +137,10 @@ class AsyncFastGPTClient(BaseClientMixin): return try: - # Close the response first - await original_close() + # Async streaming responses must use `aclose()`. + await original_aclose() finally: - # Always close the stream context, even if response.close() fails + # Always close the stream context, even if response cleanup fails if hasattr(response, '_stream_context') and response._stream_context is not None: try: await response._stream_context.__aexit__(None, None, None) @@ -150,6 +151,7 @@ class AsyncFastGPTClient(BaseClientMixin): response._stream_context_closed = True response.close = close_with_context + response.aclose = close_with_context # Safety net: ensure cleanup on garbage collection def cleanup_stream_context(stream_ctx_ref): diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 1aef88a..0ee71bb 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -162,13 +162,18 @@ class TestAsyncFastGPTClientSendRequest: mock_response = Mock(spec=httpx.Response) mock_response.status_code = 200 - # Track if close was called on the response - original_close_called = [] + original_sync_close_called = [] + original_async_close_called = [] - async def original_close(): - original_close_called.append(True) + def original_close(): + original_sync_close_called.append(True) + raise AssertionError("sync close should not be used for async streaming responses") - mock_response.close = original_close + async def original_aclose(): + original_async_close_called.append(True) + + mock_response.close = Mock(side_effect=original_close) + mock_response.aclose = AsyncMock(side_effect=original_aclose) mock_stream_context = AsyncContextManagerMock(mock_response) @@ -182,8 +187,9 @@ class TestAsyncFastGPTClientSendRequest: # Verify stream context exit was called mock_stream_context.__aexit__.assert_called_once_with(None, None, None) - # Verify the original close was called - assert len(original_close_called) == 1 + # Verify async cleanup path was used instead of sync close() + assert len(original_sync_close_called) == 0 + assert len(original_async_close_called) == 1 await client.close() @pytest.mark.asyncio diff --git a/tests/test_chat_examples.py b/tests/test_chat_examples.py new file mode 100644 index 0000000..4ccbcb6 --- /dev/null +++ b/tests/test_chat_examples.py @@ -0,0 +1,139 @@ +"""Regression tests for the interactive example helpers.""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHAT_CLI_PATH = REPO_ROOT / "examples" / "chat_cli.py" + + +def _load_chat_cli_module(): + module_name = "_test_chat_cli" + existing = sys.modules.get(module_name) + if existing is not None: + return existing + + dotenv_module = sys.modules.get("dotenv") + if dotenv_module is None: + dotenv_module = types.ModuleType("dotenv") + dotenv_module.load_dotenv = lambda *args, **kwargs: None + sys.modules["dotenv"] = dotenv_module + + original_fastgpt_client = sys.modules.get("fastgpt_client") + stub_fastgpt_client = types.ModuleType("fastgpt_client") + stub_fastgpt_client.ChatClient = object + stub_fastgpt_client.FastGPTInteractiveEvent = object + stub_fastgpt_client.iter_stream_events = lambda response: iter(()) + sys.modules["fastgpt_client"] = stub_fastgpt_client + + spec = importlib.util.spec_from_file_location(module_name, CHAT_CLI_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + finally: + if original_fastgpt_client is None: + sys.modules.pop("fastgpt_client", None) + else: + sys.modules["fastgpt_client"] = original_fastgpt_client + return module + + +def test_interactive_prompt_text_uses_opener_when_prompt_is_missing(): + chat_cli = _load_chat_cli_module() + + prompt_text = chat_cli._interactive_prompt_text( + { + "params": { + "opener": "Please tell me about your business.", + "inputForm": [{"label": "Business type"}], + } + }, + "Please provide the requested input", + ) + + assert prompt_text == "Please tell me about your business." + + +def test_interactive_prompt_text_keeps_opener_and_prompt(): + chat_cli = _load_chat_cli_module() + + prompt_text = chat_cli._interactive_prompt_text( + { + "opener": "A few details will help me tailor the answer.", + "prompt": "Which plan are you evaluating?", + }, + "Please select an option", + ) + + assert prompt_text == "A few details will help me tailor the answer.\nWhich plan are you evaluating?" + + +def test_extract_chat_init_opener_prefers_welcome_text(): + chat_cli = _load_chat_cli_module() + + opener = chat_cli._extract_chat_init_opener( + { + "data": { + "app": { + "chatConfig": {"welcomeText": "Welcome from chat config."}, + "intro": "Fallback intro.", + } + } + } + ) + + assert opener == "Welcome from chat config." + + +def test_extract_chat_init_opener_falls_back_to_intro(): + chat_cli = _load_chat_cli_module() + + opener = chat_cli._extract_chat_init_opener( + { + "data": { + "app": { + "intro": "Tell me what you're working on.", + } + } + } + ) + + assert opener == "Tell me what you're working on." + + +def test_get_initial_app_opener_uses_chat_init(): + chat_cli = _load_chat_cli_module() + + original_app_id = chat_cli.APP_ID + chat_cli.APP_ID = "app-123" + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"data": {"app": {"chatConfig": {"welcomeText": "Hello from init."}}}} + + class _Client: + def __init__(self): + self.calls = [] + + def get_chat_init(self, **kwargs): + self.calls.append(kwargs) + return _Response() + + client = _Client() + try: + opener = chat_cli._get_initial_app_opener(client, "chat-123") + finally: + chat_cli.APP_ID = original_app_id + + assert opener == "Hello from init." + assert client.calls == [{"appId": "app-123", "chatId": "chat-123"}]