diff --git a/.env.example b/.env.example index a5c80930..627f9b20 100644 --- a/.env.example +++ b/.env.example @@ -102,6 +102,11 @@ OPENAI_API_BASE=https://api.openai.com/v1 OPENAI_API_KEY= OPENAI_MODEL=gpt-4o-mini +# set this to azure if you are using azure openai +OPENAI_VENDOR= +OPENAI_AZURE_ENDPOINT= +OPENAI_AZURE_API_VERSION= + # OpenAI API key for realtime API OPENAI_REALTIME_API_KEY= # Azure OPENAI API key & Base URI for realtime API diff --git a/README.md b/README.md index d019154c..7d4a30f3 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,7 @@ docker compose up -d Open up a separate terminal window, enter the container and build the agent: ```bash docker exec -it ten_agent_dev bash - -task use AGENT=agents/examples/demo +task use ``` #### 5. Start the server @@ -166,14 +165,6 @@ task run #### TEN Agent Open up [localhost:3000]( http://localhost:3000 ) in browser to play the TEN Agent. -#### TEN Graph Designer - -Open up another tab go to [localhost:3001]( http://localhost:3001 ), and use Graph Designer to create, connect and edit extensions on canvas. - -Once you save the graph, you can return to [localhost:3000]( http://localhost:3000 ) and select the corresponding graph to view the changes. - -![TEN Graph Designer](https://github.com/TEN-framework/docs/blob/main/assets/gif/hello_world_python.gif?raw=true) -

How components work together

