Files

772 lines
27 KiB
Python

import base64
import hashlib
import json
import os
import time
import traceback
from urllib.parse import urlparse
from io import BytesIO
import numpy as np
import requests
from PIL import Image
PROVIDERS_DIR = os.path.dirname(os.path.abspath(__file__))
API_NODES_DIR = os.path.dirname(PROVIDERS_DIR)
PLUGIN_ROOT_DIR = os.path.dirname(API_NODES_DIR)
COMFY_ROOT_DIR = os.path.dirname(os.path.dirname(PLUGIN_ROOT_DIR))
CONFIG_PATH = os.path.join(API_NODES_DIR, "configs", "kimi_config.json")
DEFAULT_BASE_URL = "https://api.moonshot.ai/v1"
DEFAULT_TIMEOUT = 120
OVERLOAD_RETRY_COUNT = 3
OVERLOAD_RETRY_DELAYS = (1.0, 2.0)
DEFAULT_PLACEHOLDER_IMAGE_PATH = os.path.join(COMFY_ROOT_DIR, "input", "example.png")
_PLACEHOLDER_IMAGE_HASHES = None
_PLACEHOLDER_IMAGE_SIGNATURE = None
DEFAULT_KIMI_CHAT_MODEL = "kimi-k2-turbo-preview"
DEFAULT_KIMI_THINKING_MODEL = "kimi-k2.5"
DEFAULT_KIMI_VISION_MODEL = "moonshot-v1-8k-vision-preview"
def _load_config():
config = {}
if os.path.isfile(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as handle:
config = json.load(handle)
api_key = (
os.environ.get("ANTHROPIC_API_KEY")
or os.environ.get("MOONSHOT_API_KEY")
or os.environ.get("KIMI_API_KEY")
or config.get("api_key", "")
)
base_url = (
os.environ.get("ANTHROPIC_BASE_URL")
or os.environ.get("MOONSHOT_BASE_URL")
or os.environ.get("KIMI_BASE_URL")
or config.get("base_url", DEFAULT_BASE_URL)
)
timeout = config.get("timeout_seconds", DEFAULT_TIMEOUT)
return {
"api_key": str(api_key).strip(),
"base_url": str(base_url).rstrip("/"),
"timeout_seconds": int(timeout),
"chat_model": str(config.get("chat_model", DEFAULT_KIMI_CHAT_MODEL)).strip() or DEFAULT_KIMI_CHAT_MODEL,
"thinking_model": str(config.get("thinking_model", DEFAULT_KIMI_THINKING_MODEL)).strip() or DEFAULT_KIMI_THINKING_MODEL,
"vision_model": str(config.get("vision_model", DEFAULT_KIMI_VISION_MODEL)).strip() or DEFAULT_KIMI_VISION_MODEL,
}
def _is_kimi_coding_base_url(base_url):
parsed = urlparse(base_url)
host = parsed.netloc.lower()
path = parsed.path.lower().rstrip("/")
return host == "api.kimi.com" and path.startswith("/coding")
def _normalize_base_url(base_url):
normalized = str(base_url).rstrip("/")
if _is_kimi_coding_base_url(normalized) and not normalized.lower().endswith("/v1"):
return normalized + "/v1"
return normalized
def _normalize_message_content(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if text:
parts.append(str(text))
return "\n".join(parts)
if content is None:
return ""
return str(content)
def _image_to_data_url(image):
if image is None:
raise ValueError("Image input is empty.")
if hasattr(image, "cpu"):
image = image.cpu().numpy()
image = np.asarray(image)
if image.ndim == 4:
image = image[0]
if image.ndim != 3:
raise ValueError(f"Unsupported image shape: {image.shape}")
image = np.clip(image * 255.0, 0, 255).astype(np.uint8)
pil_image = Image.fromarray(image)
buffer = BytesIO()
pil_image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
return f"data:image/png;base64,{encoded}"
def _image_to_png_bytes(image):
image_array = _image_to_numpy_rgb(image)
pil_image = Image.fromarray(image_array)
buffer = BytesIO()
pil_image.save(buffer, format="PNG")
return buffer.getvalue()
def _image_to_numpy_rgb(image):
if hasattr(image, "cpu"):
image = image.cpu().numpy()
image = np.asarray(image)
if image.ndim == 4:
image = image[0]
if image.ndim != 3:
raise ValueError(f"Unsupported image shape: {image.shape}")
image = np.clip(image * 255.0, 0, 255).astype(np.uint8)
return image
def _average_hash_from_rgb_array(image_array, size=8):
pil_image = Image.fromarray(image_array).convert("L").resize((size, size), Image.Resampling.LANCZOS)
arr = np.asarray(pil_image, dtype=np.float32)
mean_value = float(arr.mean())
return "".join("1" if value >= mean_value else "0" for value in arr.flatten())
def _signature_from_rgb_array(image_array, size=32):
pil_image = Image.fromarray(image_array).convert("RGB").resize((size, size), Image.Resampling.LANCZOS)
arr = np.asarray(pil_image, dtype=np.float32) / 255.0
return arr
def _load_placeholder_image_hashes():
global _PLACEHOLDER_IMAGE_HASHES
if _PLACEHOLDER_IMAGE_HASHES is not None:
return _PLACEHOLDER_IMAGE_HASHES
hashes = set()
if os.path.isfile(DEFAULT_PLACEHOLDER_IMAGE_PATH):
try:
placeholder = Image.open(DEFAULT_PLACEHOLDER_IMAGE_PATH).convert("RGB")
hashes.add(_average_hash_from_rgb_array(np.asarray(placeholder, dtype=np.uint8)))
except Exception:
pass
_PLACEHOLDER_IMAGE_HASHES = hashes
return _PLACEHOLDER_IMAGE_HASHES
def _load_placeholder_image_signature():
global _PLACEHOLDER_IMAGE_SIGNATURE
if _PLACEHOLDER_IMAGE_SIGNATURE is not None:
return _PLACEHOLDER_IMAGE_SIGNATURE
if os.path.isfile(DEFAULT_PLACEHOLDER_IMAGE_PATH):
try:
placeholder = Image.open(DEFAULT_PLACEHOLDER_IMAGE_PATH).convert("RGB")
_PLACEHOLDER_IMAGE_SIGNATURE = _signature_from_rgb_array(np.asarray(placeholder, dtype=np.uint8))
return _PLACEHOLDER_IMAGE_SIGNATURE
except Exception:
pass
_PLACEHOLDER_IMAGE_SIGNATURE = None
return _PLACEHOLDER_IMAGE_SIGNATURE
def _is_placeholder_image_tensor(image):
# Treat ComfyUI's built-in input/example.png as an empty placeholder image
# even after common resize-like preprocessing, so workflows can keep
# placeholder LoadImage nodes connected without triggering a real upload.
hashes = _load_placeholder_image_hashes()
try:
image_array = _image_to_numpy_rgb(image)
except Exception:
return False
if hashes:
image_hash = _average_hash_from_rgb_array(image_array)
if image_hash in hashes:
return True
placeholder_signature = _load_placeholder_image_signature()
if placeholder_signature is None:
return False
try:
image_signature = _signature_from_rgb_array(image_array)
except Exception:
return False
mae = float(np.mean(np.abs(image_signature - placeholder_signature)))
return mae <= 0.015
def _build_kimi_user_content(prompt, image_urls):
content = [{"type": "text", "text": prompt}]
for image_url in image_urls:
content.append({"type": "image_url", "image_url": image_url})
return content
def _is_empty_image_input(image):
if image is None:
return True
if isinstance(image, str):
return True
if _is_placeholder_image_tensor(image):
return True
return False
def _empty_image_reason(image, idx):
if image is None:
return f"image_{idx} is empty"
if isinstance(image, str):
value = image.strip() or "<empty string>"
return f"image_{idx} is empty (placeholder '{value}')"
if _is_placeholder_image_tensor(image):
return f"image_{idx} is empty (placeholder image 'example.png')"
return f"image_{idx} is empty"
def _validate_prompt(prompt, node_label):
if str(prompt).strip():
return ""
return f"{node_label}: request was not sent to Kimi because prompt is empty."
def _build_system_messages(system_prompt):
if str(system_prompt).strip():
return [{"role": "system", "content": system_prompt}]
return []
def _should_use_json_mode(prompt, system_prompt):
combined = f"{system_prompt}\n{prompt}".lower()
markers = (
"```json",
'"镜头1"',
'"shot1"',
'"scene1"',
"json格式",
"json format",
"json_object",
"json object",
)
return any(marker in combined for marker in markers)
def _collect_image_urls(images, node_label):
image_urls = []
gap_found = False
first_empty_reason = ""
for idx, image in enumerate(images, start=1):
if _is_empty_image_input(image):
gap_found = True
if not first_empty_reason:
first_empty_reason = _empty_image_reason(image, idx)
continue
if gap_found:
raise ValueError(
f"{node_label}: request was not sent to Kimi because {first_empty_reason}, so image_{idx} cannot be used after that. "
"Only trailing image inputs may be empty."
)
try:
image_urls.append(_image_to_data_url(image))
except Exception as exc:
raise ValueError(f"{node_label}: request was not sent to Kimi because image_{idx} could not be encoded: {exc}") from exc
if not image_urls:
if first_empty_reason:
raise ValueError(f"{node_label}: request was not sent to Kimi because no valid image was provided. First empty slot: {first_empty_reason}.")
raise ValueError(f"{node_label}: request was not sent to Kimi because no valid image was provided.")
return image_urls
def _collect_image_blobs(images, node_label):
image_blobs = []
gap_found = False
first_empty_reason = ""
for idx, image in enumerate(images, start=1):
if _is_empty_image_input(image):
gap_found = True
if not first_empty_reason:
first_empty_reason = _empty_image_reason(image, idx)
continue
if gap_found:
raise ValueError(
f"{node_label}: request was not sent to Kimi because {first_empty_reason}, so image_{idx} cannot be used after that. "
"Only trailing image inputs may be empty."
)
try:
png_bytes = _image_to_png_bytes(image)
except Exception as exc:
raise ValueError(f"{node_label}: request was not sent to Kimi because image_{idx} could not be encoded: {exc}") from exc
image_blobs.append(
{
"index": idx,
"bytes": png_bytes,
"sha256": hashlib.sha256(png_bytes).hexdigest(),
"filename": f"image_{idx}.png",
}
)
if not image_blobs:
if first_empty_reason:
raise ValueError(f"{node_label}: request was not sent to Kimi because no valid image was provided. First empty slot: {first_empty_reason}.")
raise ValueError(f"{node_label}: request was not sent to Kimi because no valid image was provided.")
return image_blobs
def _should_retry_overload(status_code, error_message):
if status_code not in (429, 500, 502, 503, 504):
return False
lowered = str(error_message).lower()
overload_markers = (
"overloaded",
"try again later",
"server is busy",
"service unavailable",
"temporarily unavailable",
)
return any(marker in lowered for marker in overload_markers)
class _KimiBaseNode:
def __init__(self):
self.message_history = []
self._uploaded_image_cache = {}
self._session = requests.Session()
def _config_error(self):
return (
"Kimi API key is missing. Set MOONSHOT_API_KEY/KIMI_API_KEY or create kimi_config.json from "
"api_nodes/configs/kimi_config.example.json in custom_nodes/tdxh_node_comfyui."
)
def _request(self, payload):
try:
config = _load_config()
except Exception as exc:
return None, f"Failed to load Kimi config: {exc}"
if not config["api_key"]:
return None, self._config_error()
base_url = _normalize_base_url(config["base_url"])
url = f"{base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {config['api_key']}",
"Content-Type": "application/json",
}
for attempt in range(1, OVERLOAD_RETRY_COUNT + 1):
try:
response = self._session.post(
url,
headers=headers,
json=payload,
timeout=config["timeout_seconds"],
)
except requests.Timeout as exc:
if attempt < OVERLOAD_RETRY_COUNT:
delay = OVERLOAD_RETRY_DELAYS[min(attempt - 1, len(OVERLOAD_RETRY_DELAYS) - 1)]
print(
f"[Kimi API] Timeout on attempt {attempt}/{OVERLOAD_RETRY_COUNT}. "
f"Retrying in {delay:.1f}s."
)
time.sleep(delay)
continue
return None, f"Kimi request timed out after {config['timeout_seconds']} seconds: {exc}"
except requests.RequestException as exc:
return None, f"Kimi request failed: {exc}"
try:
data = response.json()
except ValueError:
text = response.text[:1000]
return None, f"Kimi returned non-JSON response ({response.status_code}): {text}"
if response.ok:
return data, ""
error_message = data.get("error", {}).get("message") or data.get("message") or json.dumps(data, ensure_ascii=False)
if response.status_code == 403 and "currently only available for Coding Agents" in error_message:
error_message = (
"Kimi For Coding rejected this request. The Kimi Code endpoint currently allows only supported coding "
"agents such as Kimi CLI / Claude Code / Roo Code. For a ComfyUI custom node, use the Moonshot "
"Open Platform endpoint https://api.moonshot.ai/v1 with a Moonshot API key instead."
)
return None, f"Kimi API error {response.status_code}: {error_message}"
if _should_retry_overload(response.status_code, error_message) and attempt < OVERLOAD_RETRY_COUNT:
delay = OVERLOAD_RETRY_DELAYS[min(attempt - 1, len(OVERLOAD_RETRY_DELAYS) - 1)]
print(
f"[Kimi API] Temporary overload on attempt {attempt}/{OVERLOAD_RETRY_COUNT}. "
f"Retrying in {delay:.1f}s. Detail: {error_message}"
)
time.sleep(delay)
continue
if _should_retry_overload(response.status_code, error_message):
return None, (
f"Kimi API overloaded after {OVERLOAD_RETRY_COUNT} attempts. "
f"The request was sent but the server stayed busy: {error_message}"
)
if response.status_code == 429:
error_message = f"Rate limited or account/billing issue: {error_message}"
return None, f"Kimi API error {response.status_code}: {error_message}"
return None, "Kimi request failed for an unknown reason."
def _upload_image_and_get_ms_url(self, image_blob):
cached = self._uploaded_image_cache.get(image_blob["sha256"])
if cached:
return cached, ""
try:
config = _load_config()
except Exception as exc:
return "", f"Failed to load Kimi config: {exc}"
if not config["api_key"]:
return "", self._config_error()
base_url = _normalize_base_url(config["base_url"])
url = f"{base_url}/files"
headers = {
"Authorization": f"Bearer {config['api_key']}",
}
files = {
"file": (image_blob["filename"], image_blob["bytes"], "image/png"),
}
data = {
"purpose": "image",
}
for attempt in range(1, OVERLOAD_RETRY_COUNT + 1):
try:
response = self._session.post(
url,
headers=headers,
files=files,
data=data,
timeout=config["timeout_seconds"],
)
except requests.Timeout as exc:
if attempt < OVERLOAD_RETRY_COUNT:
delay = OVERLOAD_RETRY_DELAYS[min(attempt - 1, len(OVERLOAD_RETRY_DELAYS) - 1)]
print(
f"[Kimi Files] Timeout on upload attempt {attempt}/{OVERLOAD_RETRY_COUNT}. "
f"Retrying in {delay:.1f}s."
)
time.sleep(delay)
continue
return "", f"Kimi image upload timed out after {config['timeout_seconds']} seconds: {exc}"
except requests.RequestException as exc:
return "", f"Kimi image upload failed: {exc}"
try:
response_data = response.json()
except ValueError:
return "", f"Kimi image upload returned non-JSON response ({response.status_code}): {response.text[:1000]}"
if response.ok:
file_id = response_data.get("id", "")
if not file_id:
return "", "Kimi image upload succeeded but no file id was returned."
ms_url = f"ms://{file_id}"
self._uploaded_image_cache[image_blob["sha256"]] = ms_url
return ms_url, ""
error_message = response_data.get("error", {}).get("message") or response_data.get("message") or json.dumps(response_data, ensure_ascii=False)
if _should_retry_overload(response.status_code, error_message) and attempt < OVERLOAD_RETRY_COUNT:
delay = OVERLOAD_RETRY_DELAYS[min(attempt - 1, len(OVERLOAD_RETRY_DELAYS) - 1)]
print(
f"[Kimi Files] Temporary failure on attempt {attempt}/{OVERLOAD_RETRY_COUNT}. "
f"Retrying in {delay:.1f}s. Detail: {error_message}"
)
time.sleep(delay)
continue
return "", f"Kimi image upload error {response.status_code}: {error_message}"
return "", "Kimi image upload failed for an unknown reason."
def _extract_message(self, response_data):
choices = response_data.get("choices") or []
if not choices:
raise ValueError("No choices found in Kimi response.")
message = choices[0].get("message") or {}
reasoning = _normalize_message_content(message.get("reasoning_content", ""))
answer = _normalize_message_content(message.get("content", ""))
return reasoning, answer
class TdxhKimiChat(_KimiBaseNode):
DESCRIPTION = (
"Text chat node for Kimi/Moonshot. Supports optional history, thinking toggle, "
"and returns answer, reasoning, and status."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"system_prompt": (
"STRING",
{"multiline": True, "default": ""},
),
"keep_history": ("BOOLEAN", {"default": False}),
"clear_history": ("BOOLEAN", {"default": False}),
"thinking_enabled": ("BOOLEAN", {"default": True}),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"max_tokens": ("INT", {"default": 4096, "min": 1, "max": 65536, "step": 1}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("answer", "reasoning", "status")
FUNCTION = "run"
CATEGORY = "TDXH/tdxh_api"
def run(self, prompt, system_prompt, keep_history, clear_history, thinking_enabled, temperature, max_tokens):
try:
config = _load_config()
except Exception as exc:
message = f"Failed to load Kimi config: {exc}"
print(f"[TdxhKimiChat] ERROR: {message}")
return ("", "", message)
is_kimi_coding = _is_kimi_coding_base_url(config["base_url"])
prompt_error = _validate_prompt(prompt, "TdxhKimiChat")
if prompt_error:
print(f"[TdxhKimiChat] ERROR: {prompt_error}")
return ("", "", prompt_error)
if clear_history:
self.message_history = []
system_messages = _build_system_messages(system_prompt)
if keep_history:
messages = system_messages + list(self.message_history)
else:
messages = list(system_messages)
messages.append({"role": "user", "content": prompt})
payload = {
"model": "kimi-for-coding" if is_kimi_coding else (config["thinking_model"] if thinking_enabled else config["chat_model"]),
"messages": messages,
"max_tokens": max_tokens,
"stream": False,
}
if _should_use_json_mode(prompt, system_prompt):
payload["response_format"] = {"type": "json_object"}
if is_kimi_coding:
if thinking_enabled:
payload["reasoning_effort"] = "medium"
else:
if thinking_enabled:
payload["temperature"] = temperature
else:
payload["thinking"] = {"type": "disabled"}
response_data, error = self._request(payload)
if error:
print(f"[TdxhKimiChat] ERROR: {error}")
return ("", "", error)
try:
reasoning, answer = self._extract_message(response_data)
except Exception as exc:
traceback.print_exc()
message = f"Failed to parse Kimi response: {exc}"
print(f"[TdxhKimiChat] ERROR: {message}")
return ("", "", message)
if keep_history:
self.message_history = list(messages[1:])
assistant_message = {"role": "assistant", "content": answer}
if reasoning:
assistant_message["reasoning_content"] = reasoning
self.message_history.append(assistant_message)
return (answer, reasoning, "OK")
class TdxhKimiDynamicVisionChat(_KimiBaseNode):
DESCRIPTION = (
"Dynamic multi-image Kimi vision chat node. Increase image inputs with 'Update inputs'. "
"Trailing image slots may be empty, but gaps in the middle are not allowed. "
"Placeholder filenames such as 'example.png' are treated as empty image inputs. "
"Outputs originating from LoadImage(example.png) are also treated as empty placeholder images. "
"Valid images are uploaded to Moonshot Files and reused via ms://file_id for lower request overhead."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"inputcount": ("INT", {"default": 2, "min": 1, "max": 24, "step": 1}),
"image_1": ("IMAGE",),
"prompt": ("STRING", {"multiline": True, "default": ""}),
"system_prompt": (
"STRING",
{"multiline": True, "default": ""},
),
"keep_history": ("BOOLEAN", {"default": False}),
"clear_history": ("BOOLEAN", {"default": False}),
"thinking_enabled": ("BOOLEAN", {"default": True}),
"temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1}),
"max_tokens": ("INT", {"default": 4096, "min": 1, "max": 65536, "step": 1}),
},
"optional": {
"image_2": ("IMAGE",),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("answer", "reasoning", "status")
FUNCTION = "run"
CATEGORY = "TDXH/tdxh_api"
def run(
self,
inputcount,
image_1,
prompt,
system_prompt,
keep_history,
clear_history,
thinking_enabled,
temperature,
max_tokens,
**kwargs,
):
try:
config = _load_config()
except Exception as exc:
message = f"Failed to load Kimi config: {exc}"
print(f"[TdxhKimiDynamicVisionChat] ERROR: {message}")
return ("", "", message)
is_kimi_coding = _is_kimi_coding_base_url(config["base_url"])
if is_kimi_coding:
message = (
"Kimi dynamic vision node does not support the Kimi Code endpoint. "
"Use Moonshot Open Platform base_url such as https://api.moonshot.ai/v1 or https://api.moonshot.cn/v1."
)
print(f"[TdxhKimiDynamicVisionChat] ERROR: {message}")
return ("", "", message)
prompt_error = _validate_prompt(prompt, "TdxhKimiDynamicVisionChat")
if prompt_error:
print(f"[TdxhKimiDynamicVisionChat] ERROR: {prompt_error}")
return ("", "", prompt_error)
images = [image_1]
for idx in range(2, int(inputcount) + 1):
images.append(kwargs.get(f"image_{idx}"))
try:
image_blobs = _collect_image_blobs(images, "TdxhKimiDynamicVisionChat")
except Exception as exc:
message = str(exc)
print(f"[TdxhKimiDynamicVisionChat] ERROR: {message}")
return ("", "", message)
image_urls = []
for image_blob in image_blobs:
image_url, error = self._upload_image_and_get_ms_url(image_blob)
if error:
print(f"[TdxhKimiDynamicVisionChat] ERROR: {error}")
return ("", "", error)
image_urls.append(image_url)
if clear_history:
self.message_history = []
system_messages = _build_system_messages(system_prompt)
if keep_history:
messages = system_messages + list(self.message_history)
else:
messages = list(system_messages)
user_content = _build_kimi_user_content(prompt, image_urls)
messages.append({"role": "user", "content": user_content})
payload = {
"model": config["thinking_model"] if thinking_enabled else config["vision_model"],
"messages": messages,
"max_tokens": max_tokens,
"stream": False,
}
if _should_use_json_mode(prompt, system_prompt):
payload["response_format"] = {"type": "json_object"}
if thinking_enabled:
payload["temperature"] = temperature
else:
payload["thinking"] = {"type": "disabled"}
response_data, error = self._request(payload)
if error:
print(f"[TdxhKimiDynamicVisionChat] ERROR: {error}")
return ("", "", error)
try:
reasoning, answer = self._extract_message(response_data)
except Exception as exc:
traceback.print_exc()
message = f"Failed to parse Kimi dynamic vision response: {exc}"
print(f"[TdxhKimiDynamicVisionChat] ERROR: {message}")
return ("", "", message)
if keep_history:
self.message_history = list(messages[1:])
assistant_message = {"role": "assistant", "content": answer}
if reasoning:
assistant_message["reasoning_content"] = reasoning
self.message_history.append(assistant_message)
return (answer, reasoning, "OK")
NODE_CLASS_MAPPINGS = {
"TdxhKimiChat": TdxhKimiChat,
"TdxhKimiDynamicVisionChat": TdxhKimiDynamicVisionChat,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TdxhKimiChat": "TdxhKimiChat",
"TdxhKimiDynamicVisionChat": "TdxhKimiDynamicVisionChat",
}