diff --git a/interactions_nodes.py b/interactions_nodes.py index 4887262..1cb1ac0 100644 --- a/interactions_nodes.py +++ b/interactions_nodes.py @@ -347,8 +347,8 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): IO.Int.Input("max_retries", default=3, min=0, max=10, step=1, tooltip="Maximum number of retry attempts when pattern matches. 0 disables retry."), IO.String.Input("timeout_fallback_text", default="", optional=True, multiline=True, tooltip="Text returned when the Gemini request times out. Leave empty to return the standard timeout message."), IO.Combo.Input("image_size", options=["None", "512", "1K", "2K", "4K"], default="None", optional=True, tooltip="Generated image resolution. Gemini 3.1 Flash Lite Image supports only 1K; 512 is supported only by Gemini 3.1 Flash Image."), - IO.Boolean.Input("store", default=True, optional=True, tooltip="Store interaction on server for multi-turn conversations. When enabled with an empty previous_interaction_id, automatically continues the last active interaction."), - IO.String.Input("previous_interaction_id", default="", optional=True, multiline=False, tooltip="Optional ID of previous interaction to continue multi-turn conversation. If left empty when store is enabled, automatically uses the last active interaction ID."), + IO.Combo.Input("store", options=["new", "previous", "manual", "disabled"], default="new", optional=True, tooltip="Session mode: 'new' starts a fresh interaction and stores it; 'previous' continues the last stored interaction; 'manual' continues from previous_interaction_id; 'disabled' does not store or continue."), + IO.String.Input("previous_interaction_id", default="", optional=True, multiline=False, tooltip="ID of previous interaction to continue when store is set to 'manual'."), cls.GeminiVideoConfig.Input("video", optional=True, tooltip="Optional configured Gemini video input with embedded audio and sampling FPS."), cls.ResponseSchema.Input("response_schema", display_name="Response format", optional=True, tooltip="Connect an answer format to request the fields you defined. Leave disconnected for a normal answer."), @@ -617,6 +617,17 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): for cat in cls.CORE_HARM_CATEGORIES ] + STORE_MODES = ("new", "previous", "manual", "disabled") + + @classmethod + def _normalize_store_mode(cls, store: Any) -> str: + if store is None: + return "new" + mode = str(store).strip().lower() + if mode not in cls.STORE_MODES: + raise ValueError(f"store must be one of: {', '.join(cls.STORE_MODES)}.") + return mode + @classmethod def _compute_fingerprint_and_check_cache(cls, config, prompt, system_instruction, model, max_output_tokens, include_images, aspect_ratio, bypass_mode, use_seed, seed, @@ -626,7 +637,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): include_thoughts=False, thinking_level=None, media_resolution=None, retry_pattern="", max_retries=3, use_cache=False, cache_ttl_minutes=60, cache_seed=0, image_size="None", - store=True, previous_interaction_id="", safety_settings=None, + store="new", previous_interaction_id="", safety_settings=None, schema_identity=None, precomputed_image_hashes=None): def get_tensor_hash(tensor): @@ -702,7 +713,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): str(media_resolution), str(retry_pattern), int(max_retries), - bool(store), + str(store), str(previous_interaction_id), str(safety_settings), schema_identity, @@ -806,12 +817,13 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): use_proxy=False, proxy_host="127.0.0.1", proxy_port=7890, use_seed=False, seed=0, timeout=30, include_thoughts=False, thinking_level=None, media_resolution=None, retry_pattern="", max_retries=3, timeout_fallback_text="", - image_size="None", store=True, previous_interaction_id="", + image_size="None", store="new", previous_interaction_id="", video: GeminiVideoConfig.Type | None = None, image_inputs: IO.Autogrow.Type | None = None, response_schema: ResponseSchema.Type | None = None, prepared_images: dict[str, Any] | None = None, - precomputed_video: Tuple[bytes | None, str | None, str | None, int | None] | None = None) -> IO.NodeOutput: + precomputed_video: Tuple[bytes | None, str | None, str | None, int | None] | None = None, + chained_previous_id: str = "") -> IO.NodeOutput: cls._check_interrupt() print(f"[INFO] SSL_GeminiInteractionsTextPrompt execute called, model: {model}") @@ -892,6 +904,21 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): return IO.NodeOutput(f"Error processing input video: {e}", cls.generate_empty_image(), output_seed, "", "", "") video_hash = hashlib.sha256(video_bytes).hexdigest() if video_bytes is not None else None + store_mode = cls._normalize_store_mode(store) + store_enabled = store_mode != "disabled" + if store_mode == "new": + effective_previous_id = str(chained_previous_id or "").strip() + elif store_mode == "previous": + effective_previous_id = str(chained_previous_id or "").strip() + if not effective_previous_id: + effective_previous_id = GeminiInteractionsStoreManager.get_last_interaction_id() + if effective_previous_id: + print(f"[INFO] Automatically continuing last interaction from store: {effective_previous_id}") + elif store_mode == "manual": + effective_previous_id = str(previous_interaction_id or "").strip() or str(chained_previous_id or "").strip() + else: + effective_previous_id = "" + resolved_safety_settings = cls._resolve_safety_settings(config) precomputed_image_hashes = prepared_images["image_hashes"] if prepared_images is not None else None fingerprint, cached = cls._compute_fingerprint_and_check_cache( @@ -903,8 +930,8 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): include_thoughts, thinking_level, media_resolution, retry_pattern, max_retries, use_cache=use_cache, cache_ttl_minutes=cache_ttl_minutes, cache_seed=cache_seed, - image_size=image_size, store=store, - previous_interaction_id=previous_interaction_id, + image_size=image_size, store=store_mode, + previous_interaction_id=effective_previous_id, safety_settings=resolved_safety_settings, schema_identity=schema_identity, precomputed_image_hashes=precomputed_image_hashes, @@ -914,13 +941,6 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): print(f"[INFO] Returning cached result for fingerprint {fingerprint}") return IO.NodeOutput(*cached) - # Resolve effective previous interaction ID from store if not provided directly - effective_previous_id = str(previous_interaction_id or "").strip() - if store and not effective_previous_id: - effective_previous_id = GeminiInteractionsStoreManager.get_last_interaction_id() - if effective_previous_id: - print(f"[INFO] Automatically continuing last interaction from store: {effective_previous_id}") - print(f"[INFO] Starting interaction, model: {model}") padded_prompt = prompt @@ -1199,8 +1219,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): kwargs["generation_config"] = call_gen_config if response_formats: kwargs["response_format"] = response_formats - if store is not None: - kwargs["store"] = bool(store) + kwargs["store"] = store_enabled if target_previous_id and str(target_previous_id).strip(): kwargs["previous_interaction_id"] = str(target_previous_id).strip() @@ -1418,7 +1437,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): interaction_id_output = "" # Record interaction in local store on success - if is_success and store and interaction_id_output: + if is_success and store_enabled and interaction_id_output: GeminiInteractionsStoreManager.record_interaction(interaction_id_output, model) # Cache the result if seed used and successful @@ -1440,7 +1459,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): use_proxy=False, proxy_host="127.0.0.1", proxy_port=7890, use_seed=False, seed=0, timeout=30, include_thoughts=False, thinking_level=None, media_resolution=None, retry_pattern="", max_retries=3, timeout_fallback_text="", - image_size="None", store=True, previous_interaction_id="", + image_size="None", store="new", previous_interaction_id="", video: GeminiVideoConfig.Type | None = None, image_inputs: IO.Autogrow.Type | None = None, response_schema: ResponseSchema.Type | None = None) -> IO.NodeOutput: @@ -1512,8 +1531,9 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): max_retries_val = int(_unwrap_scalar(max_retries, 3)) timeout_fb_val = str(_unwrap_scalar(timeout_fallback_text, "")) img_size_val = str(_unwrap_scalar(image_size, "None")) - store_val = bool(_unwrap_scalar(store, True)) + store_val = cls._normalize_store_mode(_unwrap_scalar(store, "new")) resp_schema_val = _unwrap_scalar(response_schema, None) + is_prev_id_list = _is_list_arg(previous_interaction_id) and len(previous_interaction_id) > 1 def make_item_kwargs(idx, chained_prev_id="", prepared_imgs=None, precomp_vid=None): p = prompt_list[min(idx, len(prompt_list) - 1)] if prompt_list else "" @@ -1530,9 +1550,13 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): s = int(_unwrap_scalar(seed, 0)) sys_inst = _get_aligned(system_instruction, idx, "You are a helpful AI assistant.") - prev_id = str(_get_aligned(previous_interaction_id, idx, "") or "").strip() - if not prev_id and store_val and chained_prev_id: - prev_id = chained_prev_id + if store_val == "manual": + if is_prev_id_list or idx == 0: + prev_id = str(_get_aligned(previous_interaction_id, idx, "") or "").strip() + else: + prev_id = "" + else: + prev_id = "" return { "config": config_val, @@ -1567,6 +1591,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): "response_schema": resp_schema_val, "prepared_images": prepared_imgs, "precomputed_video": precomp_vid, + "chained_previous_id": chained_prev_id, } if batch_count == 1: @@ -1615,7 +1640,7 @@ class SSL_GeminiInteractionsTextPrompt(IO.ComfyNode): ) ) batch_results.append(item_res) - if store_val and item_res[5]: + if store_val != "disabled" and item_res[5]: chained_interaction_id = str(item_res[5]).strip() return IO.NodeOutput( diff --git a/tests/test_interactions_nodes.py b/tests/test_interactions_nodes.py index c1849b1..b931cde 100644 --- a/tests/test_interactions_nodes.py +++ b/tests/test_interactions_nodes.py @@ -125,7 +125,7 @@ def _execute_kwargs(config=None, **overrides): "max_retries": 3, "timeout_fallback_text": "", "image_size": "None", - "store": True, + "store": "new", "previous_interaction_id": "", "video": None, "image_inputs": None, @@ -159,6 +159,10 @@ def test_interactions_define_schema(): assert "gemini-4-argon" in model_input.options assert "gemini-4-argon-cyber-permissive" in model_input.options + store_input = next(i for i in schema.inputs if i.id == "store") + assert store_input.options == ["new", "previous", "manual", "disabled"] + assert store_input.default == "new" + output_names = [o.id for o in schema.outputs] assert output_names == ["text", "image", "final_actual_seed", "structured_output", "thoughts", "interaction_id"] @@ -393,14 +397,14 @@ def test_multi_turn_chaining(mock_interactions_client): ] # First turn - args1 = _execute_kwargs(prompt="First message", store=True) + args1 = _execute_kwargs(prompt="First message", store="new") out1 = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args1) id1 = out1[5] assert id1 == "int_turn_1" assert mock_interactions_client.calls[0].get("previous_interaction_id") is None - # Second turn continuing first - args2 = _execute_kwargs(prompt="Follow-up message", previous_interaction_id=id1, store=True) + # Second turn continuing first via manual ID + args2 = _execute_kwargs(prompt="Follow-up message", previous_interaction_id=id1, store="manual") out2 = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args2) assert out2[0] == "Turn 2 answer" assert out2[5] == "int_turn_2" @@ -556,24 +560,43 @@ def test_auto_store_and_continue_interaction(mock_interactions_client): mock_interactions_client.responses = [ {"text": "Turn 1 answer", "id": "int_turn_1"}, {"text": "Turn 2 answer", "id": "int_turn_2"}, + {"text": "Turn 3 fresh", "id": "int_turn_3"}, + {"text": "Turn 4 disabled", "id": "int_turn_4"}, ] - # First turn: store=True, previous_interaction_id="" -> creates new interaction - args1 = _execute_kwargs(prompt="First message", store=True, previous_interaction_id="") + # First turn: store="new" -> creates new interaction and stores it + args1 = _execute_kwargs(prompt="First message", store="new", previous_interaction_id="") out1 = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args1) assert out1[0] == "Turn 1 answer" assert out1[5] == "int_turn_1" assert "previous_interaction_id" not in mock_interactions_client.calls[0] + assert mock_interactions_client.calls[0]["store"] is True assert interactions_nodes.GeminiInteractionsStoreManager.get_last_interaction_id() == "int_turn_1" - # Second turn with DIFFERENT prompt: store=True, previous_interaction_id="" -> auto-continues with int_turn_1 - args2 = _execute_kwargs(prompt="Second message", store=True, previous_interaction_id="") + # Second turn: store="previous" -> auto-continues with int_turn_1 from store + args2 = _execute_kwargs(prompt="Second message", store="previous", previous_interaction_id="") out2 = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args2) assert out2[0] == "Turn 2 answer" assert out2[5] == "int_turn_2" assert mock_interactions_client.calls[1]["previous_interaction_id"] == "int_turn_1" assert interactions_nodes.GeminiInteractionsStoreManager.get_last_interaction_id() == "int_turn_2" + # Third turn: store="new" -> starts fresh interaction even though store has int_turn_2 + args3 = _execute_kwargs(prompt="Third message", store="new", previous_interaction_id="ignored_id") + out3 = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args3) + assert out3[0] == "Turn 3 fresh" + assert out3[5] == "int_turn_3" + assert "previous_interaction_id" not in mock_interactions_client.calls[2] + assert interactions_nodes.GeminiInteractionsStoreManager.get_last_interaction_id() == "int_turn_3" + + # Fourth turn: store="disabled" -> does not continue and sets store=False + args4 = _execute_kwargs(prompt="Fourth message", store="disabled", previous_interaction_id="ignored_id") + out4 = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args4) + assert out4[0] == "Turn 4 disabled" + assert "previous_interaction_id" not in mock_interactions_client.calls[3] + assert mock_interactions_client.calls[3]["store"] is False + assert interactions_nodes.GeminiInteractionsStoreManager.get_last_interaction_id() == "int_turn_3" + def test_expired_interaction_fallback(mock_interactions_client): # First call with expired ID fails with 404/not found, then retry succeeds with new interaction @@ -582,7 +605,7 @@ def test_expired_interaction_fallback(mock_interactions_client): {"text": "Fresh interaction response", "id": "int_fresh_1"}, ] - args = _execute_kwargs(prompt="Continue expired chat", store=True, previous_interaction_id="int_old_expired") + args = _execute_kwargs(prompt="Continue expired chat", store="manual", previous_interaction_id="int_old_expired") output = interactions_nodes.SSL_GeminiInteractionsTextPrompt.execute(**args) assert output[0] == "Fresh interaction response" @@ -693,7 +716,7 @@ def test_interactions_batch_prompts(mock_interactions_client, capsys): ] args = _execute_kwargs( prompt=["Prompt A", "Prompt B", "Prompt C"], - store=True, + store="new", previous_interaction_id="", image_inputs={"image_1": torch.full((1, 2, 2, 3), 0.5)}, )