diff --git a/Taskfile.yml b/Taskfile.yml index e27827c2..e401461d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,7 +16,7 @@ tasks: use: desc: use agent, default 'agents/examples/demo' vars: - AGENT: '{{.AGENT| default "agents/examples/demo"}}' + AGENT: '{{.AGENT| default "agents/examples/default"}}' cmds: - ln -sf {{.USER_WORKING_DIR}}/{{.AGENT}}/manifest.json ./agents/ - ln -sf {{.USER_WORKING_DIR}}/{{.AGENT}}/property.json ./agents/ @@ -49,7 +49,6 @@ tasks: build-server: desc: build server dir: ./server - internal: true cmds: - go mod tidy && go mod download && go build -o bin/api main.go diff --git a/agents/examples/default/manifest.json b/agents/examples/default/manifest.json new file mode 100644 index 00000000..c87763a2 --- /dev/null +++ b/agents/examples/default/manifest.json @@ -0,0 +1,42 @@ +{ + "type": "app", + "name": "agent_demo", + "version": "0.4.0", + "dependencies": [ + { + "type": "system", + "name": "ten_runtime_go", + "version": "0.4" + }, + { + "type": "extension", + "name": "py_init_extension_cpp", + "version": "0.4" + }, + { + "type": "extension", + "name": "agora_rtc", + "version": "=0.9.0-rc1" + }, + { + "type": "extension", + "name": "agora_sess_ctrl", + "version": "0.3.0-rc1" + }, + { + "type": "system", + "name": "azure_speech_sdk", + "version": "1.38.0" + }, + { + "type": "extension", + "name": "azure_tts", + "version": "=0.6.2" + }, + { + "type": "extension", + "name": "agora_rtm", + "version": "=0.3.0" + } + ] +} \ No newline at end of file diff --git a/agents/examples/default/property.json b/agents/examples/default/property.json new file mode 100644 index 00000000..9a22abee --- /dev/null +++ b/agents/examples/default/property.json @@ -0,0 +1,406 @@ +{ + "_ten": { + "predefined_graphs": [ + { + "name": "voice_assistant", + "auto_start": false, + "nodes": [ + { + "type": "extension", + "name": "agora_rtc", + "addon": "agora_rtc", + "extension_group": "default", + "property": { + "app_id": "${env:AGORA_APP_ID}", + "token": "", + "channel": "ten_agent_test", + "stream_id": 1234, + "remote_stream_id": 123, + "subscribe_audio": true, + "publish_audio": true, + "publish_data": true, + "enable_agora_asr": false, + "agora_asr_vendor_name": "microsoft", + "agora_asr_language": "en-US", + "agora_asr_vendor_key": "${env:AZURE_STT_KEY|}", + "agora_asr_vendor_region": "${env:AZURE_STT_REGION|}", + "agora_asr_session_control_file_path": "session_control.conf" + } + }, + { + "type": "extension", + "name": "stt", + "addon": "deepgram_asr_python", + "extension_group": "stt", + "property": { + "api_key": "${env:DEEPGRAM_API_KEY}", + "language": "en-US", + "model": "nova-2", + "sample_rate": 16000 + } + }, + { + "type": "extension", + "name": "llm", + "addon": "openai_chatgpt_python", + "extension_group": "chatgpt", + "property": { + "base_url": "", + "api_key": "${env:OPENAI_API_KEY}", + "frequency_penalty": 0.9, + "model": "gpt-4o-mini", + "max_tokens": 512, + "prompt": "", + "proxy_url": "${env:OPENAI_PROXY_URL|}", + "greeting": "TEN Agent connected. How can I help you today?", + "max_memory_length": 10 + } + }, + { + "type": "extension", + "name": "tts", + "addon": "fish_audio_tts", + "extension_group": "tts", + "property": { + "api_key": "${env:FISH_AUDIO_TTS_KEY}", + "model_id": "d8639b5cc95548f5afbcfe22d3ba5ce5", + "optimize_streaming_latency": true, + "request_timeout_seconds": 30, + "base_url": "https://api.fish.audio" + } + }, + { + "type": "extension", + "name": "interrupt_detector", + "addon": "interrupt_detector_python", + "extension_group": "default" + }, + { + "type": "extension", + "name": "message_collector", + "addon": "message_collector", + "extension_group": "transcriber" + } + ], + "connections": [ + { + "extension_group": "default", + "extension": "agora_rtc", + "cmd": [ + { + "name": "on_user_joined", + "dest": [ + { + "extension_group": "stt", + "extension": "stt" + } + ] + }, + { + "name": "on_user_left", + "dest": [ + { + "extension_group": "stt", + "extension": "stt" + } + ] + }, + { + "name": "on_connection_failure", + "dest": [ + { + "extension_group": "stt", + "extension": "stt" + } + ] + } + ], + "audio_frame": [ + { + "name": "pcm_frame", + "dest": [ + { + "extension_group": "stt", + "extension": "stt" + } + ] + } + ] + }, + { + "extension_group": "stt", + "extension": "stt", + "data": [ + { + "name": "text_data", + "dest": [ + { + "extension_group": "default", + "extension": "interrupt_detector" + }, + { + "extension_group": "transcriber", + "extension": "message_collector" + } + ] + } + ] + }, + { + "extension_group": "chatgpt", + "extension": "llm", + "cmd": [ + { + "name": "flush", + "dest": [ + { + "extension_group": "tts", + "extension": "tts" + } + ] + } + ], + "data": [ + { + "name": "text_data", + "dest": [ + { + "extension_group": "tts", + "extension": "tts" + }, + { + "extension_group": "transcriber", + "extension": "message_collector" + } + ] + } + ] + }, + { + "extension_group": "transcriber", + "extension": "message_collector", + "data": [ + { + "name": "data", + "dest": [ + { + "extension_group": "default", + "extension": "agora_rtc" + } + ] + } + ] + }, + { + "extension_group": "tts", + "extension": "tts", + "cmd": [ + { + "name": "flush", + "dest": [ + { + "extension_group": "default", + "extension": "agora_rtc" + } + ] + } + ], + "audio_frame": [ + { + "name": "pcm_frame", + "dest": [ + { + "extension_group": "default", + "extension": "agora_rtc" + } + ] + } + ] + }, + { + "extension_group": "default", + "extension": "interrupt_detector", + "cmd": [ + { + "name": "flush", + "dest": [ + { + "extension_group": "chatgpt", + "extension": "llm" + } + ] + } + ], + "data": [ + { + "name": "text_data", + "dest": [ + { + "extension_group": "chatgpt", + "extension": "llm" + } + ] + } + ] + } + ] + }, + { + "name": "voice_assistant_realtime", + "auto_start": true, + "nodes": [ + { + "type": "extension", + "name": "agora_rtc", + "addon": "agora_rtc", + "extension_group": "rtc", + "property": { + "app_id": "${env:AGORA_APP_ID}", + "token": "", + "channel": "ten_agent_test", + "stream_id": 1234, + "remote_stream_id": 123, + "subscribe_audio": true, + "publish_audio": true, + "publish_data": true, + "subscribe_audio_sample_rate": 24000 + } + }, + { + "type": "extension", + "name": "v2v", + "addon": "openai_v2v_python", + "extension_group": "llm", + "property": { + "api_key": "${env:OPENAI_REALTIME_API_KEY}", + "temperature": 0.9, + "model": "gpt-4o-realtime-preview", + "max_tokens": 2048, + "voice": "alloy", + "language": "en-US", + "server_vad": true, + "dump": true, + "max_history": 10 + } + }, + { + "type": "extension", + "name": "message_collector", + "addon": "message_collector", + "extension_group": "transcriber", + "property": {} + }, + { + "type": "extension", + "name": "bingsearch_tool_python", + "addon": "bingsearch_tool_python", + "extension_group": "default", + "property": { + "api_key": "${env:BING_API_KEY|}" + } + } + ], + "connections": [ + { + "extension_group": "rtc", + "extension": "agora_rtc", + "audio_frame": [ + { + "name": "pcm_frame", + "dest": [ + { + "extension_group": "llm", + "extension": "v2v" + } + ] + } + ] + }, + { + "extension_group": "llm", + "extension": "v2v", + "cmd": [ + { + "name": "flush", + "dest": [ + { + "extension_group": "rtc", + "extension": "agora_rtc" + } + ] + } + ], + "data": [ + { + "name": "text_data", + "dest": [ + { + "extension_group": "transcriber", + "extension": "message_collector" + } + ] + } + ], + "audio_frame": [ + { + "name": "pcm_frame", + "dest": [ + { + "extension_group": "rtc", + "extension": "agora_rtc" + } + ] + } + ] + }, + { + "extension_group": "transcriber", + "extension": "message_collector", + "data": [ + { + "name": "data", + "dest": [ + { + "extension_group": "rtc", + "extension": "agora_rtc" + } + ] + } + ] + }, + { + "extension_group": "llm", + "extension": "v2v", + "cmd": [ + { + "name": "tool_call", + "dest": [ + { + "extension_group": "default", + "extension": "bingsearch_tool_python" + } + ] + } + ] + }, + { + "extension_group": "default", + "extension": "bingsearch_tool_python", + "cmd": [ + { + "name": "tool_register", + "dest": [ + { + "extension_group": "llm", + "extension": "v2v" + } + ] + } + ] + } + ] + } + ], + "log_level": 3 + } +} \ No newline at end of file diff --git a/agents/ten_packages/bak/openai_chatgpt_python/__init__.py b/agents/ten_packages/bak/openai_chatgpt_python/__init__.py deleted file mode 100644 index 42c4cd12..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from . import openai_chatgpt_addon -from .log import logger - -logger.info("openai_chatgpt_python extension loaded") diff --git a/agents/ten_packages/bak/openai_chatgpt_python/log.py b/agents/ten_packages/bak/openai_chatgpt_python/log.py deleted file mode 100644 index fa2202da..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/log.py +++ /dev/null @@ -1,13 +0,0 @@ -import logging - -logger = logging.getLogger("openai_chatgpt_python") -logger.setLevel(logging.INFO) - -formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(process)d - [%(filename)s:%(lineno)d] - %(message)s" -) - -console_handler = logging.StreamHandler() -console_handler.setFormatter(formatter) - -logger.addHandler(console_handler) diff --git a/agents/ten_packages/bak/openai_chatgpt_python/manifest.json b/agents/ten_packages/bak/openai_chatgpt_python/manifest.json deleted file mode 100644 index ce872dfe..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/manifest.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "type": "extension", - "name": "openai_chatgpt_python", - "version": "0.4.0", - "dependencies": [ - { - "type": "system", - "name": "ten_runtime_python", - "version": "0.2" - } - ], - "api": { - "property": { - "api_key": { - "type": "string" - }, - "frequency_penalty": { - "type": "float64" - }, - "presence_penalty": { - "type": "float64" - }, - "temperature": { - "type": "float64" - }, - "top_p": { - "type": "float64" - }, - "model": { - "type": "string" - }, - "max_tokens": { - "type": "int64" - }, - "base_url": { - "type": "string" - }, - "prompt": { - "type": "string" - }, - "greeting": { - "type": "string" - }, - "checking_vision_text_items": { - "type": "string" - }, - "proxy_url": { - "type": "string" - }, - "max_memory_length": { - "type": "int64" - }, - "enable_tools": { - "type": "bool" - } - }, - "data_in": [ - { - "name": "text_data", - "property": { - "text": { - "type": "string" - } - } - } - ], - "data_out": [ - { - "name": "text_data", - "property": { - "text": { - "type": "string" - } - } - } - ], - "cmd_in": [ - { - "name": "flush" - } - ], - "cmd_out": [ - { - "name": "flush" - } - ], - "video_frame_in": [ - { - "name": "video_frame" - } - ] - } -} \ No newline at end of file diff --git a/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt.py b/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt.py deleted file mode 100644 index 7f2a659c..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt.py +++ /dev/null @@ -1,92 +0,0 @@ -import random -import requests -from openai import OpenAI -from typing import List, Dict, Any, Optional -from .log import logger - - -class OpenAIChatGPTConfig: - def __init__(self, - base_url: str, - api_key: str, - model: str, - prompt: str, - frequency_penalty: float, - presence_penalty: float, - top_p: float, - temperature: float, - max_tokens: int, - seed: Optional[int] = None, - proxy_url: Optional[str] = None): - self.base_url = base_url - self.api_key = api_key - self.model = model - self.prompt = prompt - self.frequency_penalty = frequency_penalty - self.presence_penalty = presence_penalty - self.top_p = top_p - self.temperature = temperature - self.max_tokens = max_tokens - self.seed = seed if seed is not None else random.randint(0, 10000) - self.proxy_url = proxy_url - - @classmethod - def default_config(cls): - return cls( - base_url="https://api.openai.com/v1", - api_key="", - model="gpt-4", # Adjust this to match the equivalent of `openai.GPT4o` in the Python library - prompt="You are a voice assistant who talks in a conversational way and can chat with me like my friends. I will speak to you in English or Chinese, and you will answer in the corrected and improved version of my text with the language I use. Don’t talk like a robot, instead I would like you to talk like a real human with emotions. I will use your answer for text-to-speech, so don’t return me any meaningless characters. I want you to be helpful, when I’m asking you for advice, give me precise, practical and useful advice instead of being vague. When giving me a list of options, express the options in a narrative way instead of bullet points.", - frequency_penalty=0.9, - presence_penalty=0.9, - top_p=1.0, - temperature=0.1, - max_tokens=512, - seed=random.randint(0, 10000), - proxy_url="" - ) - - -class OpenAIChatGPT: - client = None - def __init__(self, config: OpenAIChatGPTConfig): - self.config = config - logger.info(f"OpenAIChatGPT initialized with config: {config.api_key}") - self.client = OpenAI( - api_key=config.api_key, - base_url=config.base_url - ) - self.session = requests.Session() - if config.proxy_url: - proxies = { - "http": config.proxy_url, - "https": config.proxy_url, - } - self.session.proxies.update(proxies) - self.client.session = self.session - - def get_chat_completions_stream(self, messages, tools = None): - req = { - "model": self.config.model, - "messages": [ - { - "role": "system", - "content": self.config.prompt, - }, - *messages, - ], - "tools": tools, - "temperature": self.config.temperature, - "top_p": self.config.top_p, - "presence_penalty": self.config.presence_penalty, - "frequency_penalty": self.config.frequency_penalty, - "max_tokens": self.config.max_tokens, - "seed": self.config.seed, - "stream": True, - } - - try: - response = self.client.chat.completions.create(**req) - return response - except Exception as e: - raise Exception(f"CreateChatCompletionStream failed, err: {e}") \ No newline at end of file diff --git a/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_addon.py b/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_addon.py deleted file mode 100644 index 861291c2..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_addon.py +++ /dev/null @@ -1,23 +0,0 @@ -# -# -# Agora Real Time Engagement -# Created by Wei Hu in 2024-05. -# Copyright (c) 2024 Agora IO. All rights reserved. -# -# -from ten import ( - Addon, - register_addon_as_extension, - TenEnv, -) -from .log import logger - - -@register_addon_as_extension("openai_chatgpt_python") -class OpenAIChatGPTExtensionAddon(Addon): - def on_create_instance(self, ten: TenEnv, addon_name: str, context) -> None: - logger.info("on_create_instance") - - from .openai_chatgpt_extension import OpenAIChatGPTExtension - - ten.on_create_instance_done(OpenAIChatGPTExtension(addon_name), context) diff --git a/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_extension.py b/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_extension.py deleted file mode 100644 index 64bdcd30..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/openai_chatgpt_extension.py +++ /dev/null @@ -1,514 +0,0 @@ -# -# -# Agora Real Time Engagement -# Created by Wei Hu in 2024-05. -# Copyright (c) 2024 Agora IO. All rights reserved. -# -# -import json -import random -import traceback -from ten.video_frame import VideoFrame -from .openai_chatgpt import OpenAIChatGPT, OpenAIChatGPTConfig -from datetime import datetime -from threading import Thread -from ten import ( - Addon, - Extension, - register_addon_as_extension, - TenEnv, - Cmd, - Data, - StatusCode, - CmdResult, -) -from .log import logger -from base64 import b64encode -import numpy as np -from io import BytesIO -from PIL import Image - - -CMD_IN_FLUSH = "flush" -CMD_OUT_FLUSH = "flush" -DATA_IN_TEXT_DATA_PROPERTY_TEXT = "text" -DATA_IN_TEXT_DATA_PROPERTY_IS_FINAL = "is_final" -DATA_OUT_TEXT_DATA_PROPERTY_TEXT = "text" -DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT = "end_of_segment" - -PROPERTY_BASE_URL = "base_url" # Optional -PROPERTY_API_KEY = "api_key" # Required -PROPERTY_MODEL = "model" # Optional -PROPERTY_PROMPT = "prompt" # Optional -PROPERTY_FREQUENCY_PENALTY = "frequency_penalty" # Optional -PROPERTY_PRESENCE_PENALTY = "presence_penalty" # Optional -PROPERTY_TEMPERATURE = "temperature" # Optional -PROPERTY_TOP_P = "top_p" # Optional -PROPERTY_MAX_TOKENS = "max_tokens" # Optional -PROPERTY_GREETING = "greeting" # Optional -PROPERTY_ENABLE_TOOLS = "enable_tools" # Optional -PROPERTY_PROXY_URL = "proxy_url" # Optional -PROPERTY_MAX_MEMORY_LENGTH = "max_memory_length" # Optional -PROPERTY_CHECKING_VISION_TEXT_ITEMS = "checking_vision_text_items" # Optional - - -def get_current_time(): - # Get the current time - start_time = datetime.now() - # Get the number of microseconds since the Unix epoch - unix_microseconds = int(start_time.timestamp() * 1_000_000) - return unix_microseconds - - -def is_punctuation(char): - if char in [",", ",", ".", "。", "?", "?", "!", "!"]: - return True - return False - - -def parse_sentence(sentence, content): - remain = "" - found_punc = False - - for char in content: - if not found_punc: - sentence += char - else: - remain += char - - if not found_punc and is_punctuation(char): - found_punc = True - - return sentence, remain, found_punc - - -def rgb2base64jpeg(rgb_data, width, height): - # Convert the RGB image to a PIL Image - pil_image = Image.frombytes("RGBA", (width, height), bytes(rgb_data)) - pil_image = pil_image.convert("RGB") - - # Resize the image while maintaining its aspect ratio - pil_image = resize_image_keep_aspect(pil_image, 320) - - # Save the image to a BytesIO object in JPEG format - buffered = BytesIO() - pil_image.save(buffered, format="JPEG") - # pil_image.save("test.jpg", format="JPEG") - - # Get the byte data of the JPEG image - jpeg_image_data = buffered.getvalue() - - # Convert the JPEG byte data to a Base64 encoded string - base64_encoded_image = b64encode(jpeg_image_data).decode("utf-8") - - # Create the data URL - mime_type = "image/jpeg" - base64_url = f"data:{mime_type};base64,{base64_encoded_image}" - return base64_url - - -def resize_image_keep_aspect(image, max_size=512): - """ - Resize an image while maintaining its aspect ratio, ensuring the larger dimension is max_size. - If both dimensions are smaller than max_size, the image is not resized. - - :param image: A PIL Image object - :param max_size: The maximum size for the larger dimension (width or height) - :return: A PIL Image object (resized or original) - """ - # Get current width and height - width, height = image.size - - # If both dimensions are already smaller than max_size, return the original image - if width <= max_size and height <= max_size: - return image - - # Calculate the aspect ratio - aspect_ratio = width / height - - # Determine the new dimensions - if width > height: - new_width = max_size - new_height = int(max_size / aspect_ratio) - else: - new_height = max_size - new_width = int(max_size * aspect_ratio) - - # Resize the image with the new dimensions - resized_image = image.resize((new_width, new_height)) - - return resized_image - - -class OpenAIChatGPTExtension(Extension): - memory = [] - max_memory_length = 10 - outdate_ts = 0 - openai_chatgpt = None - enable_tools = False - image_data = None - image_width = 0 - image_height = 0 - checking_vision_text_items = [] - - available_tools = [ - { - "type": "function", - "function": { - # ensure you use gpt-4o or later model if you need image recognition, gpt-4o-mini does not work quite well in this case - "name": "get_vision_image", - "description": "Get the image from camera. Call this whenever you need to understand the input camera image like you have vision capability, for example when user asks 'What can you see?' or 'Can you see me?'", - }, - "strict": True, - } - ] - - def on_start(self, ten: TenEnv) -> None: - logger.info("OpenAIChatGPTExtension on_start") - # Prepare configuration - openai_chatgpt_config = OpenAIChatGPTConfig.default_config() - - try: - base_url = ten.get_property_string(PROPERTY_BASE_URL) - if base_url: - openai_chatgpt_config.base_url = base_url - except Exception as err: - logger.info(f"GetProperty required {PROPERTY_BASE_URL} failed, err: {err}") - - try: - api_key = ten.get_property_string(PROPERTY_API_KEY) - openai_chatgpt_config.api_key = api_key - except Exception as err: - logger.info(f"GetProperty required {PROPERTY_API_KEY} failed, err: {err}") - return - - try: - model = ten.get_property_string(PROPERTY_MODEL) - if model: - openai_chatgpt_config.model = model - except Exception as err: - logger.info(f"GetProperty optional {PROPERTY_MODEL} error: {err}") - - try: - prompt = ten.get_property_string(PROPERTY_PROMPT) - if prompt: - openai_chatgpt_config.prompt = prompt - except Exception as err: - logger.info(f"GetProperty optional {PROPERTY_PROMPT} error: {err}") - - try: - frequency_penalty = ten.get_property_float(PROPERTY_FREQUENCY_PENALTY) - openai_chatgpt_config.frequency_penalty = float(frequency_penalty) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_FREQUENCY_PENALTY} failed, err: {err}" - ) - - try: - presence_penalty = ten.get_property_float(PROPERTY_PRESENCE_PENALTY) - openai_chatgpt_config.presence_penalty = float(presence_penalty) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_PRESENCE_PENALTY} failed, err: {err}" - ) - - try: - temperature = ten.get_property_float(PROPERTY_TEMPERATURE) - openai_chatgpt_config.temperature = float(temperature) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_TEMPERATURE} failed, err: {err}" - ) - - try: - top_p = ten.get_property_float(PROPERTY_TOP_P) - openai_chatgpt_config.top_p = float(top_p) - except Exception as err: - logger.info(f"GetProperty optional {PROPERTY_TOP_P} failed, err: {err}") - - try: - max_tokens = ten.get_property_int(PROPERTY_MAX_TOKENS) - if max_tokens > 0: - openai_chatgpt_config.max_tokens = int(max_tokens) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_MAX_TOKENS} failed, err: {err}" - ) - - try: - proxy_url = ten.get_property_string(PROPERTY_PROXY_URL) - openai_chatgpt_config.proxy_url = proxy_url - except Exception as err: - logger.info(f"GetProperty optional {PROPERTY_PROXY_URL} failed, err: {err}") - - try: - greeting = ten.get_property_string(PROPERTY_GREETING) - except Exception as err: - logger.info(f"GetProperty optional {PROPERTY_GREETING} failed, err: {err}") - - try: - self.enable_tools = ten.get_property_bool(PROPERTY_ENABLE_TOOLS) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_ENABLE_TOOLS} failed, err: {err}" - ) - - try: - prop_max_memory_length = ten.get_property_int(PROPERTY_MAX_MEMORY_LENGTH) - if prop_max_memory_length > 0: - self.max_memory_length = int(prop_max_memory_length) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_MAX_MEMORY_LENGTH} failed, err: {err}" - ) - - try: - checking_vision_text_items_str = ten.get_property_string(PROPERTY_CHECKING_VISION_TEXT_ITEMS) - self.checking_vision_text_items = json.loads(checking_vision_text_items_str) - except Exception as err: - logger.info( - f"GetProperty optional {PROPERTY_CHECKING_VISION_TEXT_ITEMS} failed, err: {err}" - ) - - # Create openaiChatGPT instance - try: - self.openai_chatgpt = OpenAIChatGPT(openai_chatgpt_config) - logger.info( - f"newOpenaiChatGPT succeed with max_tokens: {openai_chatgpt_config.max_tokens}, model: {openai_chatgpt_config.model}" - ) - except Exception as err: - logger.info(f"newOpenaiChatGPT failed, err: {err}") - - # Send greeting if available - if greeting: - try: - output_data = Data.create("text_data") - output_data.set_property_string( - DATA_OUT_TEXT_DATA_PROPERTY_TEXT, greeting - ) - output_data.set_property_bool( - DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT, True - ) - ten.send_data(output_data) - logger.info(f"greeting [{greeting}] sent") - except Exception as err: - logger.info(f"greeting [{greeting}] send failed, err: {err}") - ten.on_start_done() - - def on_stop(self, ten: TenEnv) -> None: - logger.info("OpenAIChatGPTExtension on_stop") - ten.on_stop_done() - - def append_memory(self, message): - if len(self.memory) > self.max_memory_length: - self.memory.pop(0) - self.memory.append(message) - - def on_cmd(self, ten: TenEnv, cmd: Cmd) -> None: - logger.info("OpenAIChatGPTExtension on_cmd") - cmd_json = cmd.to_json() - logger.info("OpenAIChatGPTExtension on_cmd json: " + cmd_json) - - cmd_name = cmd.get_name() - - if cmd_name == CMD_IN_FLUSH: - self.outdate_ts = get_current_time() - cmd_out = Cmd.create(CMD_OUT_FLUSH) - ten.send_cmd(cmd_out, None) - logger.info(f"OpenAIChatGPTExtension on_cmd sent flush") - else: - logger.info(f"OpenAIChatGPTExtension on_cmd unknown cmd: {cmd_name}") - cmd_result = CmdResult.create(StatusCode.ERROR) - cmd_result.set_property_string("detail", "unknown cmd") - ten.return_result(cmd_result, cmd) - return - - cmd_result = CmdResult.create(StatusCode.OK) - cmd_result.set_property_string("detail", "success") - ten.return_result(cmd_result, cmd) - - def on_video_frame(self, ten_env: TenEnv, frame: VideoFrame) -> None: - # logger.info(f"OpenAIChatGPTExtension on_video_frame {frame.get_width()} {frame.get_height()}") - self.image_data = frame.get_buf() - self.image_width = frame.get_width() - self.image_height = frame.get_height() - return - - def on_data(self, ten: TenEnv, data: Data) -> None: - """ - on_data receives data from ten graph. - current supported data: - - name: text_data - example: - {name: text_data, properties: {text: "hello"} - """ - logger.info(f"OpenAIChatGPTExtension on_data") - - # Assume 'data' is an object from which we can get properties - try: - is_final = data.get_property_bool(DATA_IN_TEXT_DATA_PROPERTY_IS_FINAL) - if not is_final: - logger.info("ignore non-final input") - return - except Exception as err: - logger.info( - f"OnData GetProperty {DATA_IN_TEXT_DATA_PROPERTY_IS_FINAL} failed, err: {err}" - ) - return - - # Get input text - try: - input_text = data.get_property_string(DATA_IN_TEXT_DATA_PROPERTY_TEXT) - if not input_text: - logger.info("ignore empty text") - return - logger.info(f"OnData input text: [{input_text}]") - except Exception as err: - logger.info( - f"OnData GetProperty {DATA_IN_TEXT_DATA_PROPERTY_TEXT} failed, err: {err}" - ) - return - - def chat_completions_stream_worker(start_time, input_text, memory): - self.chat_completion(ten, start_time, input_text, memory) - - # Start thread to request and read responses from OpenAI - start_time = get_current_time() - thread = Thread( - target=chat_completions_stream_worker, - args=(start_time, input_text, self.memory), - ) - thread.start() - logger.info(f"OpenAIChatGPTExtension on_data end") - - def send_data(self, ten, sentence, end_of_segment, input_text): - try: - output_data = Data.create("text_data") - output_data.set_property_string(DATA_OUT_TEXT_DATA_PROPERTY_TEXT, sentence) - output_data.set_property_bool( - DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT, end_of_segment - ) - ten.send_data(output_data) - logger.info( - f"for input text: [{input_text}] {'end of segment ' if end_of_segment else ''}sent sentence [{sentence}]" - ) - except Exception as err: - logger.info( - f"for input text: [{input_text}] send sentence [{sentence}] failed, err: {err}" - ) - - def process_completions( - self, chat_completions, ten, start_time, input_text, memory - ): - sentence = "" - full_content = "" - first_sentence_sent = False - - for chat_completion in chat_completions: - content = "" - if start_time < self.outdate_ts: - logger.info( - f"recv interrupt and flushing for input text: [{input_text}], startTs: {start_time}, outdateTs: {self.outdate_ts}" - ) - break - - # content = chat_completion.choices[0].delta.content if len(chat_completion.choices) > 0 and chat_completion.choices[0].delta.content is not None else "" - if len(chat_completion.choices) > 0: - if chat_completion.choices[0].delta.tool_calls is not None: - for tool_call in chat_completion.choices[0].delta.tool_calls: - logger.info(f"tool_call: {tool_call}") - if tool_call.function.name == "get_vision_image": - if full_content == "" and len(self.checking_vision_text_items) > 0: - # if no text content, send a message to ask user to wait - self.send_data(ten, random.choice(self.checking_vision_text_items), True, input_text) - # for get_vision_image, re-run the completion with vision, memory should not be affected - self.chat_completion_with_vision( - ten, start_time, input_text, memory - ) - return - elif chat_completion.choices[0].delta.content is not None: - content = chat_completion.choices[0].delta.content - else: - content = "" - - full_content += content - - while True: - sentence, content, sentence_is_final = parse_sentence(sentence, content) - if len(sentence) == 0 or not sentence_is_final: - logger.info(f"sentence {sentence} is empty or not final") - break - logger.info( - f"recv for input text: [{input_text}] got sentence: [{sentence}]" - ) - self.send_data(ten, sentence, False, input_text) - sentence = "" - - if not first_sentence_sent: - first_sentence_sent = True - logger.info( - f"recv for input text: [{input_text}] first sentence sent, first_sentence_latency {get_current_time() - start_time}ms" - ) - - # memory is recorded only when completion is completely done, with single pair of user and assistant message - self.append_memory({"role": "user", "content": input_text}) - self.append_memory({"role": "assistant", "content": full_content}) - self.send_data(ten, sentence, True, input_text) - - def chat_completion_with_vision(self, ten: TenEnv, start_time, input_text, memory): - try: - logger.info(f"for input text: [{input_text}] memory: {memory}") - message = {"role": "user", "content": input_text} - - if self.image_data is not None: - url = rgb2base64jpeg( - self.image_data, self.image_width, self.image_height - ) - message = { - "role": "user", - "content": [ - {"type": "text", "text": input_text}, - {"type": "image_url", "image_url": {"url": url}}, - ], - } - logger.info(f"msg: {message}") - - resp = self.openai_chatgpt.get_chat_completions_stream(memory + [message]) - if resp is None: - logger.error( - f"get_chat_completions_stream Response is None: {input_text}" - ) - return - - self.process_completions(resp, ten, start_time, input_text, memory) - - except Exception as e: - logger.error(f"err: {str(e)}: {input_text}") - - def chat_completion(self, ten: TenEnv, start_time, input_text, memory): - try: - logger.info(f"for input text: [{input_text}] memory: {memory}") - message = {"role": "user", "content": input_text} - - tools = self.available_tools if self.enable_tools else None - logger.info(f"chat_completion tools: {tools}") - resp = self.openai_chatgpt.get_chat_completions_stream( - memory + [message], tools - ) - if resp is None: - logger.error( - f"get_chat_completions_stream Response is None: {input_text}" - ) - return - - self.process_completions(resp, ten, start_time, input_text, memory) - - except Exception as e: - logger.error(f"err: {traceback.format_exc()}: {input_text}") - - -@register_addon_as_extension("openai_chatgpt_python") -class OpenAIChatGPTExtensionAddon(Addon): - def on_create_instance(self, ten: TenEnv, addon_name: str, context) -> None: - logger.info("on_create_instance") - ten.on_create_instance_done(OpenAIChatGPTExtension(addon_name), context) diff --git a/agents/ten_packages/bak/openai_chatgpt_python/property.json b/agents/ten_packages/bak/openai_chatgpt_python/property.json deleted file mode 100644 index 9e26dfee..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/property.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/agents/ten_packages/bak/openai_chatgpt_python/requirements.txt b/agents/ten_packages/bak/openai_chatgpt_python/requirements.txt deleted file mode 100644 index 5ddef5be..00000000 --- a/agents/ten_packages/bak/openai_chatgpt_python/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -openai -numpy -requests -pillow -asyncio \ No newline at end of file diff --git a/agents/ten_packages/extension/bedrock_llm_python/property.json b/agents/ten_packages/extension/bedrock_llm_python/property.json index 9e26dfee..119decfa 100644 --- a/agents/ten_packages/extension/bedrock_llm_python/property.json +++ b/agents/ten_packages/extension/bedrock_llm_python/property.json @@ -1 +1,10 @@ -{} \ No newline at end of file +{ + "region": "us-east-1", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", + "model": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "max_tokens": 512, + "prompt": "", + "greeting": "TEN Agent connected. How can I help you today?", + "max_memory_length": 10 +} \ No newline at end of file diff --git a/agents/ten_packages/extension/bingsearch_tool_python/property.json b/agents/ten_packages/extension/bingsearch_tool_python/property.json index 9e26dfee..d0cf467d 100644 --- a/agents/ten_packages/extension/bingsearch_tool_python/property.json +++ b/agents/ten_packages/extension/bingsearch_tool_python/property.json @@ -1 +1,3 @@ -{} \ No newline at end of file +{ + "api_key": "${env:BING_API_KEY|}" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/cartesia_tts/property.json b/agents/ten_packages/extension/cartesia_tts/property.json index 9e26dfee..8650c298 100644 --- a/agents/ten_packages/extension/cartesia_tts/property.json +++ b/agents/ten_packages/extension/cartesia_tts/property.json @@ -1 +1,7 @@ -{} \ No newline at end of file +{ + "api_key": "${env:CARTESIA_API_KEY}", + "language": "en", + "model_id": "sonic-english", + "sample_rate": 16000, + "voice_id": "f9836c6e-a0bd-460e-9d3c-f7299fa60f94" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/cosy_tts_python/property.json b/agents/ten_packages/extension/cosy_tts_python/property.json index 9e26dfee..db3baa0a 100644 --- a/agents/ten_packages/extension/cosy_tts_python/property.json +++ b/agents/ten_packages/extension/cosy_tts_python/property.json @@ -1 +1,6 @@ -{} \ No newline at end of file +{ + "api_key": "${env:QWEN_API_KEY}", + "model": "cosyvoice-v1", + "voice": "longxiaochun", + "sample_rate": 16000 +} \ No newline at end of file diff --git a/agents/ten_packages/extension/deepgram_asr_python/extension.py b/agents/ten_packages/extension/deepgram_asr_python/extension.py index af7cebde..df357787 100644 --- a/agents/ten_packages/extension/deepgram_asr_python/extension.py +++ b/agents/ten_packages/extension/deepgram_asr_python/extension.py @@ -69,7 +69,7 @@ async def on_audio_frame(self, ten_env: AsyncTenEnv, frame: AudioFrame) -> None: return if not self.connected: - self.ten_env.log_warn("send_frame: deepgram not connected.") + self.ten_env.log_debug("send_frame: deepgram not connected.") return self.stream_id = frame.get_property_int('stream_id') diff --git a/agents/ten_packages/extension/deepgram_asr_python/property.json b/agents/ten_packages/extension/deepgram_asr_python/property.json index 9e26dfee..9bdc2667 100644 --- a/agents/ten_packages/extension/deepgram_asr_python/property.json +++ b/agents/ten_packages/extension/deepgram_asr_python/property.json @@ -1 +1,6 @@ -{} \ No newline at end of file +{ + "api_key": "${env:DEEPGRAM_API_KEY}", + "language": "en-US", + "model": "nova-2", + "sample_rate": 16000 +} \ No newline at end of file diff --git a/agents/ten_packages/extension/elevenlabs_tts/property.json b/agents/ten_packages/extension/elevenlabs_tts/property.json index 9e26dfee..a17ebff8 100644 --- a/agents/ten_packages/extension/elevenlabs_tts/property.json +++ b/agents/ten_packages/extension/elevenlabs_tts/property.json @@ -1 +1,10 @@ -{} \ No newline at end of file +{ + "api_key": "${env:ELEVENLABS_TTS_KEY}", + "model_id": "eleven_multilingual_v2", + "optimize_streaming_latency": 0, + "request_timeout_seconds": 30, + "similarity_boost": 0.75, + "speaker_boost": false, + "stability": 0.5, + "voice_id": "pNInz6obpgDQGcFmaJgB" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/elevenlabs_tts_python/extension.py b/agents/ten_packages/extension/elevenlabs_tts_python/extension.py index 01dd44b3..8e9f924b 100644 --- a/agents/ten_packages/extension/elevenlabs_tts_python/extension.py +++ b/agents/ten_packages/extension/elevenlabs_tts_python/extension.py @@ -45,9 +45,10 @@ async def on_deinit(self, ten_env: AsyncTenEnv) -> None: async def on_request_tts(self, ten_env: AsyncTenEnv, input_text: str, end_of_segment: bool) -> None: audio_stream = await self.client.text_to_speech_stream(input_text) - + ten_env.log_info(f"on_request_tts: {input_text}") async for audio_data in audio_stream: self.send_audio_out(ten_env, audio_data) + ten_env.log_info(f"on_request_tts: {input_text} done") async def on_cancel_tts(self, ten_env: AsyncTenEnv) -> None: return await super().on_cancel_tts(ten_env) \ No newline at end of file diff --git a/agents/ten_packages/extension/elevenlabs_tts_python/property.json b/agents/ten_packages/extension/elevenlabs_tts_python/property.json index 9e26dfee..2f2e583d 100644 --- a/agents/ten_packages/extension/elevenlabs_tts_python/property.json +++ b/agents/ten_packages/extension/elevenlabs_tts_python/property.json @@ -1 +1,12 @@ -{} \ No newline at end of file +{ + "api_key": "${env:ELEVENLABS_TTS_KEY}", + "model_id": "eleven_multilingual_v2", + "optimize_streaming_latency": 0, + "request_timeout_seconds": 30, + "similarity_boost": 0.75, + "speaker_boost": false, + "stability": 0.5, + "voice_id": "pNInz6obpgDQGcFmaJgB", + "prompt": "", + "base_url": "" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/elevenlabs_tts_python/requirements.txt b/agents/ten_packages/extension/elevenlabs_tts_python/requirements.txt index 5e8e39a8..7680389e 100644 --- a/agents/ten_packages/extension/elevenlabs_tts_python/requirements.txt +++ b/agents/ten_packages/extension/elevenlabs_tts_python/requirements.txt @@ -1 +1 @@ -elevenlabs \ No newline at end of file +elevenlabs>=1.13.0 \ No newline at end of file diff --git a/agents/ten_packages/extension/fish_audio_tts/property.json b/agents/ten_packages/extension/fish_audio_tts/property.json index 9e26dfee..8053f9b4 100644 --- a/agents/ten_packages/extension/fish_audio_tts/property.json +++ b/agents/ten_packages/extension/fish_audio_tts/property.json @@ -1 +1,7 @@ -{} \ No newline at end of file +{ + "api_key": "${env:FISH_AUDIO_TTS_KEY}", + "model_id": "d8639b5cc95548f5afbcfe22d3ba5ce5", + "optimize_streaming_latency": true, + "request_timeout_seconds": 30, + "base_url": "https://api.fish.audio" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/gemini_llm_python/property.json b/agents/ten_packages/extension/gemini_llm_python/property.json new file mode 100644 index 00000000..a1325831 --- /dev/null +++ b/agents/ten_packages/extension/gemini_llm_python/property.json @@ -0,0 +1,11 @@ +{ + "api_key": "${env:GEMINI_API_KEY}", + "greeting": "TEN Agent connected. How can I help you today?", + "max_memory_length": 10, + "max_output_tokens": 512, + "model": "gemini-1.5-flash", + "prompt": "", + "temperature": 0.9, + "top_k": 40, + "top_p": 0.95 +} \ No newline at end of file diff --git a/agents/ten_packages/extension/minimax_tts/property.json b/agents/ten_packages/extension/minimax_tts/property.json index 9e26dfee..166d524a 100644 --- a/agents/ten_packages/extension/minimax_tts/property.json +++ b/agents/ten_packages/extension/minimax_tts/property.json @@ -1 +1,9 @@ -{} \ No newline at end of file +{ + "api_key": "${env:MINIMAX_TTS_API_KEY}", + "group_id": "${env:MINIMAX_TTS_GROUP_ID}", + "model": "speech-01-turbo", + "request_timeout_seconds": 10, + "sample_rate": 32000, + "url": "https://api.minimax.chat/v1/t2a_v2", + "voice_id": "male-qn-qingse" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/minimax_tts_python/property.json b/agents/ten_packages/extension/minimax_tts_python/property.json index 9e26dfee..166d524a 100644 --- a/agents/ten_packages/extension/minimax_tts_python/property.json +++ b/agents/ten_packages/extension/minimax_tts_python/property.json @@ -1 +1,9 @@ -{} \ No newline at end of file +{ + "api_key": "${env:MINIMAX_TTS_API_KEY}", + "group_id": "${env:MINIMAX_TTS_GROUP_ID}", + "model": "speech-01-turbo", + "request_timeout_seconds": 10, + "sample_rate": 32000, + "url": "https://api.minimax.chat/v1/t2a_v2", + "voice_id": "male-qn-qingse" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/openai_chatgpt_python/extension.py b/agents/ten_packages/extension/openai_chatgpt_python/extension.py index c59d6239..81e751f1 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/extension.py +++ b/agents/ten_packages/extension/openai_chatgpt_python/extension.py @@ -17,7 +17,7 @@ from ten_ai_base.llm import AsyncLLMBaseExtension from ten_ai_base.types import LLMCallCompletionArgs, LLMChatCompletionContentPartParam, LLMChatCompletionUserMessageParam, LLMChatCompletionMessageParam, LLMDataCompletionArgs, LLMToolMetadata, LLMToolResult -from .helper import parse_sentences, rgb2base64jpeg +from .helper import parse_sentences from .openai import OpenAIChatGPT, OpenAIChatGPTConfig from ten import ( Cmd, @@ -35,29 +35,17 @@ DATA_OUT_TEXT_DATA_PROPERTY_TEXT = "text" DATA_OUT_TEXT_DATA_PROPERTY_TEXT_END_OF_SEGMENT = "end_of_segment" -PROPERTY_BASE_URL = "base_url" # Optional -PROPERTY_API_KEY = "api_key" # Required -PROPERTY_MODEL = "model" # Optional -PROPERTY_PROMPT = "prompt" # Optional -PROPERTY_FREQUENCY_PENALTY = "frequency_penalty" # Optional -PROPERTY_PRESENCE_PENALTY = "presence_penalty" # Optional -PROPERTY_TEMPERATURE = "temperature" # Optional -PROPERTY_TOP_P = "top_p" # Optional -PROPERTY_MAX_TOKENS = "max_tokens" # Optional -PROPERTY_GREETING = "greeting" # Optional -PROPERTY_PROXY_URL = "proxy_url" # Optional -PROPERTY_MAX_MEMORY_LENGTH = "max_memory_length" # Optional - class OpenAIChatGPTExtension(AsyncLLMBaseExtension): def __init__(self, name: str): super().__init__(name) self.memory = [] self.memory_cache = [] - self.max_memory_length = 10 - self.openai_chatgpt = None + self.config = None + self.client = None self.sentence_fragment = "" self.toolcall_future = None + self.users_count = 0 async def on_init(self, ten_env: AsyncTenEnv) -> None: ten_env.log_info("on_init") @@ -67,35 +55,19 @@ async def on_start(self, ten_env: AsyncTenEnv) -> None: ten_env.log_info("on_start") await super().on_start(ten_env) - # Prepare configuration - openai_chatgpt_config = OpenAIChatGPTConfig.default_config() + self.config = OpenAIChatGPTConfig.create(ten_env=ten_env) + # Mandatory properties - get_properties_string(ten_env, [PROPERTY_BASE_URL, PROPERTY_API_KEY], lambda name, value: setattr( - openai_chatgpt_config, name, value or getattr(openai_chatgpt_config, name))) - if not openai_chatgpt_config.api_key: + if not self.config.api_key: ten_env.log_info(f"API key is missing, exiting on_start") return - # Optional properties - get_properties_string(ten_env, [PROPERTY_MODEL, PROPERTY_PROMPT, PROPERTY_PROXY_URL], lambda name, value: setattr( - openai_chatgpt_config, name, value or getattr(openai_chatgpt_config, name))) - get_properties_float(ten_env, [PROPERTY_FREQUENCY_PENALTY, PROPERTY_PRESENCE_PENALTY, PROPERTY_TEMPERATURE, PROPERTY_TOP_P], lambda name, value: setattr( - openai_chatgpt_config, name, value or getattr(openai_chatgpt_config, name))) - get_properties_int(ten_env, [PROPERTY_MAX_TOKENS], lambda name, value: setattr( - openai_chatgpt_config, name, value or getattr(openai_chatgpt_config, name))) - - # Properties that don't affect openai_chatgpt_config - self.greeting = get_property_string(ten_env, PROPERTY_GREETING) - self.max_memory_length = get_property_int( - ten_env, PROPERTY_MAX_MEMORY_LENGTH) - self.users_count = 0 - # Create instance try: - self.openai_chatgpt = OpenAIChatGPT(openai_chatgpt_config) + self.client = OpenAIChatGPT(ten_env, self.config) ten_env.log_info( - f"initialized with max_tokens: {openai_chatgpt_config.max_tokens}, model: {openai_chatgpt_config.model}") + f"initialized with max_tokens: {self.config.max_tokens}, model: {self.config.model}, vendor: {self.config.vendor}") except Exception as err: ten_env.log_info(f"Failed to initialize OpenAIChatGPT: {err}") @@ -122,8 +94,8 @@ async def on_cmd(self, ten_env: AsyncTenEnv, cmd: Cmd) -> None: elif cmd_name == CMD_IN_ON_USER_JOINED: self.users_count += 1 # Send greeting when first user joined - if self.greeting and self.users_count == 1: - self.send_text_output(ten_env, self.greeting, True) + if self.config.greeting and self.users_count == 1: + self.send_text_output(ten_env, self.config.greeting, True) status_code, detail = StatusCode.OK, "success" cmd_result = CmdResult.create(status_code) @@ -168,7 +140,7 @@ async def on_call_chat_completion(self, ten_env: TenEnv, **kargs: LLMCallComplet "messages", []) ten_env.log_info(f"on_call_chat_completion: {kmessages}") - response = await self.openai_chatgpt.get_chat_completions( + response = await self.client.get_chat_completions( kmessages, None) return response.to_json() @@ -202,10 +174,12 @@ async def on_data_chat_completion(self, ten_env: TenEnv, **kargs: LLMDataComplet self.memory_cache = self.memory_cache + \ [message, {"role": "assistant", "content": ""}] - tools = [] if not no_tool and len( - self.available_tools) > 0 else None - for tool in self.available_tools: - tools.append(self._convert_tools_to_dict(tool)) + tools = None + if not no_tool and len(self.available_tools) > 0: + tools = [] + for tool in self.available_tools: + tools.append(self._convert_tools_to_dict(tool)) + ten_env.log_info(f"tool: {tool}") self.sentence_fragment = "" @@ -271,7 +245,7 @@ async def handle_content_finished(full_content: str): listener.on("content_finished", handle_content_finished) # Make an async API call to get chat completions - await self.openai_chatgpt.get_chat_completions_stream(memory + [message], tools, listener) + await self.client.get_chat_completions_stream(memory + [message], tools, listener) # Wait for the content to be finished await content_finished_event.wait() @@ -336,6 +310,6 @@ def message_to_dict(self, message: LLMChatCompletionMessageParam): return message def _append_memory(self, message: str): - if len(self.memory) > self.max_memory_length: + if len(self.memory) > self.config.max_memory_length: self.memory.pop(0) self.memory.append(message) diff --git a/agents/ten_packages/extension/openai_chatgpt_python/manifest.json b/agents/ten_packages/extension/openai_chatgpt_python/manifest.json index 9c99ec65..16fb38d6 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/manifest.json +++ b/agents/ten_packages/extension/openai_chatgpt_python/manifest.json @@ -56,6 +56,15 @@ }, "max_memory_length": { "type": "int64" + }, + "vendor": { + "type": "string" + }, + "azure_endpoint": { + "type": "string" + }, + "azure_api_version": { + "type": "string" } }, "data_in": [ diff --git a/agents/ten_packages/extension/openai_chatgpt_python/openai.py b/agents/ten_packages/extension/openai_chatgpt_python/openai.py index 284127ae..5ac24e06 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/openai.py +++ b/agents/ten_packages/extension/openai_chatgpt_python/openai.py @@ -6,72 +6,61 @@ # # from collections import defaultdict +from dataclasses import dataclass import random import requests -from openai import AsyncOpenAI +from openai import AsyncOpenAI, AsyncAzureOpenAI from openai.types.chat.chat_completion import ChatCompletion -from typing import List, Dict, Any, Optional -from .log import logger - - -class OpenAIChatGPTConfig: - def __init__(self, - base_url: str, - api_key: str, - model: str, - prompt: str, - frequency_penalty: float, - presence_penalty: float, - top_p: float, - temperature: float, - max_tokens: int, - seed: Optional[int] = None, - proxy_url: Optional[str] = None): - self.base_url = base_url - self.api_key = api_key - self.model = model - self.prompt = prompt - self.frequency_penalty = frequency_penalty - self.presence_penalty = presence_penalty - self.top_p = top_p - self.temperature = temperature - self.max_tokens = max_tokens - self.seed = seed if seed is not None else random.randint(0, 10000) - self.proxy_url = proxy_url - - @classmethod - def default_config(cls): - return cls( - base_url="https://api.openai.com/v1", - api_key="", - model="gpt-4", # Adjust this to match the equivalent of `openai.GPT4o` in the Python library - prompt="You are a voice assistant who talks in a conversational way and can chat with me like my friends. I will speak to you in English or Chinese, and you will answer in the corrected and improved version of my text with the language I use. Don’t talk like a robot, instead I would like you to talk like a real human with emotions. I will use your answer for text-to-speech, so don’t return me any meaningless characters. I want you to be helpful, when I’m asking you for advice, give me precise, practical and useful advice instead of being vague. When giving me a list of options, express the options in a narrative way instead of bullet points.", - frequency_penalty=0.9, - presence_penalty=0.9, - top_p=1.0, - temperature=0.1, - max_tokens=512, - seed=random.randint(0, 10000), - proxy_url="" - ) - +from typing import List, Dict, Any, Literal, Optional, Union + +from ten.async_ten_env import AsyncTenEnv +from ten_ai_base.config import BaseConfig + + +@dataclass +class OpenAIChatGPTConfig(BaseConfig): + api_key: str = "" + base_url: str = "https://api.openai.com/v1" + model: str = "gpt-4o" # Adjust this to match the equivalent of `openai.GPT4o` in the Python library + prompt: str = "You are a voice assistant who talks in a conversational way and can chat with me like my friends. I will speak to you in English or Chinese, and you will answer in the corrected and improved version of my text with the language I use. Don’t talk like a robot, instead I would like you to talk like a real human with emotions. I will use your answer for text-to-speech, so don’t return me any meaningless characters. I want you to be helpful, when I’m asking you for advice, give me precise, practical and useful advice instead of being vague. When giving me a list of options, express the options in a narrative way instead of bullet points." + frequency_penalty: float = 0.9 + presence_penalty: float = 0.9 + top_p: float = 1.0 + temperature: float = 0.1 + max_tokens: int = 512 + seed: int = random.randint(0, 10000) + proxy_url: str = "" + greeting: str = "Hello, how can I help you today?" + max_memory_length: int = 10 + vendor: str = "openai" + azure_endpoint: str = "" + azure_api_version: str = "" + class OpenAIChatGPT: client = None - def __init__(self, config: OpenAIChatGPTConfig): + def __init__(self, ten_env:AsyncTenEnv, config: OpenAIChatGPTConfig): self.config = config - logger.info(f"OpenAIChatGPT initialized with config: {config.api_key}") - self.client = AsyncOpenAI( - api_key=config.api_key, - base_url=config.base_url - ) + ten_env.log_info(f"OpenAIChatGPT initialized with config: {config.api_key}") + if self.config.vendor == "azure": + self.client = AsyncAzureOpenAI( + api_key=config.api_key, + api_version=self.config.azure_api_version, + azure_endpoint=config.azure_endpoint + ) + ten_env.log_info(f"Using Azure OpenAI with endpoint: {config.azure_endpoint}, api_version: {config.azure_api_version}") + else: + self.client = AsyncOpenAI( + api_key=config.api_key, + base_url=config.base_url + ) self.session = requests.Session() if config.proxy_url: proxies = { "http": config.proxy_url, "https": config.proxy_url, } - logger.info(f"Setting proxies: {proxies}") + ten_env.log_info(f"Setting proxies: {proxies}") self.session.proxies.update(proxies) self.client.session = self.session @@ -131,6 +120,8 @@ async def get_chat_completions_stream(self, messages, tools = None, listener = N tool_calls_dict = defaultdict(lambda: {"id": None, "function": {"arguments": "", "name": None}, "type": None}) async for chat_completion in response: + if len(chat_completion.choices) == 0: + continue choice = chat_completion.choices[0] delta = choice.delta @@ -144,7 +135,6 @@ async def get_chat_completions_stream(self, messages, tools = None, listener = N if delta.tool_calls: for tool_call in delta.tool_calls: - logger.info(f"tool_call: {tool_call}") if tool_call.id is not None: tool_calls_dict[tool_call.index]["id"] = tool_call.id diff --git a/agents/ten_packages/extension/openai_chatgpt_python/property.json b/agents/ten_packages/extension/openai_chatgpt_python/property.json index 9e26dfee..b7d95f6f 100644 --- a/agents/ten_packages/extension/openai_chatgpt_python/property.json +++ b/agents/ten_packages/extension/openai_chatgpt_python/property.json @@ -1 +1,11 @@ -{} \ No newline at end of file +{ + "base_url": "", + "api_key": "${env:OPENAI_API_KEY}", + "frequency_penalty": 0.9, + "model": "${env:OPENAI_MODEL}", + "max_tokens": 512, + "prompt": "", + "proxy_url": "${env:OPENAI_PROXY_URL}", + "greeting": "TEN Agent connected. How can I help you today?", + "max_memory_length": 10 +} \ No newline at end of file diff --git a/agents/ten_packages/extension/openai_v2v_python/property.json b/agents/ten_packages/extension/openai_v2v_python/property.json index 9e26dfee..90392f6a 100644 --- a/agents/ten_packages/extension/openai_v2v_python/property.json +++ b/agents/ten_packages/extension/openai_v2v_python/property.json @@ -1 +1,12 @@ -{} \ No newline at end of file +{ + "api_key": "${env:OPENAI_REALTIME_API_KEY}", + "temperature": 0.9, + "model": "gpt-4o-realtime-preview", + "max_tokens": 2048, + "voice": "alloy", + "language": "en-US", + "server_vad": true, + "dump": true, + "history": 10, + "enable_storage": true +} \ No newline at end of file diff --git a/agents/ten_packages/extension/openai_v2v_python/requirements.txt b/agents/ten_packages/extension/openai_v2v_python/requirements.txt index 87050e2e..e2984efb 100644 --- a/agents/ten_packages/extension/openai_v2v_python/requirements.txt +++ b/agents/ten_packages/extension/openai_v2v_python/requirements.txt @@ -3,4 +3,4 @@ pydantic numpy==1.26.4 sounddevice==0.4.7 pydub==0.25.1 -aiohttp==3.10.7 \ No newline at end of file +aiohttp \ No newline at end of file diff --git a/agents/ten_packages/extension/polly_tts/property.json b/agents/ten_packages/extension/polly_tts/property.json new file mode 100644 index 00000000..a6d43852 --- /dev/null +++ b/agents/ten_packages/extension/polly_tts/property.json @@ -0,0 +1,9 @@ +{ + "region": "us-east-1", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", + "engine": "generative", + "voice": "Ruth", + "sample_rate": 16000, + "lang_code": "en-US" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/transcribe_asr_python/property.json b/agents/ten_packages/extension/transcribe_asr_python/property.json index 9e26dfee..4ddf1881 100644 --- a/agents/ten_packages/extension/transcribe_asr_python/property.json +++ b/agents/ten_packages/extension/transcribe_asr_python/property.json @@ -1 +1,7 @@ -{} \ No newline at end of file +{ + "region": "us-east-1", + "access_key": "${env:AWS_ACCESS_KEY_ID}", + "secret_key": "${env:AWS_SECRET_ACCESS_KEY}", + "sample_rate": "16000", + "lang_code": "en-US" +} \ No newline at end of file diff --git a/agents/ten_packages/extension/weatherapi_tool_python/property.json b/agents/ten_packages/extension/weatherapi_tool_python/property.json index 9e26dfee..4f5f409a 100644 --- a/agents/ten_packages/extension/weatherapi_tool_python/property.json +++ b/agents/ten_packages/extension/weatherapi_tool_python/property.json @@ -1 +1,3 @@ -{} \ No newline at end of file +{ + "api_key": "${env:WEATHERAPI_API_KEY|}" +} \ No newline at end of file diff --git a/agents/ten_packages/system/ten_ai_base/interface/ten_ai_base/llm_tool.py b/agents/ten_packages/system/ten_ai_base/interface/ten_ai_base/llm_tool.py index 7d9540ad..4c6f6b07 100644 --- a/agents/ten_packages/system/ten_ai_base/interface/ten_ai_base/llm_tool.py +++ b/agents/ten_packages/system/ten_ai_base/interface/ten_ai_base/llm_tool.py @@ -21,12 +21,13 @@ class AsyncLLMToolBaseExtension(AsyncExtension, ABC): async def on_start(self, ten_env: AsyncTenEnv) -> None: await super().on_start(ten_env) - tools = self.get_tool_metadata(ten_env) + tools:list[LLMToolMetadata] = self.get_tool_metadata(ten_env) for tool in tools: ten_env.log_info(f"tool: {tool}") c: Cmd = Cmd.create(CMD_TOOL_REGISTER) c.set_property_from_json( CMD_PROPERTY_TOOL, json.dumps(tool.model_dump_json())) + ten_env.log_info(f"begin tool register, {tool}") await ten_env.send_cmd(c) ten_env.log_info(f"tool registered, {tool}") diff --git a/agents/ten_packages/system/ten_ai_base/requirements.txt b/agents/ten_packages/system/ten_ai_base/requirements.txt index 9fa23cfd..e9f3515f 100644 --- a/agents/ten_packages/system/ten_ai_base/requirements.txt +++ b/agents/ten_packages/system/ten_ai_base/requirements.txt @@ -1,2 +1,2 @@ -pydantic +pydantic>=2 typing-extensions \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2f681093..c7d156f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: networks: - ten_agent_network ten_agent_playground: - image: ghcr.io/ten-framework/ten_agent_playground:0.6.1-15-g1160ef4 + image: ghcr.io/ten-framework/ten_agent_playground:0.6.1-44-g79b2c26 container_name: ten_agent_playground restart: always ports: @@ -38,27 +38,6 @@ services: - ten_agent_network environment: - AGENT_SERVER_URL=http://ten_agent_dev:8080 - - # use this when you want to run the playground in local development mode - # ten_agent_playground_dev: - # image: node:20-alpine - # container_name: ten_agent_playground_dev - # restart: always - # command: sh -c "cd /app/playground && npm i && npm run dev" #build && npm run start" - # ports: - # - "3002:3000" - # volumes: - # - ./:/app - ten_graph_designer: - image: ghcr.io/ten-framework/ten_graph_designer:dde0ff1 - container_name: ten_graph_designer - restart: always - ports: - - "3001:3000" - networks: - - ten_agent_network - environment: - - TEN_DEV_SERVER_URL=http://ten_agent_dev:49483 networks: ten_agent_network: driver: bridge diff --git a/playground/package.json b/playground/package.json index 84901065..ea8b4569 100644 --- a/playground/package.json +++ b/playground/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-select": "^2.1.2", diff --git a/playground/pnpm-lock.yaml b/playground/pnpm-lock.yaml index bd1d419c..32fc20e8 100644 --- a/playground/pnpm-lock.yaml +++ b/playground/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -868,12 +871,6 @@ packages: cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.33.5': - resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1231,6 +1228,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.2': + resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.1': resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} peerDependencies: @@ -1275,6 +1285,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.2': + resolution: {integrity: sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.2': resolution: {integrity: sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==} peerDependencies: @@ -4577,11 +4600,6 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 optional: true - '@img/sharp-linuxmusl-x64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 - optional: true - '@img/sharp-wasm32@0.33.5': dependencies: '@emnapi/runtime': 1.3.1 @@ -4882,6 +4900,21 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-menu': 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -4915,6 +4948,32 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-popover@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -7182,7 +7241,6 @@ snapshots: '@img/sharp-linux-s390x': 0.33.5 '@img/sharp-linux-x64': 0.33.5 '@img/sharp-linuxmusl-arm64': 0.33.5 - '@img/sharp-linuxmusl-x64': 0.33.5 '@img/sharp-wasm32': 0.33.5 '@img/sharp-win32-ia32': 0.33.5 '@img/sharp-win32-x64': 0.33.5 diff --git a/playground/src/apis/routes.tsx b/playground/src/apis/routes.tsx deleted file mode 100644 index 2f1affc3..00000000 --- a/playground/src/apis/routes.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { LanguageMap } from '@/common/constant'; -import { NextRequest, NextResponse } from 'next/server'; - - -const { AGENT_SERVER_URL } = process.env; - -// Check if environment variables are available -if (!AGENT_SERVER_URL) { - throw "Environment variables AGENT_SERVER_URL are not available"; -} - - -export const voiceNameMap: LanguageMap = { - "zh-CN": { - azure: { - male: "zh-CN-YunxiNeural", - female: "zh-CN-XiaoxiaoNeural", - }, - elevenlabs: { - male: "pNInz6obpgDQGcFmaJgB", // Adam - female: "Xb7hH8MSUJpSbSDYk0k2", // Alice - }, - polly: { - male: "Zhiyu", - female: "Zhiyu", - }, - }, - "en-US": { - azure: { - male: "en-US-BrianNeural", - female: "en-US-AndrewMultilingualNeural", - }, - elevenlabs: { - male: "pNInz6obpgDQGcFmaJgB", // Adam - female: "Xb7hH8MSUJpSbSDYk0k2", // Alice - }, - polly: { - male: "Matthew", - female: "Ruth", - }, - }, - "ja-JP": { - azure: { - male: "ja-JP-KeitaNeural", - female: "ja-JP-NanamiNeural", - }, - }, - "ko-KR": { - azure: { - male: "ko-KR-InJoonNeural", - female: "ko-KR-JiMinNeural", - }, - }, -}; - -// Get the graph properties based on the graph name, language, and voice type -// This is the place where you can customize the properties for different graphs to override default property.json -export const getGraphProperties = (graphName: string, language: string, voiceType: string) => { - let localizationOptions = { - "greeting": "TEN agent connected. How can I help you today?", - "checking_vision_text_items": "[\"Let me take a look...\",\"Let me check your camera...\",\"Please wait for a second...\"]", - } - - if (language === "zh-CN") { - localizationOptions = { - "greeting": "TEN Agent 已连接,需要我为您提供什么帮助?", - "checking_vision_text_items": "[\"让我看看你的摄像头...\",\"让我看一下...\",\"我看一下,请稍候...\"]", - } - } else if (language === "ja-JP") { - localizationOptions = { - "greeting": "TEN Agent エージェントに接続されました。今日は何をお手伝いしましょうか?", - "checking_vision_text_items": "[\"ちょっと見てみます...\",\"カメラをチェックします...\",\"少々お待ちください...\"]", - } - } else if (language === "ko-KR") { - localizationOptions = { - "greeting": "TEN Agent 에이전트에 연결되었습니다. 오늘은 무엇을 도와드릴까요?", - "checking_vision_text_items": "[\"조금만 기다려 주세요...\",\"카메라를 확인해 보겠습니다...\",\"잠시만 기다려 주세요...\"]", - } - } - - if (graphName == "camera_va_openai_azure") { - return { - "agora_rtc": { - "agora_asr_language": language, - }, - "openai_chatgpt": { - "model": "gpt-4o", - ...localizationOptions - }, - "azure_tts": { - "azure_synthesis_voice_name": voiceNameMap[language]["azure"][voiceType] - } - } - } else if (graphName == "va_openai_azure") { - return { - "agora_rtc": { - "agora_asr_language": language, - }, - "openai_chatgpt": { - "model": "gpt-4o-mini", - ...localizationOptions - }, - "azure_tts": { - "azure_synthesis_voice_name": voiceNameMap[language]["azure"][voiceType] - } - } - } else if (graphName == "va_qwen_rag") { - return { - "agora_rtc": { - "agora_asr_language": language, - }, - "azure_tts": { - "azure_synthesis_voice_name": voiceNameMap[language]["azure"][voiceType] - } - } - } - return {} -} - -export async function startAgent(request: NextRequest) { - try { - const body = await request.json(); - const { - request_id, - channel_name, - user_uid, - graph_name, - language, - voice_type, - } = body; - - // Send a POST request to start the agent - const response = await fetch(`${AGENT_SERVER_URL}/start`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - request_id, - channel_name, - user_uid, - graph_name, - // Get the graph properties based on the graph name, language, and voice type - properties: getGraphProperties(graph_name, language, voice_type), - }), - }); - - const responseData = await response.json(); - - return NextResponse.json(responseData, { status: response.status }); - } catch (error) { - if (error instanceof Response) { - const errorData = await error.json(); - return NextResponse.json(errorData, { status: error.status }); - } else { - return NextResponse.json({ code: "1", data: null, msg: "Internal Server Error" }, { status: 500 }); - } - } -} diff --git a/playground/src/common/constant.ts b/playground/src/common/constant.ts index 75231009..d180b0b6 100644 --- a/playground/src/common/constant.ts +++ b/playground/src/common/constant.ts @@ -112,3 +112,7 @@ export const MOBILE_ACTIVE_TAB_MAP = { [EMobileActiveTab.AGENT]: "Agent", [EMobileActiveTab.CHAT]: "Chat", }; + +export const isLLM = (extensionName: string) => { + return extensionName === "llm" || extensionName === "v2v"; +} \ No newline at end of file diff --git a/playground/src/common/graph.ts b/playground/src/common/graph.ts new file mode 100644 index 00000000..27ad5107 --- /dev/null +++ b/playground/src/common/graph.ts @@ -0,0 +1,199 @@ +import axios from "axios" +import { useCallback, useEffect, useMemo, useState } from "react" +import { useAppDispatch, useAppSelector } from "./hooks" +import { + fetchGraphDetails, + initializeGraphData, + setAddonModules, + setGraph, + setGraphList, + updateGraph, +} from "@/store/reducers/global" +import { + apiFetchAddonsExtensions, + apiFetchGraphDetails, + apiFetchGraphs, + apiFetchInstalledAddons, + apiReloadPackage, + apiSaveProperty, + apiUpdateGraph, +} from "./request" + +export namespace AddonDef { + export type AttributeType = + | "string" + | "bool" + | "int32" + | "int64" + | "Uint32" + | "Uint64" + | "float64" + | "array" + | "buf" + + export type Attribute = { + type: AttributeType + } + + export type PropertyDefinition = { + name: string + attributes: Attribute + } + + export type Command = { + name: string + property?: PropertyDefinition[] + required?: string[] + result?: { + property: PropertyDefinition[] + required?: string[] + } + } + + export type ApiEndpoint = { + name: string + property?: PropertyDefinition[] + } + + export type Api = { + property?: Record + cmd_in?: Command[] + cmd_out?: Command[] + data_in?: ApiEndpoint[] + data_out?: ApiEndpoint[] + audio_frame_in?: ApiEndpoint[] + audio_frame_out?: ApiEndpoint[] + video_frame_in?: ApiEndpoint[] + video_frame_out?: ApiEndpoint[] + } + + export type Module = { + name: string + defaultProperty: Property + api: Api + } +} + +type Property = { + [key: string]: any +} + +type Node = { + name: string + addon: string + extensionGroup: string + app: string + property?: Property +} +type Command = { + name: string // Command name + dest: Array // Destination connections +} + +type Data = { + name: string // Data type name + dest: Array // Destination connections +} + +type AudioFrame = { + name: string // Audio frame type name + dest: Array // Destination connections +} + +type VideoFrame = { + name: string // Video frame type name + dest: Array // Destination connections +} + +type MsgConversion = { + type: string // Type of message conversion + rules: Array<{ + path: string // Path in the data structure + conversionMode: string // Mode of conversion (e.g., "replace", "append") + value?: string // Optional value for the conversion + originalPath?: string // Optional original path for mapping + }> + keepOriginal?: boolean // Whether to keep the original data +} + +type Destination = { + app: string // Application identifier + extensionGroup: string // Extension group name + extension: string // Extension name + msgConversion?: MsgConversion // Optional message conversion rules +} + +type Connection = { + app: string // Application identifier + extensionGroup: string // Extension group name + extension: string // Extension name + cmd?: Array // Optional command connections + data?: Array // Optional data connections + audioFrame?: Array // Optional audio frame connections + videoFrame?: Array // Optional video frame connections +} + +type Graph = { + id: string + autoStart: boolean + nodes: Node[] + connections: Connection[] +} + +const useGraphManager = () => { + const dispatch = useAppDispatch() + const selectedGraphId = useAppSelector( + (state) => state.global.selectedGraphId, + ) + const graphMap = useAppSelector((state) => state.global.graphMap) + const selectedGraph = graphMap[selectedGraphId] + const addonModules = useAppSelector((state) => state.global.addonModules) + + // Extract tool modules from addonModules + const toolModules = useMemo( + () => addonModules.filter((module) => module.name.includes("tool") + && module.name !== "vision_analyze_tool_python" + ), + [addonModules], + ) + + const initialize = async () => { + await dispatch(initializeGraphData()) + } + + const fetchDetails = async () => { + if (selectedGraphId) { + await dispatch(fetchGraphDetails(selectedGraphId)) + } + } + + const update = async (graphId: string, updates: Partial) => { + await dispatch(updateGraph({ graphId, updates })) + } + + const getGraphNodeAddonByName = useCallback( + (nodeName: string) => { + if (!selectedGraph) { + return null + } + const node = selectedGraph.nodes.find((node) => node.name === nodeName) + if (!node) { + return null + } + return node + }, + [selectedGraph], + ) + + return { + initialize, + fetchDetails, + update, + getGraphNodeAddonByName, + selectedGraph, + toolModules, + } +} + +export { useGraphManager } +export type { Graph, Node, Connection, Command, Destination } diff --git a/playground/src/common/hooks.ts b/playground/src/common/hooks.ts index 87bb7c1f..e28d286c 100644 --- a/playground/src/common/hooks.ts +++ b/playground/src/common/hooks.ts @@ -5,6 +5,7 @@ import { deepMerge, normalizeFrequencies } from "./utils"; import { useState, useEffect, useMemo, useRef } from "react"; import type { AppDispatch, AppStore, RootState } from "../store"; import { useDispatch, useSelector, useStore } from "react-redux"; +import { Node, AddonDef } from "./graph"; // import { Grid } from "antd" // const { useBreakpoint } = Grid; @@ -127,39 +128,3 @@ export const usePrevious = (value: any) => { return ref.current; }; - -export const useGraphExtensions = () => { - const graphName = useAppSelector((state) => state.global.graphName); - const nodes = useAppSelector((state) => state.global.extensions); - const overridenProperties = useAppSelector( - (state) => state.global.overridenProperties - ); - const [graphExtensions, setGraphExtensions] = useState>( - {} - ); - - useEffect(() => { - if (nodes && nodes[graphName]) { - let extensions: Record = {}; - let extensionsByGraph = JSON.parse(JSON.stringify(nodes[graphName])); - let overriden = overridenProperties[graphName] || {}; - for (const key of Object.keys(extensionsByGraph)) { - if (!overriden[key]) { - extensions[key] = extensionsByGraph[key]; - continue; - } - extensions[key] = { - addon: extensionsByGraph[key].addon, - name: extensionsByGraph[key].name, - }; - extensions[key].property = deepMerge( - extensionsByGraph[key].property, - overriden[key] - ); - } - setGraphExtensions(extensions); - } - }, [graphName, nodes, overridenProperties]); - - return graphExtensions; -}; diff --git a/playground/src/common/request.ts b/playground/src/common/request.ts index 95c2c176..c7a9cf54 100644 --- a/playground/src/common/request.ts +++ b/playground/src/common/request.ts @@ -1,6 +1,7 @@ import { genUUID } from "./utils" import { Language } from "@/types" import axios from "axios" +import { AddonDef, Connection, Graph, Node } from "./graph" interface StartRequestConfig { channel: string @@ -8,7 +9,6 @@ interface StartRequestConfig { graphName: string, language: Language, voiceType: "male" | "female" - properties: Record } interface GenAgoraDataConfig { @@ -33,15 +33,14 @@ export const apiGenAgoraData = async (config: GenAgoraDataConfig) => { export const apiStartService = async (config: StartRequestConfig): Promise => { // look at app/apis/route.tsx for the server-side implementation const url = `/api/agents/start` - const { channel, userId, graphName, language, voiceType, properties } = config + const { channel, userId, graphName, language, voiceType } = config const data = { request_id: genUUID(), channel_name: channel, user_uid: userId, graph_name: graphName, language, - voice_type: voiceType, - properties, + voice_type: voiceType } let resp: any = await axios.post(url, data) resp = (resp.data) || {} @@ -100,35 +99,295 @@ export const apiPing = async (channel: string) => { return resp } -export const apiGetGraphs = async () => { - // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL - const url = `/api/dev/v1/graphs` - let resp: any = await axios.get(url) +export const apiFetchAddonsExtensions = async (): Promise => { + let resp: any = await axios.get(`/api/dev/v1/addons/extensions`) + return resp.data.data +} + +export const apiCheckCompatibleMessages = async (payload: { + app: string + graph: string + extension_group: string + extension: string + msg_type: string + msg_direction: string + msg_name: string +}) => { + let resp: any = await axios.post(`/api/dev/v1/messages/compatible`, payload) resp = (resp.data) || {} return resp } -export const apiGetExtensionMetadata = async () => { - // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL - const url = `/api/dev/v1/addons/extensions` - let resp: any = await axios.get(url) +export const apiFetchGraphs = async (): Promise => { + let resp: any = await axios.get(`/api/dev/v1/graphs`) + return resp.data.data.map((graph: any) => ({ + id: graph.name, + autoStart: graph.auto_start, + nodes: [], + connections: [], + })) +} + +export const apiFetchGraphNodes = async (graphId: string): Promise => { + let resp: any = await axios.get(`/api/dev/v1/graphs/${graphId}/nodes`) + return resp.data.data.map((node: any) => ({ + name: node.name, + addon: node.addon, + extensionGroup: node.extension_group, + app: node.app, + property: node.property || {}, + })) +} + +export const apiFetchGraphConnections = async (graphId: string): Promise => { + let resp: any = await axios.get(`/api/dev/v1/graphs/${graphId}/connections`) + return resp.data.data.map( + (connection: any) => ({ + app: connection.app, + extensionGroup: connection.extension_group, + extension: connection.extension, + cmd: connection.cmd?.map((cmd: any) => ({ + name: cmd.name, + dest: cmd.dest.map((dest: any) => ({ + app: dest.app, + extensionGroup: dest.extension_group, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule: any) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + data: connection.data?.map((data: any) => ({ + name: data.name, + dest: data.dest.map((dest: any) => ({ + app: dest.app, + extensionGroup: dest.extension_group, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule: any) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + audioFrame: connection.audio_frame?.map((audioFrame: any) => ({ + name: audioFrame.name, + dest: audioFrame.dest.map((dest: any) => ({ + app: dest.app, + extensionGroup: dest.extension_group, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule: any) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + videoFrame: connection.videoFrame?.map((videoFrame: any) => ({ + name: videoFrame.name, + dest: videoFrame.dest.map((dest: any) => ({ + app: dest.app, + extensionGroup: dest.extension_group, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule: any) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + }), + ) +} + +export const apiUpdateGraph = async (graphId: string, updates: Partial) => { + const { autoStart, nodes, connections } = updates + const payload: any = {} + + // Map autoStart field + if (autoStart !== undefined) payload.auto_start = autoStart + + // Map nodes to the payload + if (nodes) { + payload.nodes = nodes.map((node) => ({ + name: node.name, + addon: node.addon, + extension_group: node.extensionGroup, + app: node.app, + property: node.property, + })) + } + + // Map connections to the payload + if (connections) { + payload.connections = connections.map((connection) => ({ + app: connection.app, + extension_group: connection.extensionGroup, + extension: connection.extension, + cmd: connection.cmd?.map((cmd) => ({ + name: cmd.name, + dest: cmd.dest.map((dest) => ({ + app: dest.app, + extension_group: dest.extensionGroup, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + data: connection.data?.map((data) => ({ + name: data.name, + dest: data.dest.map((dest) => ({ + app: dest.app, + extension_group: dest.extensionGroup, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + audio_frame: connection.audioFrame?.map((audioFrame) => ({ + name: audioFrame.name, + dest: audioFrame.dest.map((dest) => ({ + app: dest.app, + extension_group: dest.extensionGroup, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + video_frame: connection.videoFrame?.map((videoFrame) => ({ + name: videoFrame.name, + dest: videoFrame.dest.map((dest) => ({ + app: dest.app, + extension_group: dest.extensionGroup, + extension: dest.extension, + msgConversion: dest.msgConversion + ? { + type: dest.msgConversion.type, + rules: dest.msgConversion.rules.map((rule) => ({ + path: rule.path, + conversionMode: rule.conversionMode, + value: rule.value, + originalPath: rule.originalPath, + })), + keepOriginal: dest.msgConversion.keepOriginal, + } + : undefined, + })), + })), + })) + } + + let resp: any = await axios.put(`/api/dev/v1/graphs/${graphId}`, payload) resp = (resp.data) || {} return resp } -export const apiGetNodes = async (graphName: string) => { - // the request will be rewrite at middleware.tsx to send to $AGENT_SERVER_URL - const url = `/api/dev/v1/graphs/${graphName}/nodes` - let resp: any = await axios.get(url) +export const apiFetchAddonModulesDefaultProperties = async (): Promise< + Record> +> => { + let resp: any = await axios.get(`/api/dev/v1/addons/default-properties`) + const properties = resp.data.data + const result: Record> = {} + for (const property of properties) { + result[property.addon] = property.property + } + return result +} + +export const apiSaveProperty = async () => { + let resp: any = await axios.put(`/api/dev/v1/property`) resp = (resp.data) || {} return resp } - -export const apiReloadGraph = async (): Promise => { - // look at app/apis/route.tsx for the server-side implementation - const url = `/api/dev/v1/packages/reload` - let resp: any = await axios.post(url) +export const apiReloadPackage = async () => { + let resp: any = await axios.post(`/api/dev/v1/packages/reload`) resp = (resp.data) || {} return resp +} + + +export const apiFetchInstalledAddons = async (): Promise => { + const [modules, defaultProperties] = await Promise.all([ + apiFetchAddonsExtensions(), + apiFetchAddonModulesDefaultProperties(), + ]) + return modules.map((module: any) => ({ + name: module.name, + defaultProperty: defaultProperties[module.name], + api: module.api, + })) +} + +export const apiFetchGraphDetails = async (graphId: string): Promise => { + const [nodes, connections] = await Promise.all([ + apiFetchGraphNodes(graphId), + apiFetchGraphConnections(graphId), + ]) + return { + id: graphId, + autoStart: true, + nodes, + connections, + } } \ No newline at end of file diff --git a/playground/src/common/storage.ts b/playground/src/common/storage.ts index 54c956c6..e9dd9930 100644 --- a/playground/src/common/storage.ts +++ b/playground/src/common/storage.ts @@ -11,24 +11,8 @@ export const getOptionsFromLocal = () => { return DEFAULT_OPTIONS } -export const getOverridenPropertiesFromLocal = () => { - if (typeof window !== "undefined") { - const data = localStorage.getItem(OVERRIDEN_PROPERTIES_KEY) - if (data) { - return JSON.parse(data) - } - } - return {} -} - export const setOptionsToLocal = (options: IOptions) => { if (typeof window !== "undefined") { localStorage.setItem(OPTIONS_KEY, JSON.stringify(options)) } -} - -export const setOverridenPropertiesToLocal = (properties: Record) => { - if (typeof window !== "undefined") { - localStorage.setItem(OVERRIDEN_PROPERTIES_KEY, JSON.stringify(properties)) - } -} +} \ No newline at end of file diff --git a/playground/src/components/Chat/ChatCard.tsx b/playground/src/components/Chat/ChatCard.tsx index 517b4b17..713c7a50 100644 --- a/playground/src/components/Chat/ChatCard.tsx +++ b/playground/src/components/Chat/ChatCard.tsx @@ -3,9 +3,8 @@ import * as React from "react"; import { cn } from "@/lib/utils"; import { - RemoteGraphSelect, - RemoteGraphCfgSheet, -} from "@/components/Chat/ChatCfgSelect"; + RemotePropertyCfgSheet, +} from "@/components/Chat/ChatCfgPropertySelect"; import PdfSelect from "@/components/Chat/PdfSelect"; import { genRandomChatList, @@ -15,28 +14,20 @@ import { useAppSelector, GRAPH_OPTIONS, isRagGraph, - apiGetGraphs, - apiGetNodes, - useGraphExtensions, - apiGetExtensionMetadata, - apiReloadGraph, } from "@/common"; import { setRtmConnected, addChatItem, - setExtensionMetadata, - setGraphName, - setGraphs, + setSelectedGraphId, setLanguage, - setExtensions, - setOverridenPropertiesByGraph, - setOverridenProperties, } from "@/store/reducers/global"; import MessageList from "@/components/Chat/MessageList"; import { Button } from "@/components/ui/button"; import { Send } from "lucide-react"; import { rtmManager } from "@/manager/rtm"; import { type IRTMTextItem, EMessageType, ERTMTextType } from "@/types"; +import { RemoteGraphSelect } from "@/components/Chat/ChatCfgGraphSelect"; +import { RemoteModuleCfgSheet } from "@/components/Chat/ChatCfgModuleSelect"; export default function ChatCard(props: { className?: string }) { const { className } = props; @@ -45,18 +36,8 @@ export default function ChatCard(props: { className?: string }) { const rtmConnected = useAppSelector((state) => state.global.rtmConnected); const dispatch = useAppDispatch(); - const graphs = useAppSelector((state) => state.global.graphs); - const extensions = useAppSelector((state) => state.global.extensions); - const graphName = useAppSelector((state) => state.global.graphName); - const chatItems = useAppSelector((state) => state.global.chatItems); + const graphName = useAppSelector((state) => state.global.selectedGraphId); const agentConnected = useAppSelector((state) => state.global.agentConnected); - const graphExtensions = useGraphExtensions(); - const extensionMetadata = useAppSelector( - (state) => state.global.extensionMetadata - ); - const overridenProperties = useAppSelector( - (state) => state.global.overridenProperties - ); const options = useAppSelector((state) => state.global.options); const disableInputMemo = React.useMemo(() => { @@ -80,44 +61,8 @@ export default function ChatCard(props: { className?: string }) { // const chatItems = genRandomChatList(10) const chatRef = React.useRef(null); - React.useEffect(() => { - apiReloadGraph().then(() => { - Promise.all([apiGetGraphs(), apiGetExtensionMetadata()]).then( - (res: any) => { - let [graphRes, metadataRes] = res; - let graphs = graphRes["data"].map((item: any) => item["name"]); - - let metadata = metadataRes["data"]; - let metadataMap: Record = {}; - metadata.forEach((item: any) => { - metadataMap[item["name"]] = item; - }); - dispatch(setGraphs(graphs)); - dispatch(setExtensionMetadata(metadataMap)); - } - ); - }); - }, []); - - React.useEffect(() => { - if (!extensions[graphName]) { - apiGetNodes(graphName).then((res: any) => { - let nodes = res["data"]; - let nodesMap: Record = {}; - nodes.forEach((item: any) => { - nodesMap[item["name"]] = item; - }); - dispatch(setExtensions({ graphName, nodesMap })); - }); - } - }, [graphName]); - useAutoScroll(chatRef); - const onGraphNameChange = (val: any) => { - dispatch(setGraphName(val)); - }; - const onTextChanged = (text: IRTMTextItem) => { console.log("[rtm] onTextChanged", text); if (text.type == ERTMTextType.TRANSCRIBE) { @@ -164,9 +109,10 @@ export default function ChatCard(props: { className?: string }) {
{/* Action Bar */} -
+
- + + {isRagGraph(graphName) && }
{/* Chat messages would go here */} diff --git a/playground/src/components/Chat/ChatCfgGraphSelect.tsx b/playground/src/components/Chat/ChatCfgGraphSelect.tsx new file mode 100644 index 00000000..0d952599 --- /dev/null +++ b/playground/src/components/Chat/ChatCfgGraphSelect.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useAppDispatch, useAppSelector } from "@/common/hooks" +import { + setSelectedGraphId, +} from "@/store/reducers/global" + + +export function RemoteGraphSelect() { + const dispatch = useAppDispatch() + const graphName = useAppSelector((state) => state.global.selectedGraphId) + const graphs = useAppSelector((state) => state.global.graphList) + const agentConnected = useAppSelector((state) => state.global.agentConnected) + + const onGraphNameChange = (val: string) => { + dispatch(setSelectedGraphId(val)) + } + + const graphOptions = graphs.map((item) => ({ + label: item, + value: item, + })) + + return ( + <> + + + ) + } + \ No newline at end of file diff --git a/playground/src/components/Chat/ChatCfgModuleSelect.tsx b/playground/src/components/Chat/ChatCfgModuleSelect.tsx new file mode 100644 index 00000000..adb40f2a --- /dev/null +++ b/playground/src/components/Chat/ChatCfgModuleSelect.tsx @@ -0,0 +1,562 @@ +import * as React from "react" +import { buttonVariants } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { useAppDispatch, useAppSelector } from "@/common/hooks" +import { AddonDef, Graph, useGraphManager, Destination } from "@/common/graph" +import { toast } from "sonner" +import { BoxesIcon, ChevronRightIcon, LoaderCircleIcon, SettingsIcon, Trash2Icon, WrenchIcon } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "../ui/dropdown" +import { isLLM } from "@/common" + +export function RemoteModuleCfgSheet() { + const addonModules = useAppSelector((state) => state.global.addonModules); + const { getGraphNodeAddonByName, selectedGraph, update: updateGraph } = useGraphManager(); + + const moduleMapping: Record = { + stt: [], + llm: ["openai_chatgpt_python"], + v2v: [], + tts: [], + }; + + // Define the exclusion map for modules + const exclusionMapping: Record = { + stt: [], + llm: ["qwen_llm_python"], + v2v: ["minimax_v2v_python"], + tts: [], + }; + + const modules = React.useMemo(() => { + const result: Record = {}; + + addonModules.forEach((module) => { + const matchingNode = selectedGraph?.nodes.find((node) => + ["stt", "tts", "llm", "v2v"].some((type) => + node.name === type && + (module.name.includes(type) || + (type === "stt" && module.name.includes("asr")) || + (moduleMapping[type]?.includes(module.name))) + ) + ); + + if ( + matchingNode && + !exclusionMapping[matchingNode.name]?.includes(module.name) + ) { + if (!result[matchingNode.name]) { + result[matchingNode.name] = []; + } + result[matchingNode.name].push(module.name); + } + }); + + return result; + }, [addonModules, selectedGraph]); + + const { toolModules } = useGraphManager(); + + const metadata = React.useMemo(() => { + const dynamicMetadata: Record = {}; + + Object.keys(modules).forEach((key) => { + dynamicMetadata[key] = { type: "string", options: modules[key] }; + }); + + return dynamicMetadata; + }, [modules]); + + const initialData = React.useMemo(() => { + const dynamicInitialData: Record = {}; + + Object.keys(modules).forEach((key) => { + dynamicInitialData[key] = getGraphNodeAddonByName(key)?.addon; + }); + + return dynamicInitialData; + }, [modules, getGraphNodeAddonByName]); + + return ( + + + + + + + Module Picker + + You can adjust STT/TTS/LLM/LLMv2v extension modules here, the values will be + written into property.json file when you save. + + + +
+ { + // Clone the selectedGraph to avoid mutating the original graph + const selectedGraphCopy: Graph = JSON.parse(JSON.stringify(selectedGraph)); + const nodes = selectedGraphCopy?.nodes || []; + const connections = selectedGraphCopy?.connections || []; + let needUpdate = false; + + // Retrieve current tools in the graph + const currentToolsInGraph = nodes + .filter((node) => toolModules.map((module) => module.name).includes(node.addon)) + .map((node) => node.addon); + + // Retrieve the app value from the agora_rtc node + const agoraRtcNode = nodes.find((node) => node.name === "agora_rtc"); + + if (!agoraRtcNode) { + toast.error("agora_rtc node not found in the graph"); + return; + } + + const agoraApp = agoraRtcNode?.app || "localhost"; + + // Identify removed tools + const removedTools = currentToolsInGraph.filter((tool) => !tools.includes(tool)); + + removedTools.forEach((tool) => { + // Remove the tool node + const toolNodeIndex = nodes.findIndex((node) => node.addon === tool); + if (toolNodeIndex !== -1) { + nodes.splice(toolNodeIndex, 1); + needUpdate = true; + } + + // Remove connections involving the tool + connections.forEach((connection, connIndex) => { + // If the connection extension matches the tool, remove the entire connection + if (connection.extension === tool) { + connections.splice(connIndex, 1); + needUpdate = true; + return; // Skip further processing for this connection + } + + // Remove tool from cmd, data, audioFrame, and videoFrame destinations + const removeEmptyDestObjects = (array: Array<{ name: string; dest: Array }> | undefined) => { + if (!array) return; + + array.forEach((object, objIndex) => { + object.dest = object.dest.filter((dest) => dest.extension !== tool); + + // If `dest` is empty, remove the object + if (object.dest.length === 0) { + array.splice(objIndex, 1); + needUpdate = true; + } + }); + }; + + // Clean up cmd, data, audioFrame, and videoFrame + removeEmptyDestObjects(connection.cmd); + removeEmptyDestObjects(connection.data); + removeEmptyDestObjects(connection.audioFrame); + removeEmptyDestObjects(connection.videoFrame); + + // Remove the entire connection if it has no `cmd`, `data`, `audioFrame`, or `videoFrame` + if ( + (!connection.cmd || connection.cmd.length === 0) && + (!connection.data || connection.data.length === 0) && + (!connection.audioFrame || connection.audioFrame.length === 0) && + (!connection.videoFrame || connection.videoFrame.length === 0) + ) { + connections.splice(connIndex, 1); + needUpdate = true; + } + }); + }); + + // Process tool modules + if (tools.length > 0) { + if (tools.some((tool) => tool.includes("vision"))) { + agoraRtcNode.property = { + ...agoraRtcNode.property, + subscribe_video_pix_fmt: 4, + subscribe_video: true, + } + needUpdate = true; + } else { + delete agoraRtcNode.property?.subscribe_video_pix_fmt; + delete agoraRtcNode.property?.subscribe_video; + } + + tools.forEach((tool) => { + if (!currentToolsInGraph.includes(tool)) { + // 1. Remove existing node for the tool if it exists + const existingNodeIndex = nodes.findIndex((node) => node.name === tool); + if (existingNodeIndex >= 0) { + nodes.splice(existingNodeIndex, 1); + } + + // Add new node for the tool + const toolModule = addonModules.find((module) => module.name === tool); + if (toolModule) { + nodes.push({ + app: agoraApp, + name: tool, + addon: tool, + extensionGroup: "default", + property: toolModule.defaultProperty, + }); + needUpdate = true; + } + + // 2. Find or create a connection for node name "llm" with cmd dest "tool_call" + let llmConnection = connections.find( + (connection) => isLLM(connection.extension) + ); + + // Retrieve the extensionGroup dynamically from the graph + const llmNode = nodes.find((node) => isLLM(node.name)); + + if (!llmNode) { + toast.error("LLM node not found in the graph"); + return; + } + + const llmExtensionGroup = llmNode?.extensionGroup; + + + + if (llmConnection) { + // If the connection exists, ensure it has a cmd array + if (!llmConnection.cmd) { + llmConnection.cmd = []; + } + + // Find the tool_call command + let toolCallCommand = llmConnection.cmd.find((cmd) => cmd.name === "tool_call"); + + if (!toolCallCommand) { + // If tool_call command doesn't exist, create it + toolCallCommand = { + name: "tool_call", + dest: [], + }; + llmConnection.cmd.push(toolCallCommand); + needUpdate = true; + } + + // Add the tool to the dest array if not already present + if (!toolCallCommand.dest.some((dest) => dest.extension === tool)) { + toolCallCommand.dest.push({ + app: agoraApp, + extensionGroup: "default", + extension: tool, + }); + needUpdate = true; + } + } else { + // If llmConnection doesn't exist, create it with the tool_call command + connections.push({ + app: agoraApp, + extensionGroup: llmExtensionGroup, + extension: llmNode.name, + cmd: [ + { + name: "tool_call", + dest: [ + { + app: agoraApp, + extensionGroup: "default", + extension: tool, + }, + ], + }, + ], + }); + needUpdate = true; + } + + + // 3. Create a connection for the tool node with cmd dest "tool_register" + connections.push({ + app: agoraApp, + extensionGroup: "default", + extension: tool, + cmd: [ + { + name: "tool_register", + dest: [ + { + app: agoraApp, + extensionGroup: llmExtensionGroup, + extension: llmNode.name, + }, + ], + }, + ], + }); + needUpdate = true; + + // Create videoFrame connection for tools with "visual" in the name + if (tool.includes("vision")) { + const rtcConnection = connections.find( + (connection) => + connection.extension === "agora_rtc" + ); + + if (rtcConnection) { + if (!rtcConnection?.videoFrame) { + rtcConnection.videoFrame = [] + } + + if (!rtcConnection.videoFrame.some((frame) => frame.name === "video_frame")) { + rtcConnection.videoFrame.push({ + name: "video_frame", + dest: [ + { + app: agoraApp, + extensionGroup: "default", + extension: tool, + }, + ], + }); + needUpdate = true; + } else if (!rtcConnection.videoFrame.some((frame) => frame.dest.some((dest) => dest.extension === tool))) { + rtcConnection.videoFrame.find((frame) => frame.name === "video_frame")?.dest.push({ + app: agoraApp, + extensionGroup: "default", + extension: tool, + }); + needUpdate = true; + } + } + } + } + }); + } + + // Update graph nodes with selected modules + Object.entries(data).forEach(([key, value]) => { + const node = nodes.find((n) => n.name === key); + if (node && value && node.addon !== value) { + node.addon = value; + node.property = addonModules.find((module) => module.name === value)?.defaultProperty; + needUpdate = true; + } + }); + + // Perform the update if changes are detected + if (needUpdate) { + try { + await updateGraph(selectedGraphCopy.id, selectedGraphCopy); + toast.success("Modules updated", { + description: `Graph: ${selectedGraphCopy.id}`, + }); + } catch (e) { + toast.error("Failed to update modules"); + } + } + }} + + /> +
+
+
+ ); +} + +const GraphModuleCfgForm = ({ + initialData, + metadata, + onUpdate, +}: { + initialData: Record; + metadata: Record; + onUpdate: (data: Record, tools: string[]) => void; +}) => { + const formSchema = z.record(z.string(), z.string().nullable()); + const { selectedGraph, toolModules } = useGraphManager(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: initialData, + }); + + const onSubmit = (data: z.infer) => { + onUpdate(data, selectedTools); + }; + + + // Custom labels for specific keys + const fieldLabels: Record = { + stt: "STT (Speech to Text)", + llm: "LLM (Large Language Model)", + tts: "TTS (Text to Speech)", + v2v: "LLM v2v (V2V Large Language Model)", + }; + + + // Initialize selectedTools by extracting tool addons used in graph nodes + const initialSelectedTools = React.useMemo(() => { + const toolNames = toolModules.map((module) => module.name); + return selectedGraph?.nodes + .filter((node) => toolNames.includes(node.addon)) + .map((node) => node.addon) || []; + }, [toolModules, selectedGraph]); + + const [selectedTools, setSelectedTools] = React.useState(initialSelectedTools); + + // Desired field order + const fieldOrder = ["stt", "llm", "v2v", "tts"]; + return ( +
+ + {fieldOrder.map( + (key) => + metadata[key] && ( // Check if the field exists in metadata +
+ ( + + +
+
{fieldLabels[key]}
+ {isLLM(key) && ( + + + + + + } className="flex justify-between"> + Add Tools + + + + {toolModules.map((module) => ( + { + if (!selectedTools.includes(module.name)) { + setSelectedTools((prev) => [ + ...prev, + module.name, + ]); + } + }}> + {module.name} + + ))} + + + + + + )} +
+
+ + + +
+ )} + /> + {isLLM(key) && selectedTools.length > 0 && ( +
+ {selectedTools.map((tool) => ( +
+ {tool} +
{ + setSelectedTools((prev) => prev.filter((t) => t !== tool)) + }} // Delete action + > + +
+
+ ))} +
+ )} +
+ ) + )} + + +
+ + ); +}; diff --git a/playground/src/components/Chat/ChatCfgPropertySelect.tsx b/playground/src/components/Chat/ChatCfgPropertySelect.tsx new file mode 100644 index 00000000..43eb202e --- /dev/null +++ b/playground/src/components/Chat/ChatCfgPropertySelect.tsx @@ -0,0 +1,408 @@ +"use client" + +import * as React from "react" +import { buttonVariants } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + useAppDispatch, + useAppSelector, +} from "@/common" +import { cn } from "@/lib/utils" +import { SettingsIcon, LoaderCircleIcon, BoxesIcon, Trash2Icon } from "lucide-react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { toast } from "sonner" +import { AddonDef, Graph, useGraphManager } from "@/common/graph" + +export function RemotePropertyCfgSheet() { + const dispatch = useAppDispatch() + const { selectedGraph, update: updateGraph } = useGraphManager() + const graphName = useAppSelector((state) => state.global.selectedGraphId) + + const [selectedExtension, setSelectedExtension] = React.useState("") + const selectedExtensionNode = selectedGraph?.nodes.find(n => n.name === selectedExtension) + const addonModules = useAppSelector((state) => state.global.addonModules) + const selectedAddonModule = addonModules.find( + (module) => module.name === selectedExtensionNode?.addon, + ) + const hasProperty = !!selectedAddonModule?.api?.property && Object.keys(selectedAddonModule?.api?.property).length > 0 + + return ( + + + + + + + Properties Setting + + You can adjust extension properties for selected graph here, the values will be + written into property.json file when you save. + + + +
+ + +
+ + {hasProperty ? selectedExtensionNode?.["property"] && ( + module.name === selectedExtensionNode?.addon, + )?.api?.property || {} + } + onUpdate={async (data) => { + // clone the overridenProperties + const selectedGraphCopy: Graph = JSON.parse(JSON.stringify(selectedGraph)) + const nodes = selectedGraphCopy?.nodes || [] + let needUpdate = false + for (const node of nodes) { + if (node.name === selectedExtension) { + node.property = data + needUpdate = true + } + } + if (needUpdate) { + await updateGraph(selectedGraphCopy.id, selectedGraphCopy) + toast.success("Properties updated", { + description: `Graph: ${graphName}, Extension: ${selectedExtension}`, + }) + } + }} + /> + ) : ( + + No properties found for the selected extension. + + )} +
+
+ ) +} + + + +export function RemotePropertyAddCfgSheet({ + selectedExtension, + extensionNodeData, + onUpdate, +}: { + selectedExtension: string, + extensionNodeData: Record, + onUpdate: (data: string) => void +}) { + const dispatch = useAppDispatch() + const { selectedGraph } = useGraphManager() + + const selectedExtensionNode = selectedGraph?.nodes.find(n => n.name === selectedExtension) + const addonModules = useAppSelector((state) => state.global.addonModules) + const selectedAddonModule = addonModules.find( + (module) => module.name === selectedExtensionNode?.addon, + ) + const allProperties = Object.keys(selectedAddonModule?.api?.property || {}) + const usedProperties = Object.keys(extensionNodeData) + const remainingProperties = allProperties.filter( + (prop) => !usedProperties.includes(prop), + ) + const hasRemainingProperties = remainingProperties.length > 0 + + const [selectedProperty, setSelectedProperty] = React.useState("") + const [isSheetOpen, setSheetOpen] = React.useState(false) // State to control the sheet + + return ( + + +
+ +
+
+ + + Property Add + + You can add a property into a graph extension node and configure its value. + + + {hasRemainingProperties ? ( + <> + + + + + ) : ( + <> + + No remaining properties to add. + + + + )} + + +
+ ) +} + +// Helper to convert values based on type +const convertToType = (value: any, type: string) => { + switch (type) { + case "int64": + case "int32": + return parseInt(value, 10) + case "float64": + return parseFloat(value) + case "bool": + return value === true || value === "true" + case "string": + return String(value) + default: + return value + } +} + +const defaultTypeValue = (type: string) => { + switch (type) { + case "int64": + case "int32": + return 0 + case "float64": + return 0.1 + case "bool": + return false + case "string": + default: + return "" + } +} + +import { useState } from "react" + +const GraphCfgForm = ({ + selectedExtension, + selectedAddonModule, + initialData, + metadata, + onUpdate, +}: { + selectedExtension: string, + selectedAddonModule: AddonDef.Module | undefined, + initialData: Record + metadata: Record + onUpdate: (data: Record) => void +}) => { + const formSchema = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]) + ) + + const [formData, setFormData] = useState(initialData) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: formData, + }) + + const onSubmit = (data: z.infer) => { + const convertedData = Object.entries(data).reduce( + (acc, [key, value]) => { + const type = metadata[key]?.type || "string" + acc[key] = value === "" ? defaultTypeValue(type) : convertToType(value, type) + return acc + }, + {} as Record + ) + onUpdate(convertedData) + } + + const handleDelete = (key: string) => { + const updatedData = { ...formData } + delete updatedData[key] // Remove the specific key + setFormData(updatedData) // Update state + form.reset(updatedData) // Reset the form + } + + const initialDataWithType = Object.entries(formData).reduce( + (acc, [key, value]) => { + acc[key] = { value, type: metadata[key]?.type || "string" } + return acc + }, + {} as Record< + string, + { value: string | number | boolean | null; type: string } + > + ) + + return ( +
+ + {Object.entries(initialDataWithType).map(([key, { value, type }]) => ( + ( + + {key} +
+ + {type === "bool" ? ( +
+ +
+ ) : ( + + )} +
+
handleDelete(key)} // Delete action + > + +
+
+
+ )} + /> + ))} +
+ { + let defaultProperty = selectedAddonModule?.defaultProperty || {} + let defaultValue = defaultProperty[key] + + if (defaultValue === undefined) { + let schema = selectedAddonModule?.api?.property || {} + let schemaType = schema[key]?.type + if (schemaType === "bool") { + defaultValue = false + } + } + let updatedData = { ...formData } + updatedData[key] = defaultValue + setFormData(updatedData) + form.reset(updatedData) + }} + /> + +
+ + + ) +} diff --git a/playground/src/components/Chat/ChatCfgSelect.tsx b/playground/src/components/Chat/ChatCfgSelect.tsx deleted file mode 100644 index 499ec1b3..00000000 --- a/playground/src/components/Chat/ChatCfgSelect.tsx +++ /dev/null @@ -1,299 +0,0 @@ -"use client" - -import * as React from "react" -import { buttonVariants } from "@/components/ui/button" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Label } from "@/components/ui/label" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { Switch } from "@/components/ui/switch" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { - useAppDispatch, - LANGUAGE_OPTIONS, - useAppSelector, - GRAPH_OPTIONS, - useGraphExtensions, -} from "@/common" -import type { Language } from "@/types" -import { - setGraphName, - setLanguage, - setOverridenPropertiesByGraph, -} from "@/store/reducers/global" -import { cn } from "@/lib/utils" -import { SettingsIcon, LoaderCircleIcon } from "lucide-react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { z } from "zod" -import { toast } from "sonner" - -export function RemoteGraphSelect() { - const dispatch = useAppDispatch() - const graphName = useAppSelector((state) => state.global.graphName) - const graphs = useAppSelector((state) => state.global.graphs) - const agentConnected = useAppSelector((state) => state.global.agentConnected) - - const onGraphNameChange = (val: string) => { - dispatch(setGraphName(val)) - } - - const graphOptions = graphs.map((item) => ({ - label: item, - value: item, - })) - - return ( - <> - - - ) -} - -export function RemoteGraphCfgSheet() { - const dispatch = useAppDispatch() - const graphExtensions = useGraphExtensions() - const graphName = useAppSelector((state) => state.global.graphName) - const extensionMetadata = useAppSelector( - (state) => state.global.extensionMetadata, - ) - const overridenProperties = useAppSelector( - (state) => state.global.overridenProperties, - ) - - const [selectedExtension, setSelectedExtension] = React.useState("") - - return ( - - - - - - - Properties Override - - You can adjust extension properties here, the values will be - overridden when the agent starts using "Connect." Note that this - won't modify the property.json file. - - - -
- - -
- - {graphExtensions?.[selectedExtension]?.["property"] && ( - { - // clone the overridenProperties - let nodesMap = JSON.parse( - JSON.stringify(overridenProperties[selectedExtension] || {}), - ) - // Update initial data with any existing overridden values - if (overridenProperties[selectedExtension]) { - Object.assign(nodesMap, overridenProperties[selectedExtension]) - } - nodesMap[selectedExtension] = data - toast.success("Properties updated", { - description: `Graph: ${graphName}, Extension: ${selectedExtension}`, - }) - dispatch( - setOverridenPropertiesByGraph({ - graphName, - nodesMap, - }), - ) - }} - /> - )} - - {/* - - - - */} -
-
- ) -} - -// Helper to convert values based on type -const convertToType = (value: any, type: string) => { - switch (type) { - case "int64": - case "int32": - return parseInt(value, 10) - case "float64": - return parseFloat(value) - case "bool": - return value === true || value === "true" - case "string": - return String(value) - default: - return value - } -} - -const GraphCfgForm = ({ - initialData, - metadata, - onUpdate, -}: { - initialData: Record - metadata: Record - onUpdate: (data: Record) => void -}) => { - const formSchema = z.record( - z.string(), - z.union([z.string(), z.number(), z.boolean(), z.null()]), - ) - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: initialData, - }) - - const onSubmit = (data: z.infer) => { - const convertedData = Object.entries(data).reduce( - (acc, [key, value]) => { - const type = metadata[key]?.type || "string" - acc[key] = value === "" ? null : convertToType(value, type) - return acc - }, - {} as Record, - ) - onUpdate(convertedData) - } - - const initialDataWithType = Object.entries(initialData).reduce( - (acc, [key, value]) => { - acc[key] = { value, type: metadata[key]?.type || "string" } - return acc - }, - {} as Record< - string, - { value: string | number | boolean | null; type: string } - >, - ) - - return ( -
- - {Object.entries(initialDataWithType).map(([key, { value, type }]) => ( - ( - - {key} - - {type === "bool" ? ( -
- -
- ) : ( - - )} -
-
- )} - /> - ))} - - - - ) -} diff --git a/playground/src/components/Layout/Action.tsx b/playground/src/components/Layout/Action.tsx index d96fddbf..b222a4bb 100644 --- a/playground/src/components/Layout/Action.tsx +++ b/playground/src/components/Layout/Action.tsx @@ -27,10 +27,7 @@ export default function Action(props: { className?: string }) { const userId = useAppSelector((state) => state.global.options.userId); const language = useAppSelector((state) => state.global.language); const voiceType = useAppSelector((state) => state.global.voiceType); - const graphName = useAppSelector((state) => state.global.graphName); - const overridenProperties = useAppSelector( - (state) => state.global.overridenProperties - ); + const graphName = useAppSelector((state) => state.global.selectedGraphId); const mobileActiveTab = useAppSelector( (state) => state.global.mobileActiveTab ); @@ -60,15 +57,12 @@ export default function Action(props: { className?: string }) { toast.success("Agent disconnected"); stopPing(); } else { - let properties: Record = - overridenProperties[graphName] || {}; const res = await apiStartService({ channel, userId, graphName, language, voiceType, - properties, }); const { code, msg } = res || {}; if (code != 0) { @@ -139,7 +133,6 @@ export default function Action(props: { className?: string }) { ))} - yarn {/* -- Action Button */} @@ -148,6 +141,7 @@ export default function Action(props: { className?: string }) { onClick={onClickConnect} variant={!agentConnected ? "default" : "destructive"} size="sm" + disabled={graphName === ""} className="w-fit min-w-24" loading={loading} svgProps={{ className: "h-4 w-4 text-muted-foreground" }} diff --git a/playground/src/components/authInitializer/index.tsx b/playground/src/components/authInitializer/index.tsx index c19e9fb8..3fdcb593 100644 --- a/playground/src/components/authInitializer/index.tsx +++ b/playground/src/components/authInitializer/index.tsx @@ -1,8 +1,9 @@ "use client" import { ReactNode, useEffect } from "react" -import { useAppDispatch, getOptionsFromLocal, getRandomChannel, getRandomUserId, getOverridenPropertiesFromLocal } from "@/common" -import { setOptions, reset, setOverridenProperties } from "@/store/reducers/global" +import { useAppDispatch, getOptionsFromLocal, getRandomChannel, getRandomUserId, useAppSelector } from "@/common" +import { setOptions, reset, fetchGraphDetails } from "@/store/reducers/global" +import { useGraphManager } from "@/common/graph"; interface AuthInitializerProps { children: ReactNode; @@ -11,11 +12,13 @@ interface AuthInitializerProps { const AuthInitializer = (props: AuthInitializerProps) => { const { children } = props; const dispatch = useAppDispatch() + const {initialize} = useGraphManager() + const selectedGraphId = useAppSelector((state) => state.global.selectedGraphId) useEffect(() => { if (typeof window !== "undefined") { const options = getOptionsFromLocal() - const overridenProperties = getOverridenPropertiesFromLocal() + initialize() if (options && options.channel) { dispatch(reset()) dispatch(setOptions(options)) @@ -26,10 +29,15 @@ const AuthInitializer = (props: AuthInitializerProps) => { userId: getRandomUserId(), })) } - dispatch(setOverridenProperties(overridenProperties)) } }, [dispatch]) + useEffect(() => { + if (selectedGraphId) { + dispatch(fetchGraphDetails(selectedGraphId)); + } + }, [selectedGraphId, dispatch]); // Automatically fetch details when `selectedGraphId` changes + return children } diff --git a/playground/src/components/ui/dropdown.tsx b/playground/src/components/ui/dropdown.tsx new file mode 100644 index 00000000..b3586b08 --- /dev/null +++ b/playground/src/components/ui/dropdown.tsx @@ -0,0 +1,111 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; + +import { cn } from "@/lib/utils"; +import { ChevronRightIcon } from "lucide-react"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "start", sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + icon?: React.ReactNode; // Allow passing a custom icon + } +>(({ className, children, icon, ...props }, ref) => ( + + {children} +
+ {icon} +
+
+)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + icon?: React.ReactNode; // Allow passing a custom icon + } +>(({ className, children, icon, ...props }, ref) => ( + + {children} +
+ {icon} +
+
+)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 0, ...props }, ref) => ( + + + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + + + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuPortal, +}; diff --git a/playground/src/middleware.tsx b/playground/src/middleware.tsx index f3577ff3..48d74439 100644 --- a/playground/src/middleware.tsx +++ b/playground/src/middleware.tsx @@ -1,7 +1,5 @@ // middleware.js import { NextRequest, NextResponse } from 'next/server'; -import { startAgent } from './apis/routes'; - const { AGENT_SERVER_URL, TEN_DEV_SERVER_URL } = process.env; @@ -14,20 +12,28 @@ if (!TEN_DEV_SERVER_URL) { throw "Environment variables TEN_DEV_SERVER_URL are not available"; } -export function middleware(req: NextRequest) { +export async function middleware(req: NextRequest) { const { pathname } = req.nextUrl; const url = req.nextUrl.clone(); + if (pathname.startsWith(`/api/agents/`)) { - if (!pathname.startsWith('/api/agents/start')) { - // Proxy all other agents API requests - url.href = `${AGENT_SERVER_URL}${pathname.replace('/api/agents/', '/')}`; + // if (!pathname.startsWith('/api/agents/start')) { + // Proxy all other agents API requests + url.href = `${AGENT_SERVER_URL}${pathname.replace('/api/agents/', '/')}`; - // console.log(`Rewriting request to ${url.href}`); - return NextResponse.rewrite(url); - } else { - return NextResponse.next(); + try { + const body = await req.json(); + console.log(`Request to ${pathname} with body ${JSON.stringify(body)}`); + } catch (e) { + console.log(`Request to ${pathname} ${e}`); } + + // console.log(`Rewriting request to ${url.href}`); + return NextResponse.rewrite(url); + // } else { + // return NextResponse.next(); + // } } else if (pathname.startsWith(`/api/vector/`)) { // Proxy all other documents requests @@ -43,16 +49,12 @@ export function middleware(req: NextRequest) { return NextResponse.rewrite(url); } else if (pathname.startsWith('/api/dev/')) { - // Proxy all other documents requests - const url = req.nextUrl.clone(); - url.href = `${TEN_DEV_SERVER_URL}${pathname.replace('/api/dev/', '/api/dev-server/')}`; - - // console.log(`Rewriting request to ${url.href}`); - return NextResponse.rewrite(url); - } else if (pathname.startsWith('/api/dev/')) { + if (pathname.startsWith('/api/dev/v1/addons/default-properties')) { + url.href = `${AGENT_SERVER_URL}/dev-tmp/addons/default-properties`; + console.log(`Rewriting request to ${url.href}`); + return NextResponse.rewrite(url); + } - // Proxy all other documents requests - const url = req.nextUrl.clone(); url.href = `${TEN_DEV_SERVER_URL}${pathname.replace('/api/dev/', '/api/dev-server/')}`; // console.log(`Rewriting request to ${url.href}`); diff --git a/playground/src/store/reducers/global.ts b/playground/src/store/reducers/global.ts index 9f6e6ee0..a5105a1d 100644 --- a/playground/src/store/reducers/global.ts +++ b/playground/src/store/reducers/global.ts @@ -3,18 +3,22 @@ import { IChatItem, Language, VoiceType, - IAgentSettings, } from "@/types"; -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { EMobileActiveTab, DEFAULT_OPTIONS, COLOR_LIST, setOptionsToLocal, - genRandomChatList, - setOverridenPropertiesToLocal, - deepMerge, + apiReloadPackage, + apiFetchGraphs, + apiFetchInstalledAddons, + apiFetchGraphDetails, + apiUpdateGraph, + apiSaveProperty, } from "@/common"; +import { AddonDef, Graph } from "@/common/graph"; +import { set } from "react-hook-form"; export interface InitialState { options: IOptions; @@ -25,11 +29,10 @@ export interface InitialState { language: Language; voiceType: VoiceType; chatItems: IChatItem[]; - graphName: string; - graphs: string[]; - extensions: Record; - overridenProperties: Record; - extensionMetadata: Record; + selectedGraphId: string; + graphList: string[]; + graphMap: Record; + addonModules: AddonDef.Module[]; // addon modules mobileActiveTab: EMobileActiveTab; } @@ -43,11 +46,10 @@ const getInitialState = (): InitialState => { language: "en-US", voiceType: "male", chatItems: [], - graphName: "camera_va_openai_azure", - graphs: [], - extensions: {}, - overridenProperties: {}, - extensionMetadata: {}, + selectedGraphId: "", + graphList: [], + graphMap: {}, + addonModules: [], mobileActiveTab: EMobileActiveTab.AGENT, }; }; @@ -136,39 +138,11 @@ export const globalSlice = createSlice({ setLanguage: (state, action: PayloadAction) => { state.language = action.payload; }, - setGraphName: (state, action: PayloadAction) => { - state.graphName = action.payload; + setSelectedGraphId: (state, action: PayloadAction) => { + state.selectedGraphId = action.payload; }, - setGraphs: (state, action: PayloadAction) => { - state.graphs = action.payload; - }, - setExtensions: (state, action: PayloadAction>) => { - let { graphName, nodesMap } = action.payload; - state.extensions[graphName] = nodesMap; - }, - setOverridenProperties: ( - state, - action: PayloadAction> - ) => { - state.overridenProperties = action.payload; - setOverridenPropertiesToLocal(state.overridenProperties); - }, - setOverridenPropertiesByGraph: ( - state, - action: PayloadAction> - ) => { - let { graphName, nodesMap } = action.payload; - state.overridenProperties[graphName] = deepMerge( - state.overridenProperties[graphName] || {}, - nodesMap - ); - setOverridenPropertiesToLocal(state.overridenProperties); - }, - setExtensionMetadata: ( - state, - action: PayloadAction> - ) => { - state.extensionMetadata = action.payload; + setGraphList: (state, action: PayloadAction) => { + state.graphList = action.payload; }, setVoiceType: (state, action: PayloadAction) => { state.voiceType = action.payload; @@ -183,9 +157,54 @@ export const globalSlice = createSlice({ COLOR_LIST[0].active ); }, + setGraph: (state, action: PayloadAction) => { + let graphMap = JSON.parse(JSON.stringify(state.graphMap)); + graphMap[action.payload.id] = action.payload; + state.graphMap = graphMap; + }, + setAddonModules: (state, action: PayloadAction[]>) => { + state.addonModules = JSON.parse(JSON.stringify(action.payload)); + } }, }); +// Initialize graph data +export const initializeGraphData = createAsyncThunk( + "global/initializeGraphData", + async (_, { dispatch }) => { + await apiReloadPackage(); + const [fetchedGraphs, modules] = await Promise.all([ + apiFetchGraphs(), + apiFetchInstalledAddons(), + ]); + dispatch(setGraphList(fetchedGraphs.map((graph) => graph.id))); + dispatch(setAddonModules(modules)); + } +); + +// Fetch graph details +export const fetchGraphDetails = createAsyncThunk( + "global/fetchGraphDetails", + async (graphId: string, { dispatch }) => { + const graph = await apiFetchGraphDetails(graphId); + dispatch(setGraph(graph)); + } +); + +// Update a graph +export const updateGraph = createAsyncThunk( + "global/updateGraph", + async ( + { graphId, updates }: { graphId: string; updates: Partial }, + { dispatch } + ) => { + await apiUpdateGraph(graphId, updates); + await apiSaveProperty(); + const updatedGraph = await apiFetchGraphDetails(graphId); + dispatch(setGraph(updatedGraph)); + } +); + export const { reset, setOptions, @@ -196,13 +215,11 @@ export const { addChatItem, setThemeColor, setLanguage, - setGraphName, - setGraphs, - setExtensions, - setExtensionMetadata, - setOverridenProperties, - setOverridenPropertiesByGraph, + setSelectedGraphId, + setGraphList, setMobileActiveTab, + setGraph, + setAddonModules, } = globalSlice.actions; export default globalSlice.reducer; diff --git a/server/internal/code.go b/server/internal/code.go index 8a4b61b2..d518f821 100644 --- a/server/internal/code.go +++ b/server/internal/code.go @@ -23,6 +23,8 @@ var ( codeErrStopWorkerFailed = NewCode("10102", "stop worker failed") codeErrHttpStatusNotOk = NewCode("10103", "http status not 200") codeErrUpdateWorkerFailed = NewCode("10104", "update worker failed") + codeErrReadDirectoryFailed = NewCode("10105", "read directory failed") + codeErrReadFileFailed = NewCode("10106", "read file failed") ) func NewCode(code string, msg string) *Code { diff --git a/server/internal/http_server.go b/server/internal/http_server.go index c9935f72..e1752952 100644 --- a/server/internal/http_server.go +++ b/server/internal/http_server.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "time" @@ -108,6 +109,47 @@ func (s *HttpServer) handlerList(c *gin.Context) { s.output(c, codeSuccess, filtered) } +func (s *HttpServer) handleAddonDefaultProperties(c *gin.Context) { + // Get the base directory path + baseDir := "./agents/ten_packages/extension" + + // Read all folders under the base directory + entries, err := os.ReadDir(baseDir) + if err != nil { + slog.Error("failed to read extension directory", "err", err, logTag) + s.output(c, codeErrReadDirectoryFailed, http.StatusInternalServerError) + return + } + + // Iterate through each folder and read the property.json file + var addons []map[string]interface{} + for _, entry := range entries { + if entry.IsDir() { + addonName := entry.Name() + propertyFilePath := fmt.Sprintf("%s/%s/property.json", baseDir, addonName) + content, err := os.ReadFile(propertyFilePath) + if err != nil { + slog.Warn("failed to read property file", "addon", addonName, "err", err, logTag) + continue + } + + var properties map[string]interface{} + err = json.Unmarshal(content, &properties) + if err != nil { + slog.Warn("failed to parse property file", "addon", addonName, "err", err, logTag) + continue + } + + addons = append(addons, map[string]interface{}{ + "addon": addonName, + "property": properties, + }) + } + } + + s.output(c, codeSuccess, addons) +} + func (s *HttpServer) handlerPing(c *gin.Context) { var req PingReq @@ -145,6 +187,7 @@ func (s *HttpServer) handlerStart(c *gin.Context) { slog.Info("handlerStart start", "workersRunning", workersRunning, logTag) var req StartReq + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { slog.Error("handlerStart params invalid", "err", err, "requestId", req.RequestId, logTag) s.output(c, codeErrParamsInvalid, http.StatusBadRequest) @@ -490,6 +533,51 @@ func (s *HttpServer) processProperty(req *StartReq) (propertyJsonFile string, lo } } + // Validate environment variables in the "nodes" section + envPattern := regexp.MustCompile(`\${env:([^}|]+)}`) + for _, graph := range newGraphs { + graphMap, _ := graph.(map[string]interface{}) + nodes, ok := graphMap["nodes"].([]interface{}) + if !ok { + slog.Info("No nodes section in the graph", "graph", graphName, "requestId", req.RequestId, logTag) + continue + } + for _, node := range nodes { + nodeMap, _ := node.(map[string]interface{}) + properties, ok := nodeMap["property"].(map[string]interface{}) + if !ok { + // slog.Info("No property section in the node", "node", nodeMap, "requestId", req.RequestId, logTag) + continue + } + for key, val := range properties { + strVal, ok := val.(string) + if !ok { + continue + } + // Log the property value being processed + // slog.Info("Processing property", "key", key, "value", strVal) + + matches := envPattern.FindAllStringSubmatch(strVal, -1) + // if len(matches) == 0 { + // slog.Info("No environment variable patterns found in property", "key", key, "value", strVal) + // } + + for _, match := range matches { + if len(match) < 2 { + continue + } + variable := match[1] + exists := os.Getenv(variable) != "" + // slog.Info("Checking environment variable", "variable", variable, "exists", exists) + if !exists { + slog.Error("Environment variable not found", "variable", variable, "property", key, "requestId", req.RequestId, logTag) + } + } + } + + } + } + // Marshal the modified JSON back to a string modifiedPropertyJson, err := json.MarshalIndent(propertyJson, "", " ") if err != nil { @@ -515,6 +603,7 @@ func (s *HttpServer) Start() { r.POST("/start", s.handlerStart) r.POST("/stop", s.handlerStop) r.POST("/ping", s.handlerPing) + r.GET("/dev-tmp/addons/default-properties", s.handleAddonDefaultProperties) r.POST("/token/generate", s.handlerGenerateToken) r.GET("/vector/document/preset/list", s.handlerVectorDocumentPresetList) r.POST("/vector/document/update", s.handlerVectorDocumentUpdate)