diff --git a/.coveragerc b/.coveragerc index 4d69e35a2607..929b9a7c0959 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,3 +3,4 @@ branch = True source = autogen omit = *test* + *samples* diff --git a/samples/apps/autogen-assistant/.gitignore b/samples/apps/autogen-assistant/.gitignore new file mode 100644 index 000000000000..96bf32516424 --- /dev/null +++ b/samples/apps/autogen-assistant/.gitignore @@ -0,0 +1,24 @@ +database.sqlite +.cache/* +autogenra/web/files/user/* +autogenra/web/files/ui/* +OAI_CONFIG_LIST +scratch/ +autogenra/web/workdir/* +autogenra/web/ui/* +autogenra/web/skills/user/* +.release.sh + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ diff --git a/samples/apps/autogen-assistant/MANIFEST.in b/samples/apps/autogen-assistant/MANIFEST.in new file mode 100644 index 000000000000..b552fc42ed86 --- /dev/null +++ b/samples/apps/autogen-assistant/MANIFEST.in @@ -0,0 +1,5 @@ +recursive-include autogenra/web/ui * +recursive-exclude notebooks * +recursive-exclude frontend * +recursive-exclude docs * +recursive-exclude tests * diff --git a/samples/apps/autogen-assistant/README.md b/samples/apps/autogen-assistant/README.md new file mode 100644 index 000000000000..2e4a5ccc529b --- /dev/null +++ b/samples/apps/autogen-assistant/README.md @@ -0,0 +1,119 @@ +# AutoGen Assistant + +![ARA](./docs/ara_stockprices.png) + +AutoGen Assistant is an Autogen-powered AI app (user interface) that can converse with you to help you conduct research, write and execute code, run saved skills, create new skills (explicitly and by demonstration), and adapt in response to your interactions. + +### Capabilities / Roadmap + +Some of the capabilities supported by the app frontend include the following: + +- [x] Select fron a list of agents (current support for two agent workflows - `UserProxyAgent` and `AssistantAgent`) +- [x] Modify agent configuration (e.g. temperature, model, agent system message, model etc) and chat with updated agent configurations. +- [x] View agent messages and output files in the UI from agent runs. +- [ ] Support for more complex agent workflows (e.g. `GroupChat` workflows) +- [ ] Improved user experience (e.g., streaming intermediate model output, better summarization of agent responses, etc) + +Project Structure: + +- _autogenra/_ code for the backend classes and web api (FastAPI) +- _frontend/_ code for the webui, built with Gatsby and Tailwind + +## Getting Started + +AutoGen requires access to an LLM. Please see the [AutoGen docs](https://microsoft.github.io/autogen/docs/FAQ#set-your-api-endpoints) on how to configure access to your LLM provider. In this sample, We recommend setting up your `OPENAI_API_KEY` or `AZURE_OPENAI_API_KEY` environment variable and then specifying the exact model parameters to be used in the `llm_config` that is passed to each agent specification. See the `get_default_agent_config()` method in `utils.py` to see an example of setting up `llm_config`. The example below shows how to configure access to an Azure OPENAI LLM. + +```python +llm_config = LLMConfig( + config_list=[{ + "model": "gpt-4", + "api_key": "", + "api_base": "", + "api_type": "azure", + "api_version": "2023-06-01-preview" + }], + temperature=0, + ) +``` + +```bash +export OPENAI_API_KEY= +``` + +### Install and Run + +To install a prebuilt version of the app from PyPi. We highly recommend using a virtual environment (e.g. miniconda) and **python 3.10+** to avoid dependency conflicts. + +```bash +pip install autogenra +autogenra ui --port 8081 # run the web ui on port 8081 +``` + +### Install from Source + +To install the app from source, clone the repository and install the dependencies. + +```bash +pip install -e . +``` + +You will also need to build the app front end. Note that your Gatsby requires node > 14.15.0 . You may need to [upgrade your node](https://stackoverflow.com/questions/10075990/upgrading-node-js-to-latest-version) version as needed. + +```bash +npm install --global yarn +cd frontend +yarn install +yarn build +``` + +The command above will build the frontend ui and copy the build artifacts to the `autogenra` web ui folder. Note that you may have to run `npm install --force --legacy-peer-deps` to force resolve some peer dependencies. + +Run the web ui: + +```bash +autogenra ui --port 8081 # run the web ui on port 8081 +``` + +Navigate to to view the web ui. + +To update the web ui, navigate to the frontend directory, make changes and rebuild the ui. + +## Capabilities + +This demo focuses on the research assistant use case with some generalizations: + +- **Skills**: The agent is provided with a list of skills that it can leverage while attempting to address a user's query. Each skill is a python function that may be in any file in a folder made availabe to the agents. We separate the concept of global skills available to all agents `backend/files/global_utlis_dir` and user level skills `backend/files/user//utils_dir`, relevant in a multi user environment. Agents are aware skills as they are appended to the system message. A list of example skills is available in the `backend/global_utlis_dir` folder. Modify the file or create a new file with a function in the same directory to create new global skills. + +- **Conversation Persistence**: Conversation history is persisted in an sqlite database `database.sqlite`. + +- **Default Agent Workflow**: The default a sample workflow with two agents - a user proxy agent and an assistant agent. + +## Example Usage + +Let us use a simple query demonstrating the capabilities of the research assistant. + +``` +Plot a chart of NVDA and TESLA stock price YTD. Save the result to a file named nvda_tesla.png +``` + +The agents responds by _writing and executing code_ to create a python program to generate the chart with the stock prices. + +> Note than there could be multiple turns between the `AssistantAgent` and the `UserProxyAgent` to produce and execute the code in order to complete the task. + +![ARA](./docs/ara_stockprices.png) + +> Note: You can also view the debug console that generates useful information to see how the agents are interacting in the background. + + + +## FAQ + +- How do I add more skills to the research assistant? This can be done by adding a new file with documented functions to `autogenra/web/skills/global` directory. +- How do I specify the agent configuration (e.g. temperature, model, agent system message, model etc). You can do either from the UI interface or by modifying the default agent configuration in `utils.py` (`get_default_agent_config()` method) +- How do I reset the conversation? You can reset the conversation by deleting the `database.sqlite` file. You can also delete user files by deleting the `autogenra/web/files/user/` folder. +- How do I view messages generated by agents? You can view the messages generated by the agents in the debug console. You can also view the messages in the `database.sqlite` file. + +## Acknowledgements + +Based on the [AutoGen](https://microsoft.github.io/autogen) project. +Adapted in October 2023 from a research prototype (original credits: Gagan Bansal, Adam Fourney, Victor Dibia, Piali Choudhury, Saleema Amershi, Ahmed Awadallah, Chi Wang) diff --git a/samples/apps/autogen-assistant/autogenra/__init__.py b/samples/apps/autogen-assistant/autogenra/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/samples/apps/autogen-assistant/autogenra/autogenchat.py b/samples/apps/autogen-assistant/autogenra/autogenchat.py new file mode 100644 index 000000000000..b39781b086c7 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/autogenchat.py @@ -0,0 +1,64 @@ +import json +import time +from typing import List +from .datamodel import FlowConfig, Message +from .utils import extract_successful_code_blocks, get_default_agent_config, get_modified_files +from .autogenflow import AutoGenFlow +import os + + +class ChatManager: + def __init__(self) -> None: + pass + + def chat(self, message: Message, history: List, flow_config: FlowConfig = None, **kwargs) -> None: + work_dir = kwargs.get("work_dir", None) + scratch_dir = os.path.join(work_dir, "scratch") + skills_suffix = kwargs.get("skills_prompt", "") + + # if no flow config is provided, use the default + if flow_config is None: + flow_config = get_default_agent_config(scratch_dir, skills_suffix=skills_suffix) + + # print("Flow config: ", flow_config) + flow = AutoGenFlow(config=flow_config, history=history, work_dir=scratch_dir, asst_prompt=skills_suffix) + message_text = message.content.strip() + + output = "" + start_time = time.time() + + metadata = {} + flow.run(message=f"{message_text}", clear_history=False) + + agent_chat_messages = flow.receiver.chat_messages[flow.sender][len(history) :] + metadata["messages"] = agent_chat_messages + + successful_code_blocks = extract_successful_code_blocks(agent_chat_messages) + successful_code_blocks = "\n\n".join(successful_code_blocks) + output = ( + ( + flow.sender.last_message()["content"] + + "\n The following code snippets were used: \n" + + successful_code_blocks + ) + if successful_code_blocks + else flow.sender.last_message()["content"] + ) + + metadata["code"] = "" + end_time = time.time() + metadata["time"] = end_time - start_time + modified_files = get_modified_files(start_time, end_time, scratch_dir, dest_dir=work_dir) + metadata["files"] = modified_files + + print("Modified files: ", modified_files) + + output_message = Message( + user_id=message.user_id, + root_msg_id=message.root_msg_id, + role="assistant", + content=output, + metadata=json.dumps(metadata), + ) + + return output_message diff --git a/samples/apps/autogen-assistant/autogenra/autogenflow.py b/samples/apps/autogen-assistant/autogenra/autogenflow.py new file mode 100644 index 000000000000..731aab63a003 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/autogenflow.py @@ -0,0 +1,134 @@ +from typing import List, Optional +from dataclasses import asdict +import autogen +from .datamodel import AgentFlowSpec, FlowConfig, Message + + +class AutoGenFlow: + """ + AutoGenFlow class to load agents from a provided configuration and run a chat between them + """ + + def __init__( + self, config: FlowConfig, history: Optional[List[Message]] = None, work_dir: str = None, asst_prompt: str = None + ) -> None: + """ + Initializes the AutoGenFlow with agents specified in the config and optional + message history. + + Args: + config: The configuration settings for the sender and receiver agents. + history: An optional list of previous messages to populate the agents' history. + + """ + self.work_dir = work_dir + self.asst_prompt = asst_prompt + self.sender = self.load(config.sender) + self.receiver = self.load(config.receiver) + + if history: + self.populate_history(history) + + def _sanitize_history_message(self, message: str) -> str: + """ + Sanitizes the message e.g. remove references to execution completed + + Args: + message: The message to be sanitized. + + Returns: + The sanitized message. + """ + to_replace = ["execution succeeded", "exitcode"] + for replace in to_replace: + message = message.replace(replace, "") + return message + + def populate_history(self, history: List[Message]) -> None: + """ + Populates the agent message history from the provided list of messages. + + Args: + history: A list of messages to populate the agents' history. + """ + for msg in history: + if isinstance(msg, dict): + msg = Message(**msg) + if msg.role == "user": + self.sender.send( + msg.content, + self.receiver, + request_reply=False, + ) + elif msg.role == "assistant": + self.receiver.send( + msg.content, + self.sender, + request_reply=False, + ) + + def sanitize_agent_spec(self, agent_spec: AgentFlowSpec) -> AgentFlowSpec: + """ + Sanitizes the agent spec by setting loading defaults + + Args: + config: The agent configuration to be sanitized. + agent_type: The type of the agent. + + Returns: + The sanitized agent configuration. + """ + + agent_spec.config.is_termination_msg = agent_spec.config.is_termination_msg or ( + lambda x: "TERMINATE" in x.get("content", "").rstrip() + ) + + if agent_spec.type == "userproxy": + code_execution_config = agent_spec.config.code_execution_config or {} + code_execution_config["work_dir"] = self.work_dir + agent_spec.config.code_execution_config = code_execution_config + if agent_spec.type == "assistant": + agent_spec.config.system_message = ( + autogen.AssistantAgent.DEFAULT_SYSTEM_MESSAGE + + "\n\n" + + agent_spec.config.system_message + + "\n\n" + + self.asst_prompt + ) + + return agent_spec + + def load(self, agent_spec: AgentFlowSpec) -> autogen.Agent: + """ + Loads an agent based on the provided agent specification. + + Args: + agent_spec: The specification of the agent to be loaded. + + Returns: + An instance of the loaded agent. + """ + agent: autogen.Agent + agent_spec = self.sanitize_agent_spec(agent_spec) + if agent_spec.type == "assistant": + agent = autogen.AssistantAgent(**asdict(agent_spec.config)) + elif agent_spec.type == "userproxy": + agent = autogen.UserProxyAgent(**asdict(agent_spec.config)) + else: + raise ValueError(f"Unknown agent type: {agent_spec.type}") + return agent + + def run(self, message: str, clear_history: bool = False) -> None: + """ + Initiates a chat between the sender and receiver agents with an initial message + and an option to clear the history. + + Args: + message: The initial message to start the chat. + clear_history: If set to True, clears the chat history before initiating. + """ + self.sender.initiate_chat( + self.receiver, + message=message, + clear_history=clear_history, + ) diff --git a/samples/apps/autogen-assistant/autogenra/cli.py b/samples/apps/autogen-assistant/autogenra/cli.py new file mode 100644 index 000000000000..07a8c489424b --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/cli.py @@ -0,0 +1,48 @@ +import os +from typing_extensions import Annotated +import typer +import uvicorn + +from .version import VERSION + +app = typer.Typer() + + +@app.command() +def ui( + host: str = "127.0.0.1", + port: int = 8081, + workers: int = 1, + reload: Annotated[bool, typer.Option("--reload")] = False, + docs: bool = False, +): + """ + Launch the Autogen RA UI CLI .Pass in parameters host, port, workers, and reload to override the default values. + """ + + os.environ["AUTOGENUI_API_DOCS"] = str(docs) + + uvicorn.run( + "autogenra.web.app:app", + host=host, + port=port, + workers=workers, + reload=reload, + ) + + +@app.command() +def version(): + """ + Print the version of the Autogen RA UI CLI. + """ + + typer.echo(f"Autogen RA UI CLI version: {VERSION}") + + +def run(): + app() + + +if __name__ == "__main__": + app() diff --git a/samples/apps/autogen-assistant/autogenra/datamodel.py b/samples/apps/autogen-assistant/autogenra/datamodel.py new file mode 100644 index 000000000000..7c9294569bf8 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/datamodel.py @@ -0,0 +1,120 @@ +import uuid +from datetime import datetime +from typing import Any, Callable, Dict, List, Literal, Optional, Union +from pydantic.dataclasses import dataclass +from dataclasses import field + + +@dataclass +class Message(object): + user_id: str + role: str + content: str + root_msg_id: Optional[str] = None + msg_id: Optional[str] = None + timestamp: Optional[datetime] = None + personalize: Optional[bool] = False + ra: Optional[str] = None + code: Optional[str] = None + metadata: Optional[Any] = None + + def __post_init__(self): + if self.msg_id is None: + self.msg_id = str(uuid.uuid4()) + if self.timestamp is None: + self.timestamp = datetime.now() + + def dict(self): + return { + "user_id": self.user_id, + "role": self.role, + "content": self.content, + "root_msg_id": self.root_msg_id, + "msg_id": self.msg_id, + "timestamp": self.timestamp, + "personalize": self.personalize, + "ra": self.ra, + "code": self.code, + "metadata": self.metadata, + } + + +# web api data models + + +# autogenflow data models +@dataclass +class ModelConfig: + """Data model for Model Config item in LLMConfig for Autogen""" + + model: str + api_key: Optional[str] = None + base_url: Optional[str] = None + api_type: Optional[str] = None + api_version: Optional[str] = None + + +@dataclass +class LLMConfig: + """Data model for LLM Config for Autogen""" + + config_list: List[Any] = field(default_factory=List) + temperature: float = 0 + cache_seed: Optional[Union[int, None]] = None + timeout: Optional[int] = None + + +@dataclass +class AgentConfig: + """Data model for Agent Config for Autogen""" + + name: str + llm_config: Optional[LLMConfig] = None + human_input_mode: str = "NEVER" + max_consecutive_auto_reply: int = 10 + system_message: Optional[str] = None + is_termination_msg: Optional[Union[bool, str, Callable]] = None + code_execution_config: Optional[Union[bool, str, Dict[str, Any]]] = None + + +@dataclass +class AgentFlowSpec: + """Data model to help flow load agents from config""" + + type: Literal["assistant", "userproxy", "groupchat"] + config: AgentConfig = field(default_factory=AgentConfig) + + +@dataclass +class FlowConfig: + """Data model for Flow Config for Autogen""" + + name: str + sender: AgentFlowSpec + receiver: Union[AgentFlowSpec, List[AgentFlowSpec]] + type: Literal["default", "groupchat"] = "default" + + +@dataclass +class ChatWebRequestModel(object): + """Data model for Chat Web Request for Web End""" + + message: Message + flow_config: FlowConfig + + +@dataclass +class DeleteMessageWebRequestModel(object): + user_id: str + msg_id: str + + +@dataclass +class ClearDBWebRequestModel(object): + user_id: str + + +@dataclass +class CreateSkillWebRequestModel(object): + user_id: str + skills: Union[str, List[str]] diff --git a/samples/apps/autogen-assistant/autogenra/db.py b/samples/apps/autogen-assistant/autogenra/db.py new file mode 100644 index 000000000000..235cd789f1d5 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/db.py @@ -0,0 +1,114 @@ +import logging +import sqlite3 +import threading +import os +from typing import Any, List, Dict, Tuple + +lock = threading.Lock() +logger = logging.getLogger() + + +class DBManager: + """ + A database manager class that handles the creation and interaction with an SQLite database. + """ + + def __init__(self, path: str = "database.sqlite", **kwargs: Any) -> None: + """ + Initializes the DBManager object, creates a database if it does not exist, and establishes a connection. + + Args: + path (str): The file path to the SQLite database file. + **kwargs: Additional keyword arguments to pass to the sqlite3.connect method. + """ + self.path = path + # check if the database exists, if not create it + if not os.path.exists(self.path): + logger.info("Creating database") + self.init_db(path=self.path, **kwargs) + + try: + self.conn = sqlite3.connect(self.path, check_same_thread=False, **kwargs) + self.cursor = self.conn.cursor() + except Exception as e: + logger.error("Error connecting to database: %s", e) + raise e + + def init_db(self, path: str = "database.sqlite", **kwargs: Any) -> None: + """ + Initializes the database by creating necessary tables. + + Args: + path (str): The file path to the SQLite database file. + **kwargs: Additional keyword arguments to pass to the sqlite3.connect method. + """ + # Connect to the database (or create a new one if it doesn't exist) + self.conn = sqlite3.connect(path, check_same_thread=False, **kwargs) + self.cursor = self.conn.cursor() + + # Create the table with the specified columns, appropriate data types, and a UNIQUE constraint on (root_msg_id, msg_id) + self.cursor.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + user_id TEXT NOT NULL, + root_msg_id TEXT NOT NULL, + msg_id TEXT, + role TEXT NOT NULL, + content TEXT NOT NULL, + metadata TEXT, + timestamp DATETIME, + UNIQUE (user_id, root_msg_id, msg_id) + ) + """ + ) + + # Create a table for personalization profiles + self.cursor.execute( + """ + CREATE TABLE IF NOT EXISTS personalization_profiles ( + user_id INTEGER NOT NULL, + profile TEXT, + timestamp DATETIME NOT NULL, + UNIQUE (user_id) + ) + """ + ) + + # Commit the changes and close the connection + self.conn.commit() + + def query(self, query: str, args: Tuple = (), json: bool = False) -> List[Dict[str, Any]]: + """ + Executes a given SQL query and returns the results. + + Args: + query (str): The SQL query to execute. + args (Tuple): The arguments to pass to the SQL query. + json (bool): If True, the results will be returned as a list of dictionaries. + + Returns: + List[Dict[str, Any]]: The result of the SQL query. + """ + try: + with lock: + self.cursor.execute(query, args) + result = self.cursor.fetchall() + self.commit() + if json: + result = [dict(zip([key[0] for key in self.cursor.description], row)) for row in result] + return result + except Exception as e: + logger.error("Error running query with query %s and args %s: %s", query, args, e) + raise e + + def commit(self) -> None: + """ + Commits the current transaction to the database. + """ + self.conn.commit() + + def close(self) -> None: + """ + Closes the database connection. + """ + self.conn.close() diff --git a/samples/apps/autogen-assistant/autogenra/utils.py b/samples/apps/autogen-assistant/autogenra/utils.py new file mode 100644 index 000000000000..a482e80694d1 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/utils.py @@ -0,0 +1,406 @@ +import ast +import hashlib +from typing import List, Dict, Union +import os +import shutil +import re +import uuid +import autogen +from .datamodel import AgentConfig, AgentFlowSpec, FlowConfig, LLMConfig, Message +from .db import DBManager + + +def md5_hash(text: str) -> str: + """ + Compute the MD5 hash of a given text. + + :param text: The string to hash + :return: The MD5 hash of the text + """ + return hashlib.md5(text.encode()).hexdigest() + + +def save_message(message: Message, dbmanager: DBManager) -> None: + """ + Save a message in the database using the provided database manager. + + :param message: The Message object containing message data + :param dbmanager: The DBManager instance used to interact with the database + """ + query = "INSERT INTO messages (user_id, root_msg_id, msg_id, role, content, metadata, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)" + args = ( + message.user_id, + message.root_msg_id, + message.msg_id, + message.role, + message.content, + message.metadata, + message.timestamp, + ) + dbmanager.query(query=query, args=args) + + +def load_messages(user_id: str, dbmanager: DBManager) -> List[dict]: + """ + Load messages for a specific user from the database, sorted by timestamp. + + :param user_id: The ID of the user whose messages are to be loaded + :param dbmanager: The DBManager instance to interact with the database + :return: A list of dictionaries, each representing a message + """ + query = "SELECT * FROM messages WHERE user_id = ?" + args = (user_id,) + result = dbmanager.query(query=query, args=args, json=True) + # Sort by timestamp ascending + result = sorted(result, key=lambda k: k["timestamp"], reverse=False) + return result + + +def delete_message(user_id: str, msg_id: str, dbmanager: DBManager, delete_all: bool = False) -> List[dict]: + """ + Delete a specific message or all messages for a user from the database. + + :param user_id: The ID of the user whose messages are to be deleted + :param msg_id: The ID of the specific message to be deleted (ignored if delete_all is True) + :param dbmanager: The DBManager instance to interact with the database + :param delete_all: If True, all messages for the user will be deleted + :return: A list of the remaining messages if not all were deleted, otherwise an empty list + """ + if delete_all: + query = "DELETE FROM messages WHERE user_id = ?" + args = (user_id,) + dbmanager.query(query=query, args=args) + return [] + else: + query = "DELETE FROM messages WHERE user_id = ? AND msg_id = ?" + args = (user_id, msg_id) + dbmanager.query(query=query, args=args) + messages = load_messages(user_id=user_id, dbmanager=dbmanager) + + return messages + + +def get_modified_files(start_timestamp: float, end_timestamp: float, source_dir: str, dest_dir: str) -> List[str]: + """ + Copy files from source_dir that were modified within a specified timestamp range + to dest_dir, renaming files if they already exist there. The function excludes + files with certain file extensions and names. + + :param start_timestamp: The start timestamp to filter modified files. + :param end_timestamp: The end timestamp to filter modified files. + :param source_dir: The directory to search for modified files. + :param dest_dir: The destination directory to copy modified files to. + + :return: A list of file paths in dest_dir that were modified and copied over. + Files with extensions "__pycache__", "*.pyc", "__init__.py", and "*.cache" + are ignored. + """ + modified_files = [] + ignore_extensions = {".pyc", ".cache"} + ignore_files = {"__pycache__", "__init__.py"} + + for root, dirs, files in os.walk(source_dir): + # Excluding the directory "__pycache__" if present + dirs[:] = [d for d in dirs if d not in ignore_files] + + for file in files: + file_path = os.path.join(root, file) + file_ext = os.path.splitext(file)[1] + file_name = os.path.basename(file) + + if file_ext in ignore_extensions or file_name in ignore_files: + continue + + file_mtime = os.path.getmtime(file_path) + if start_timestamp < file_mtime < end_timestamp: + dest_file_path = os.path.join(dest_dir, file) + copy_idx = 1 + while os.path.exists(dest_file_path): + base, extension = os.path.splitext(file) + # Handling potential name conflicts by appending a number + dest_file_path = os.path.join(dest_dir, f"{base}_{copy_idx}{extension}") + copy_idx += 1 + + # Copying the modified file to the destination directory + shutil.copy2(file_path, dest_file_path) + uid = dest_dir.split("/")[-1] + print("******", uid) + file_path = f"files/user/{uid}/{dest_file_path.split('/')[-1]}" + modified_files.append(file_path) + + return modified_files + + +def init_webserver_folders(root_file_path: str) -> Dict[str, str]: + """ + Initialize folders needed for a web server, such as static file directories + and user-specific data directories. + + :param root_file_path: The root directory where webserver folders will be created + :return: A dictionary with the path of each created folder + """ + files_static_root = os.path.join(root_file_path, "files/") + static_folder_root = os.path.join(root_file_path, "ui") + workdir_root = os.path.join(root_file_path, "workdir") + skills_dir = os.path.join(root_file_path, "skills") + user_skills_dir = os.path.join(skills_dir, "user") + global_skills_dir = os.path.join(skills_dir, "global") + + os.makedirs(files_static_root, exist_ok=True) + os.makedirs(os.path.join(files_static_root, "user"), exist_ok=True) + os.makedirs(static_folder_root, exist_ok=True) + os.makedirs(workdir_root, exist_ok=True) + os.makedirs(skills_dir, exist_ok=True) + os.makedirs(user_skills_dir, exist_ok=True) + os.makedirs(global_skills_dir, exist_ok=True) + + folders = { + "files_static_root": files_static_root, + "static_folder_root": static_folder_root, + "workdir_root": workdir_root, + "skills_dir": skills_dir, + "user_skills_dir": user_skills_dir, + "global_skills_dir": global_skills_dir, + } + return folders + + +def skill_from_folder(folder: str) -> List[Dict[str, str]]: + """ + Given a folder, return a dict of the skill (name, python file content). Only python files are considered. + + :param folder: The folder to search for skills + :return: A list of dictionaries, each representing a skill + """ + + skills = [] + for root, dirs, files in os.walk(folder): + for file in files: + if file.endswith(".py"): + skill_name = file.split(".")[0] + skill_file_path = os.path.join(root, file) + with open(skill_file_path, "r", encoding="utf-8") as f: + skill_content = f.read() + skills.append({"name": skill_name, "content": skill_content, "file_name": file}) + return skills + + +def get_all_skills(user_skills_path: str, global_skills_path: str, dest_dir: str = None) -> List[Dict[str, str]]: + """ + Get all skills from the user and global skills directories. If dest_dir, copy all skills to dest_dir. + + :param user_skills_path: The path to the user skills directory + :param global_skills_path: The path to the global skills directory + :param dest_dir: The destination directory to copy all skills to + :return: A dictionary of user and global skills + """ + user_skills = skill_from_folder(user_skills_path) + os.makedirs(user_skills_path, exist_ok=True) + global_skills = skill_from_folder(global_skills_path) + skills = { + "user": user_skills, + "global": global_skills, + } + + if dest_dir: + # chcek if dest_dir exists + if not os.path.exists(dest_dir): + os.makedirs(dest_dir) + # copy all skills to dest_dir + for skill in user_skills + global_skills: + skill_file_path = os.path.join(dest_dir, skill["file_name"]) + with open(skill_file_path, "w", encoding="utf-8") as f: + f.write(skill["content"]) + + return skills + + +def get_skills_prompt(skills: List[Dict[str, str]]) -> str: + """ + Get a prompt with the content of all skills. + + :param skills: A dictionary of user and global skills + :return: A string containing the content of all skills + """ + user_skills = skills["user"] + global_skills = skills["global"] + all_skills = user_skills + global_skills + + prompt = """ + +While solving the task you may use functions in the files below. +To use a function from a file in code, import the file and then use the function. +If you need to install python packages, write shell code to +install via pip and use --quiet option. + + """ + for skill in all_skills: + prompt += f""" + +##### Begin of {skill["file_name"]} ##### + +{skill["content"]} + +#### End of {skill["file_name"]} #### + + """ + + return prompt + + +def delete_files_in_folder(folders: Union[str, List[str]]) -> None: + """ + Delete all files and directories in the specified folders. + + :param folders: A list of folders or a single folder string + """ + + if isinstance(folders, str): + folders = [folders] + + for folder in folders: + # Check if the folder exists + if not os.path.isdir(folder): + print(f"The folder {folder} does not exist.") + continue + + # List all the entries in the directory + for entry in os.listdir(folder): + # Get the full path + path = os.path.join(folder, entry) + try: + if os.path.isfile(path) or os.path.islink(path): + # Remove the file or link + os.remove(path) + elif os.path.isdir(path): + # Remove the directory and all its content + shutil.rmtree(path) + except Exception as e: + # Print the error message and skip + print(f"Failed to delete {path}. Reason: {e}") + + +def get_default_agent_config(work_dir: str, skills_suffix: str = "") -> FlowConfig: + """ + Get a default agent flow config . + """ + + llm_config = LLMConfig( + config_list=[{"model": "gpt-4"}], + temperature=0, + ) + + USER_PROXY_INSTRUCTIONS = """If the request has been addressed sufficiently, summarize the answer and end with the word TERMINATE. Otherwise, ask a follow-up question. + """ + + userproxy_spec = AgentFlowSpec( + type="userproxy", + config=AgentConfig( + name="user_proxy", + human_input_mode="NEVER", + system_message=USER_PROXY_INSTRUCTIONS, + code_execution_config={ + "work_dir": work_dir, + "use_docker": False, + }, + max_consecutive_auto_reply=10, + llm_config=llm_config, + is_termination_msg=lambda x: x.get("content", "").rstrip().endswith("TERMINATE"), + ), + ) + + assistant_spec = AgentFlowSpec( + type="assistant", + config=AgentConfig( + name="primary_assistant", + system_message=autogen.AssistantAgent.DEFAULT_SYSTEM_MESSAGE + skills_suffix, + llm_config=llm_config, + ), + ) + + flow_config = FlowConfig( + name="default", + sender=userproxy_spec, + receiver=assistant_spec, + type="default", + ) + + return flow_config + + +def extract_successful_code_blocks(messages: List[Dict[str, str]]) -> List[str]: + """ + Parses through a list of messages containing code blocks and execution statuses, + returning the array of code blocks that executed successfully and retains + the backticks for Markdown rendering. + + Parameters: + messages (List[Dict[str, str]]): A list of message dictionaries containing 'content' and 'role' keys. + + Returns: + List[str]: A list containing the code blocks that were successfully executed, including backticks. + """ + successful_code_blocks = [] + code_block_regex = r"```[\s\S]*?```" # Regex pattern to capture code blocks enclosed in triple backticks. + + for i, message in enumerate(messages): + if message["role"] == "user" and "execution succeeded" in message["content"]: + if i > 0 and messages[i - 1]["role"] == "assistant": + prev_content = messages[i - 1]["content"] + # Find all matches for code blocks + code_blocks = re.findall(code_block_regex, prev_content) + successful_code_blocks.extend(code_blocks) # Add the code blocks with backticks + + return successful_code_blocks + + +def create_skills_from_code(dest_dir: str, skills: Union[str, List[str]]) -> None: + """ + Create skills from a list of code blocks. + Parameters: + dest_dir (str): The destination directory to copy all skills to. + skills (Union[str, List[str]]): A list of strings containing code blocks. + """ + + # Ensure skills is a list + if isinstance(skills, str): + skills = [skills] + + # Check if dest_dir exists + if not os.path.exists(dest_dir): + os.makedirs(dest_dir) + + for skill in skills: + # Attempt to parse the code and extract the top-level function name + try: + parsed = ast.parse(skill) + function_name = None + for node in parsed.body: + if isinstance(node, ast.FunctionDef): + function_name = node.name + break + + if function_name is None: + raise ValueError("No top-level function definition found.") + + # Sanitize the function name for use as a file name + function_name = "".join(ch for ch in function_name if ch.isalnum() or ch == "_") + skill_file_name = f"{function_name}.py" + + except (ValueError, SyntaxError): + skill_file_name = "new_skill.py" + + # If the generated/sanitized name already exists, append an index + skill_file_path = os.path.join(dest_dir, skill_file_name) + index = 1 + while os.path.exists(skill_file_path): + base, ext = os.path.splitext(skill_file_name) + if base.endswith(f"_{index - 1}"): + base = base.rsplit("_", 1)[0] + + skill_file_path = os.path.join(dest_dir, f"{base}_{index}{ext}") + index += 1 + + # Write the skill to the file + with open(skill_file_path, "w", encoding="utf-8") as f: + f.write(skill) diff --git a/samples/apps/autogen-assistant/autogenra/version.py b/samples/apps/autogen-assistant/autogenra/version.py new file mode 100644 index 000000000000..76450728fd96 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/version.py @@ -0,0 +1,2 @@ +VERSION = "0.0.05a" +APP_NAME = "autogenra" diff --git a/samples/apps/autogen-assistant/autogenra/web/__init__.py b/samples/apps/autogen-assistant/autogenra/web/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/samples/apps/autogen-assistant/autogenra/web/app.py b/samples/apps/autogen-assistant/autogenra/web/app.py new file mode 100644 index 000000000000..47617db09bf0 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/web/app.py @@ -0,0 +1,204 @@ +import json +import os +import traceback +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi import HTTPException +from ..db import DBManager +from ..datamodel import ( + ChatWebRequestModel, + ClearDBWebRequestModel, + CreateSkillWebRequestModel, + DeleteMessageWebRequestModel, + Message, +) +from ..autogenchat import ChatManager +from ..utils import ( + create_skills_from_code, + delete_files_in_folder, + get_all_skills, + load_messages, + md5_hash, + save_message, + delete_message, + init_webserver_folders, + get_skills_prompt, +) + +app = FastAPI() + + +# allow cross origin requests for testing on localhost:800* ports only +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:8000", + "http://127.0.0.1:8000", + "http://localhost:8001", + "http://localhost:8081", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +root_file_path = os.path.dirname(os.path.abspath(__file__)) +folders = init_webserver_folders(root_file_path) # init folders skills, workdir, static, files etc + +api = FastAPI(root_path="/api") +# mount an api route such that the main route serves the ui and the /api +app.mount("/api", api) + +app.mount("/", StaticFiles(directory=folders["static_folder_root"], html=True), name="ui") +api.mount("/files", StaticFiles(directory=folders["files_static_root"], html=True), name="files") + + +db_path = os.path.join(root_file_path, "database.sqlite") +dbmanager = DBManager(path=db_path) # manage database operations +chatmanager = ChatManager() # manage calls to autogen + + +@api.post("/messages") +async def add_message(req: ChatWebRequestModel): + message = Message(**req.message.dict()) + user_history = load_messages(user_id=message.user_id, dbmanager=dbmanager) + + # save incoming message to db + save_message(message=message, dbmanager=dbmanager) + user_dir = os.path.join(folders["files_static_root"], "user", md5_hash(message.user_id)) + os.makedirs(user_dir, exist_ok=True) + + # load skills, append to chat + skills = get_all_skills( + os.path.join(folders["user_skills_dir"], md5_hash(message.user_id)), + folders["global_skills_dir"], + dest_dir=os.path.join(user_dir, "scratch"), + ) + skills_prompt = get_skills_prompt(skills) + + try: + response_message: Message = chatmanager.chat( + message=message, + history=user_history, + work_dir=user_dir, + skills_prompt=skills_prompt, + flow_config=req.flow_config, + ) + + # save assistant response to db + save_message(message=response_message, dbmanager=dbmanager) + response = { + "status": True, + "message": response_message.content, + "metadata": json.loads(response_message.metadata), + } + return response + except Exception as ex_error: + print(traceback.format_exc()) + return { + "status": False, + "message": "Error occurred while processing message: " + str(ex_error), + } + + +@api.get("/messages") +def get_messages(user_id: str = None): + if user_id is None: + raise HTTPException(status_code=400, detail="user_id is required") + user_history = load_messages(user_id=user_id, dbmanager=dbmanager) + + return { + "status": True, + "data": user_history, + "message": "Messages retrieved successfully", + } + + +@api.post("/messages/delete") +async def remove_message(req: DeleteMessageWebRequestModel): + """Delete a message from the database""" + + try: + messages = delete_message(user_id=req.user_id, msg_id=req.msg_id, dbmanager=dbmanager) + return { + "status": True, + "message": "Message deleted successfully", + "data": messages, + } + except Exception as ex_error: + print(ex_error) + return { + "status": False, + "message": "Error occurred while deleting message: " + str(ex_error), + } + + +@api.post("/cleardb") +async def clear_db(req: ClearDBWebRequestModel): + """Clear user conversation history database and files""" + + user_files_dir = os.path.join(folders["files_static_root"], "user", md5_hash(req.user_id)) + # user_skills_dir = os.path.join(folders["user_skills_dir"], md5_hash(req.user_id)) + + delete_files_in_folder([user_files_dir]) + + try: + delete_message(user_id=req.user_id, msg_id=None, dbmanager=dbmanager, delete_all=True) + return { + "status": True, + "message": "Messages and files cleared successfully", + } + except Exception as ex_error: + print(ex_error) + return { + "status": False, + "message": "Error occurred while deleting message: " + str(ex_error), + } + + +@api.get("/skills") +def get_skills(user_id: str): + skills = get_all_skills(os.path.join(folders["user_skills_dir"], md5_hash(user_id)), folders["global_skills_dir"]) + + return { + "status": True, + "message": "Skills retrieved successfully", + "skills": skills, + } + + +@api.post("/skills") +def create_user_skills(req: CreateSkillWebRequestModel): + """_summary_ + + Args: + user_id (str): the user id + code (str): code that represents the skill to be created + + Returns: + _type_: dict + """ + + user_skills_dir = os.path.join(folders["user_skills_dir"], md5_hash(req.user_id)) + + try: + create_skills_from_code(dest_dir=user_skills_dir, skills=req.skills) + + skills = get_all_skills( + os.path.join(folders["user_skills_dir"], md5_hash(req.user_id)), folders["global_skills_dir"] + ) + + return { + "status": True, + "message": "Skills retrieved successfully", + "skills": skills, + } + + except Exception as ex_error: + print(ex_error) + return { + "status": False, + "message": "Error occurred while creating skills: " + str(ex_error), + } diff --git a/samples/apps/autogen-assistant/autogenra/web/skills/global/fetch_profile.py b/samples/apps/autogen-assistant/autogenra/web/skills/global/fetch_profile.py new file mode 100644 index 000000000000..be82545a47ab --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/web/skills/global/fetch_profile.py @@ -0,0 +1,35 @@ +from typing import Optional +import requests +from bs4 import BeautifulSoup + + +def fetch_user_profile(url: str) -> Optional[str]: + """ + Fetches the text content from a personal website. + + Given a URL of a person's personal website, this function scrapes + the content of the page and returns the text found within the . + + Args: + url (str): The URL of the person's personal website. + + Returns: + Optional[str]: The text content of the website's body, or None if any error occurs. + """ + try: + # Send a GET request to the URL + response = requests.get(url) + # Check for successful access to the webpage + if response.status_code == 200: + # Parse the HTML content of the page using BeautifulSoup + soup = BeautifulSoup(response.text, "html.parser") + # Extract the content of the tag + body_content = soup.find("body") + # Return all the text in the body tag, stripping leading/trailing whitespaces + return " ".join(body_content.stripped_strings) if body_content else None + else: + # Return None if the status code isn't 200 (success) + return None + except requests.RequestException: + # Return None if any request-related exception is caught + return None diff --git a/samples/apps/autogen-assistant/autogenra/web/skills/global/find_papers_arxiv.py b/samples/apps/autogen-assistant/autogenra/web/skills/global/find_papers_arxiv.py new file mode 100644 index 000000000000..3a4359245af7 --- /dev/null +++ b/samples/apps/autogen-assistant/autogenra/web/skills/global/find_papers_arxiv.py @@ -0,0 +1,73 @@ +import os +import re +import json +import hashlib + + +def search_arxiv(query, max_results=10): + """ + Searches arXiv for the given query using the arXiv API, then returns the search results. This is a helper function. In most cases, callers will want to use 'find_relevant_papers( query, max_results )' instead. + + Args: + query (str): The search query. + max_results (int, optional): The maximum number of search results to return. Defaults to 10. + + Returns: + jresults (list): A list of dictionaries. Each dictionary contains fields such as 'title', 'authors', 'summary', and 'pdf_url' + + Example: + >>> results = search_arxiv("attention is all you need") + >>> print(results) + """ + + import arxiv + + key = hashlib.md5(("search_arxiv(" + str(max_results) + ")" + query).encode("utf-8")).hexdigest() + # Create the cache if it doesn't exist + cache_dir = ".cache" + if not os.path.isdir(cache_dir): + os.mkdir(cache_dir) + + fname = os.path.join(cache_dir, key + ".cache") + + # Cache hit + if os.path.isfile(fname): + fh = open(fname, "r", encoding="utf-8") + data = json.loads(fh.read()) + fh.close() + return data + + # Normalize the query, removing operator keywords + query = re.sub(r"[^\s\w]", " ", query.lower()) + query = re.sub(r"\s(and|or|not)\s", " ", " " + query + " ") + query = re.sub(r"[^\s\w]", " ", query.lower()) + query = re.sub(r"\s+", " ", query).strip() + + search = arxiv.Search(query=query, max_results=max_results, sort_by=arxiv.SortCriterion.Relevance) + + jresults = list() + for result in search.results(): + r = dict() + r["entry_id"] = result.entry_id + r["updated"] = str(result.updated) + r["published"] = str(result.published) + r["title"] = result.title + r["authors"] = [str(a) for a in result.authors] + r["summary"] = result.summary + r["comment"] = result.comment + r["journal_ref"] = result.journal_ref + r["doi"] = result.doi + r["primary_category"] = result.primary_category + r["categories"] = result.categories + r["links"] = [str(link) for link in result.links] + r["pdf_url"] = result.pdf_url + jresults.append(r) + + if len(jresults) > max_results: + jresults = jresults[0:max_results] + + # Save to cache + fh = open(fname, "w") + fh.write(json.dumps(jresults)) + fh.close() + return jresults diff --git a/samples/apps/autogen-assistant/docs/ara_stockprices.png b/samples/apps/autogen-assistant/docs/ara_stockprices.png new file mode 100644 index 000000000000..bfafe3b5b4a0 Binary files /dev/null and b/samples/apps/autogen-assistant/docs/ara_stockprices.png differ diff --git a/samples/apps/autogen-assistant/frontend/.env.default b/samples/apps/autogen-assistant/frontend/.env.default new file mode 100644 index 000000000000..da3ebffaa289 --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/.env.default @@ -0,0 +1,5 @@ + # use this for .env.development assuming your backend is running on port 8081 +GATSBY_API_URL=http://127.0.0.1:8081/api + +# use this .env.production assuming your backend is running on same port as frontend. Remember toremove these comments. +GATSBY_API_URL=/api diff --git a/samples/apps/autogen-assistant/frontend/.gitignore b/samples/apps/autogen-assistant/frontend/.gitignore new file mode 100644 index 000000000000..8a0ea868f24b --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.cache/ +public/ + +.env.development +.env.production + +yarn.lock diff --git a/samples/apps/autogen-assistant/frontend/LICENSE b/samples/apps/autogen-assistant/frontend/LICENSE new file mode 100644 index 000000000000..16ab6489c8ed --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Victor Dibia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/samples/apps/autogen-assistant/frontend/README.md b/samples/apps/autogen-assistant/frontend/README.md new file mode 100644 index 000000000000..7af58ee311ec --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/README.md @@ -0,0 +1,30 @@ +## 🚀 Running UI in Dev Mode + +Run the UI in dev mode (make changes and see them reflected in the browser with hotreloading): + +- npm install +- npm run start + +This should start the server on port 8000. + +## Design Elements + +- **Gatsby**: The app is created in Gatsby. A guide on bootstrapping a Gatsby app can be found here - https://www.gatsbyjs.com/docs/quick-start/. + This provides an overview of the project file structure include functionality of files like `gatsby-config.js`, `gatsby-node.js`, `gatsby-browser.js` and `gatsby-ssr.js`. +- **TailwindCSS**: The app uses TailwindCSS for styling. A guide on using TailwindCSS with Gatsby can be found here - https://tailwindcss.com/docs/guides/gatsby.https://tailwindcss.com/docs/guides/gatsby . This will explain the functionality in tailwind.config.js and postcss.config.js. + +## Modifying the UI, Adding Pages + +The core of the app can be found in the `src` folder. To add pages, add a new folder in `src/pages` and add a `index.js` file. This will be the entry point for the page. For example to add a route in the app like `/about`, add a folder `about` in `src/pages` and add a `index.tsx` file. You can follow the content style in `src/pages/index.tsx` to add content to the page. + +Core logic for each component should be written in the `src/components` folder and then imported in pages as needed. + +## connecting to front end + +the front end makes request to the backend api and expects it at /api on localhost port 8081 + +## setting env variables for the UI + +- please look at env.default +- make a copy of this file and name it `env.development` +- set the values for the variables in this file diff --git a/samples/apps/autogen-assistant/frontend/gatsby-browser.js b/samples/apps/autogen-assistant/frontend/gatsby-browser.js new file mode 100644 index 000000000000..b28e798f0d41 --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/gatsby-browser.js @@ -0,0 +1,6 @@ +import "antd/dist/reset.css"; +import "./src/styles/global.css"; + +import AuthProvider from "./src/hooks/provider"; + +export const wrapRootElement = AuthProvider; diff --git a/samples/apps/autogen-assistant/frontend/gatsby-config.ts b/samples/apps/autogen-assistant/frontend/gatsby-config.ts new file mode 100644 index 000000000000..d65fbab4f926 --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/gatsby-config.ts @@ -0,0 +1,52 @@ +import type { GatsbyConfig } from "gatsby"; + +require("dotenv").config({ + path: `.env.${process.env.NODE_ENV}`, +}); + +const config: GatsbyConfig = { + pathPrefix: `${process.env.PREFIX_PATH_VALUE}`, + siteMetadata: { + title: `AutoGen Assistant`, + description: `Build LLM Enabled Agents`, + siteUrl: `http://tbd.place`, + }, + flags: { + LAZY_IMAGES: true, + FAST_DEV: true, + DEV_SSR: false, + }, + plugins: [ + "gatsby-plugin-sass", + "gatsby-plugin-image", + "gatsby-plugin-sitemap", + "gatsby-plugin-postcss", + { + resolve: "gatsby-plugin-manifest", + options: { + icon: "src/images/icon.png", + }, + }, + "gatsby-plugin-mdx", + "gatsby-plugin-sharp", + "gatsby-transformer-sharp", + { + resolve: "gatsby-source-filesystem", + options: { + name: "images", + path: "./src/images/", + }, + __key: "images", + }, + { + resolve: "gatsby-source-filesystem", + options: { + name: "pages", + path: "./src/pages/", + }, + __key: "pages", + }, + ], +}; + +export default config; diff --git a/samples/apps/autogen-assistant/frontend/gatsby-ssr.tsx b/samples/apps/autogen-assistant/frontend/gatsby-ssr.tsx new file mode 100644 index 000000000000..7601c31d03ca --- /dev/null +++ b/samples/apps/autogen-assistant/frontend/gatsby-ssr.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +const codeToRunOnClient = `(function() { + try { + var mode = localStorage.getItem('darkmode'); + document.getElementsByTagName("html")[0].className === 'dark' ? 'dark' : 'light'; + } catch (e) {} +})();`; + +export const onRenderBody = ({ setHeadComponents }) => + setHeadComponents([ +