From 9b1c73fc5120327ecad2a3f739727097ad2604dd Mon Sep 17 00:00:00 2001 From: Jerron Lim Date: Tue, 18 Jun 2024 23:57:28 +0800 Subject: [PATCH] Enable creating custom skills (#53) * Make it easier and show how to add custom skills * Add 'managed' col in skills table * Update skills model, add migration file, update skills service to support new cols in skills table * Update init db to handle updating or deletion of managed skills * Update SkillsOut and SkillOut model * Add routes to create, update and delete skills. Update read_skills route to show managed skills aside from user owned skills * Add skills page, update sidebar and navbar to incorporate skills page. * Fix navigate route to not use backticks. Format code. * - Add GraphSkill pydantic model used by GraphMember to store skill info and provide its BaseTool instance. - Add dynamic_api_tool to create api tool from tool definitions. - Rename all_skills to managed_skills. * Fix query to invalidate after skill is edited * Update DeleteAlert component to handle skill deletion * - Set skill description and definition as required when creating or editing skill. - Set default for skill definition when creating skill. - Rename 'Pre-fill' button. * Order skills, teams in descending id. Order threads in descending order of updated at. * Set a maxWidth for name and description cols for skills and teams table * Refactor code * Fix lint issues * Update readme to include info about creating skills * Update readme to include boolean as possible param type * Fix validation error in api tool when optional field is not included in params. * Make mypy verbose * Disable mypy incremental mode * Use python 3.12 for github test action * Checking memory usage in github actions * Remove memory usage check in test.yml. Track memory usage in linting stage * Try running mypy on a small portion of code * Try using swap space * Show traceback when running mypy * Track if docker containers are up * Revert linting stage to original and remove swap space from github test action. * Fix migration script to add superuser if it doesnt exist * Change coverage threshold to 70 * Update skills test * Fix lint issues * Fix skill tests * Fix unauthorised error in skill test * Fix read_skill route not using user authorisation * Fix skill test * Update dynamic_api_tool to validate tool_definition * Add tests for dynamic_api_tool * Fix lint errors * Removed unused import * Add endpoint to validate skill's tool_definition * Validate tool_definition before adding or updating skill, if invalid restrict from submitting * Fix update form to reset isDirty check after submitting * validate tool_definition in create_skill and update_skill too * Fix validate_tool_definition should return tool definition * Update skill tests * Fix skill tests * fix skill test --- .github/workflows/smokeshow.yml | 2 +- .github/workflows/test.yml | 20 +- README.md | 66 +++ ...9df0a02_add_managed_col_in_skills_table.py | 31 ++ .../c1acf65d4731_update_skills_table.py | 73 ++++ backend/app/api/routes/skills.py | 141 +++++- backend/app/api/routes/teams.py | 47 +- backend/app/api/routes/threads.py | 6 +- backend/app/core/db.py | 24 +- backend/app/core/graph/build.py | 26 +- backend/app/core/graph/members.py | 28 +- backend/app/core/graph/skills.py | 57 --- backend/app/core/graph/skills/__init__.py | 38 ++ backend/app/core/graph/skills/api_tool.py | 177 ++++++++ backend/app/core/graph/skills/calculator.py | 23 + backend/app/models.py | 39 +- backend/app/tests/api/routes/test_skills.py | 148 ++++++- backend/app/tests/graph/__init__.py | 0 backend/app/tests/graph/test_api_tool.py | 163 +++++++ backend/scripts/lint.sh | 2 +- frontend/package-lock.json | 408 +++++++++++++++++- frontend/package.json | 1 + frontend/src/client/index.ts | 6 + frontend/src/client/models/Skill.ts | 5 +- frontend/src/client/models/SkillCreate.ts | 12 + frontend/src/client/models/SkillOut.ts | 4 +- frontend/src/client/models/SkillUpdate.ts | 12 + frontend/src/client/models/SkillsOut.ts | 4 +- .../client/models/ToolDefinitionValidate.ts | 9 + frontend/src/client/schemas/$Skill.ts | 21 +- frontend/src/client/schemas/$SkillCreate.ts | 27 ++ frontend/src/client/schemas/$SkillOut.ts | 14 +- frontend/src/client/schemas/$SkillUpdate.ts | 44 ++ frontend/src/client/schemas/$SkillsOut.ts | 2 +- .../client/schemas/$ToolDefinitionValidate.ts | 16 + frontend/src/client/services/SkillsService.ts | 99 ++++- .../src/components/Common/ActionsMenu.tsx | 21 +- .../src/components/Common/DeleteAlert.tsx | 8 +- frontend/src/components/Common/Navbar.tsx | 14 +- .../src/components/Common/SidebarItems.tsx | 2 + .../src/components/Members/EditMember.tsx | 5 +- frontend/src/components/Skills/AddSkill.tsx | 174 ++++++++ frontend/src/components/Skills/EditSkill.tsx | 179 ++++++++ .../src/components/Skills/SkillEditor.tsx | 87 ++++ frontend/src/components/Teams/EditTeam.tsx | 5 +- frontend/src/routeTree.gen.ts | 11 + frontend/src/routes/_layout/skills.tsx | 107 +++++ frontend/src/routes/_layout/teams.index.tsx | 106 +++-- 48 files changed, 2292 insertions(+), 222 deletions(-) create mode 100644 backend/app/alembic/versions/a8fff9df0a02_add_managed_col_in_skills_table.py create mode 100644 backend/app/alembic/versions/c1acf65d4731_update_skills_table.py delete mode 100644 backend/app/core/graph/skills.py create mode 100644 backend/app/core/graph/skills/__init__.py create mode 100644 backend/app/core/graph/skills/api_tool.py create mode 100644 backend/app/core/graph/skills/calculator.py create mode 100644 backend/app/tests/graph/__init__.py create mode 100644 backend/app/tests/graph/test_api_tool.py create mode 100644 frontend/src/client/models/SkillCreate.ts create mode 100644 frontend/src/client/models/SkillUpdate.ts create mode 100644 frontend/src/client/models/ToolDefinitionValidate.ts create mode 100644 frontend/src/client/schemas/$SkillCreate.ts create mode 100644 frontend/src/client/schemas/$SkillUpdate.ts create mode 100644 frontend/src/client/schemas/$ToolDefinitionValidate.ts create mode 100644 frontend/src/components/Skills/AddSkill.tsx create mode 100644 frontend/src/components/Skills/EditSkill.tsx create mode 100644 frontend/src/components/Skills/SkillEditor.tsx create mode 100644 frontend/src/routes/_layout/skills.tsx diff --git a/.github/workflows/smokeshow.yml b/.github/workflows/smokeshow.yml index 62aef248..e063b7b1 100644 --- a/.github/workflows/smokeshow.yml +++ b/.github/workflows/smokeshow.yml @@ -30,7 +30,7 @@ jobs: - run: smokeshow upload backend/htmlcov env: SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} - SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 90 + SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 70 SMOKESHOW_GITHUB_CONTEXT: coverage SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5916ce50..4cd7c5e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,6 @@ on: - synchronize jobs: - test: runs-on: ubuntu-latest steps: @@ -20,12 +19,27 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: "3.10" - run: cp .env.example .env - run: docker compose build - run: docker compose down -v --remove-orphans - run: docker compose up -d + + - name: Wait for Docker Containers to be Ready + run: | + echo "Waiting for Docker containers to be ready..." + sleep 10 + docker ps -a + + - name: Ensure Docker Container is Running + run: | + if [ $(docker inspect -f '{{.State.Running}}' $(docker-compose ps -q backend)) != "true" ]; then + echo "Backend container is not running" + docker logs $(docker-compose ps -q backend) + exit 1 + fi + - name: Lint run: docker compose exec -T backend bash /app/scripts/lint.sh - name: Run tests @@ -38,7 +52,7 @@ jobs: path: backend/htmlcov # https://github.com/marketplace/actions/alls-green#why - alls-green: # This job does nothing and is only used for the branch protection + alls-green: # This job does nothing and is only used for the branch protection if: always() needs: - test diff --git a/README.md b/README.md index 2a96cb81..ac8356da 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ - [Sequential vs Hierarchical workflows](#sequential-vs-hierarchical-workflows) - [Sequential workflows](#sequential-workflows) - [Hierarchical workflows](#hierarchical-workflows) + - [Skills](#skills) + - [Create a Skill Using Skill Definitions](#create-a-skill-using-skill-definitions) + - [Writing a Custom Skill using LangChain](#writing-a-custom-skill-using-langchain) - [Guides](#guides) - [Creating Your First Hierarchical Team](#creating-your-first-hierarchical-team) - [Equipping Your Team Member with Skills](#equipping-your-team-member-with-skills) @@ -107,6 +110,69 @@ Use this if: - Task delegation and re-evaluation are crucial for your workflow. - You want flexibility in task management and adaptability to changes. +### Skills + +Skills are abilities that you can equip your agents with to interact with the world. For example, you can provide your agent with the skill to check the current weather condition or search the web for the latest news. By default, Tribe provides three skills: + +- **duckduckgo-search**: Performs web searches. +- **wikipedia**: Searches Wikipedia for information. +- **yahoo-finance**: Retrieves information from Yahoo Finance News. + +You will likely want to create custom skills, which can be done in two ways: by using function definitions for simple HTTP requests or by writing custom skills in the codebase. + +#### Create a Skill Using Skill Definitions + +If your skill involves performing an HTTP request to fetch or update data, using skill definitions is the simplest approach. In Tribe, start by navigating to the 'Skills' tab and clicking the 'Add Skill' button. You will then be prompted to provide the skill definition, which instructs your agent on how to execute the specific skill. This definition should be structured as follows: + +```json +{ + "url": "https://example.com", + "method": "GET", + "headers": {}, + "type": "function", + "function": { + "name": "Your skill name", + "description": "Your skill description", + "parameters": { + "type": "object", + "properties": { + "param1": { + "type": "integer", + "description": "Description of the first parameter" + }, + "param2": { + "type": "string", + "enum": ["option1"], + "description": "Description of the second parameter" + } + }, + "required": ["param1", "param2"] + } + } +} +``` + +| Key | Description | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | The endpoint URL for the API call. | +| `method` | The HTTP method used for the request. It can be `GET`, `POST`, `PUT`, `PATCH`, or `DELETE`. | +| `headers` | Any HTTP headers to include in the request. | +| `function` | Contains details about the skill: | +| `function > name` | The name of the skill. Follow these rules: only letters (a-z, A-Z), numbers (0-9), underscores (_), and hyphens (-) are allowed; must be between 1 and 64 characters long. | +| `function > description` | Describes the skill to inform the agent about its usage. | +| `function > parameters` | Details about the parameters the API accepts. | +| `properties > param` | The name of the query or body parameter. For `GET` methods, this will be a query parameter. For `POST`, `PUT`, `PATCH`, and `DELETE`, it will be in the request body. | +| `param > type` | Specifies the type of the parameter, which can be `string`, `number`, `integer`, or `boolean`. | +| `param > description` | Provides context about the parameter's purpose. | +| `param > enum` | Optionally, include an array to restrict the agent to select from predefined values. | +| `parameters > required` | Lists the parameters that are required, ensuring they are always included in the API request. | + +#### Writing a Custom Skill using LangChain + +For more intricate tasks that extend beyond simple HTTP requests, LangChain allows you to develop more advanced tools. You can integrate these tools into Tribe by adding them to the [`managed_skills` dictionary](https://github.com/streetlamb/tribe/blob/master/backend/app/core/graph/skills/__init__.py). For a practical example, refer to the [demo calculator tool](https://github.com/streetlamb/tribe/blob/master/backend/app/core/graph/skills/calculator.py). To learn how to create a LangChain tool, please consult their [documentation](https://python.langchain.com/v0.2/docs/how_to/custom_tools/). + +After creating a new tool, restart the application to ensure the tool is properly loaded into the database. Likewise, if you need to remove a tool, simply delete it from the `managed_skills` dictionary and restart the application to ensure it is removed from the database. Do note that tools created this way are available to all users in your application. + ### Guides #### Creating Your First Hierarchical Team diff --git a/backend/app/alembic/versions/a8fff9df0a02_add_managed_col_in_skills_table.py b/backend/app/alembic/versions/a8fff9df0a02_add_managed_col_in_skills_table.py new file mode 100644 index 00000000..cc4cf723 --- /dev/null +++ b/backend/app/alembic/versions/a8fff9df0a02_add_managed_col_in_skills_table.py @@ -0,0 +1,31 @@ +"""Add managed col in skills table + +Revision ID: a8fff9df0a02 +Revises: 6fa42be09dd2 +Create Date: 2024-06-13 12:17:08.622973 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a8fff9df0a02' +down_revision = '6fa42be09dd2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('skill', sa.Column('managed', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true())) + # ### end Alembic commands ### + + # Remove server default after setting the initial values + op.alter_column('skill', 'managed', server_default=None) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('skill', 'managed') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/alembic/versions/c1acf65d4731_update_skills_table.py b/backend/app/alembic/versions/c1acf65d4731_update_skills_table.py new file mode 100644 index 00000000..0aa27e81 --- /dev/null +++ b/backend/app/alembic/versions/c1acf65d4731_update_skills_table.py @@ -0,0 +1,73 @@ +"""Update skills table + +Revision ID: c1acf65d4731 +Revises: a8fff9df0a02 +Create Date: 2024-06-13 16:09:19.067502 + +""" +from alembic import op +from app.core.security import get_password_hash +import sqlalchemy as sa +from sqlalchemy.sql import table, column, select +from sqlalchemy import String, Integer, insert + +# revision identifiers, used by Alembic. +revision = 'c1acf65d4731' +down_revision = 'a8fff9df0a02' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Add tool_definition column + op.add_column('skill', sa.Column('tool_definition', sa.JSON(), nullable=True)) + + # Import the settings to get the FIRST_SUPERUSER email + from app.core.config import settings + + # Use raw SQL to find the user ID for the superuser + connection = op.get_bind() + user_table = table('user', + column('id', Integer), + column('email', String), + column('hashed_password', String), + column('is_superuser', sa.Boolean), + column('is_active', sa.Boolean)) + superuser_email = settings.FIRST_SUPERUSER + + # Correct the query to properly select the user ID + superuser_id = connection.execute( + select(user_table.c.id).where(user_table.c.email == superuser_email) + ).scalar() + + if superuser_id is None: + # Insert the superuser if it does not exist + connection.execute( + insert(user_table).values( + email=superuser_email, + hashed_password=get_password_hash(settings.FIRST_SUPERUSER_PASSWORD), + is_superuser=True, + is_active=True + ) + ) + # Fetch the superuser ID after insertion + superuser_id = connection.execute( + select(user_table.c.id).where(user_table.c.email == superuser_email) + ).scalar() + + # Add owner_id column with the superuser ID as default for existing rows + op.add_column('skill', sa.Column('owner_id', sa.Integer(), nullable=False, server_default=str(superuser_id))) + op.alter_column('skill', 'description', existing_type=sa.VARCHAR(), nullable=False) + op.create_foreign_key(None, 'skill', 'user', ['owner_id'], ['id']) + + # Remove the server default after setting the initial values + op.alter_column('skill', 'owner_id', server_default=None) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'skill', type_='foreignkey') + op.alter_column('skill', 'description', existing_type=sa.VARCHAR(), nullable=True) + op.drop_column('skill', 'owner_id') + op.drop_column('skill', 'tool_definition') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/api/routes/skills.py b/backend/app/api/routes/skills.py index 37c24067..004b8842 100644 --- a/backend/app/api/routes/skills.py +++ b/backend/app/api/routes/skills.py @@ -1,38 +1,163 @@ from typing import Any from fastapi import APIRouter, HTTPException -from sqlmodel import func, select +from pydantic import ValidationError +from sqlmodel import col, func, or_, select -from app.api.deps import SessionDep +from app.api.deps import CurrentUser, SessionDep +from app.core.graph.skills.api_tool import ToolDefinition from app.models import ( + Message, Skill, + SkillCreate, SkillOut, SkillsOut, + SkillUpdate, + ToolDefinitionValidate, ) router = APIRouter() +def validate_tool_definition(tool_definition: dict[str, Any]) -> ToolDefinition | None: + """ + Validates the tool_definition. + Raises an HTTPException with detailed validation errors if invalid. + """ + try: + return ToolDefinition.model_validate(tool_definition) + except ValidationError as e: + error_details = [] + for error in e.errors(): + loc = " -> ".join(map(str, error["loc"])) + msg = error["msg"] + error_details.append(f"Field '{loc}': {msg}") + raise HTTPException(status_code=400, detail="; ".join(error_details)) + + @router.get("/", response_model=SkillsOut) -def read_skills(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: +def read_skills( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: """ - Retrieve skills. + Retrieve skills """ - count_statement = select(func.count()).select_from(Skill) - count = session.exec(count_statement).one() + if current_user.is_superuser: + count_statement = select(func.count()).select_from(Skill) + count = session.exec(count_statement).one() + statement = ( + select(Skill).order_by(col(Skill.id).desc()).offset(skip).limit(limit) + ) + + else: + count_statement = ( + select(func.count()) + .select_from(Skill) + .where(or_(Skill.managed == True, Skill.owner_id == current_user.id)) # noqa: E712 + ) + count = session.exec(count_statement).one() + + statement = ( + select(Skill) + .where(or_(Skill.managed == True, Skill.owner_id == current_user.id)) # noqa: E712 + .order_by(col(Skill.id).desc()) + .offset(skip) + .limit(limit) + ) - statement = select(Skill).offset(skip).limit(limit) skills = session.exec(statement).all() return SkillsOut(data=skills, count=count) @router.get("/{id}", response_model=SkillOut) -def read_skill(session: SessionDep, id: int) -> Any: +def read_skill(session: SessionDep, current_user: CurrentUser, id: int) -> Any: """ Get skill by ID. """ skill = session.get(Skill, id) if not skill: raise HTTPException(status_code=404, detail="Skill not found") + if not skill.managed and (skill.owner_id != current_user.id): + raise HTTPException(status_code=400, detail="Not enough permissions") return skill + + +@router.post("/", response_model=SkillOut) +def create_skill( + *, + session: SessionDep, + current_user: CurrentUser, + skill_in: SkillCreate, +) -> Any: + """ + Create new skill. + """ + validate_tool_definition(skill_in.tool_definition) + + skill = Skill.model_validate(skill_in, update={"owner_id": current_user.id}) + session.add(skill) + session.commit() + session.refresh(skill) + return skill + + +@router.put("/{id}", response_model=SkillOut) +def update_skill( + *, + session: SessionDep, + current_user: CurrentUser, + id: int, + skill_in: SkillUpdate, +) -> Any: + """ + Update a skill. + """ + skill = session.get(Skill, id) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + if not current_user.is_superuser and (skill.owner_id != current_user.id): + raise HTTPException(status_code=400, detail="Not enough permissions") + + if skill_in.tool_definition: + validate_tool_definition(skill_in.tool_definition) + + update_dict = skill_in.model_dump(exclude_unset=True) + skill.sqlmodel_update(update_dict) + session.add(skill) + session.commit() + session.refresh(skill) + return skill + + +@router.delete("/{id}") +def delete_skill(session: SessionDep, current_user: CurrentUser, id: int) -> Any: + """ + Delete a skill. + """ + skill = session.get(Skill, id) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + if not current_user.is_superuser and (skill.owner_id != current_user.id): + raise HTTPException(status_code=400, detail="Not enough permissions") + if skill.managed: + raise HTTPException(status_code=400, detail="Cannot delete managed skills") + session.delete(skill) + session.commit() + return Message(message="Skill deleted successfully") + + +@router.post("/validate") +def validate_skill(tool_definition_in: ToolDefinitionValidate) -> Any: + """ + Validate skill's tool definition. + """ + try: + validated_tool_definition = validate_tool_definition( + tool_definition_in.tool_definition + ) + return validated_tool_definition + except HTTPException as e: + raise HTTPException(status_code=400, detail=str(e.detail)) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/api/routes/teams.py b/backend/app/api/routes/teams.py index 3f64796c..faab2f70 100644 --- a/backend/app/api/routes/teams.py +++ b/backend/app/api/routes/teams.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse -from sqlmodel import func, select +from sqlmodel import col, func, select from app.api.deps import CurrentUser, SessionDep from app.core.graph.build import generator @@ -18,48 +18,6 @@ Thread, ) -# TODO: To remove -teams = { - "FoodExpertLeader": { - "name": "FoodExperts", - "members": { - "ChineseFoodExpert": { - "type": "worker", - "name": "ChineseFoodExpert", - "backstory": "Studied culinary school in Singapore. Well-verse in hawker to fine-dining experiences. ISFP.", - "role": "Provide chinese food suggestions in Singapore", - "tools": [], - }, - "MalayFoodExpert": { - "type": "worker", - "name": "MalayFoodExpert", - "backstory": "Studied culinary school in Singapore. Well-verse in hawker to fine-dining experiences. INTP.", - "role": "Provide malay food suggestions in Singapore", - "tools": [], - }, - }, - }, - "TravelExpertLeader": { - "name": "TravelKakis", - "members": { - "FoodExpertLeader": { - "type": "leader", - "name": "FoodExpertLeader", - "role": "Gather inputs from your team and provide a diverse food suggestions in Singapore.", - "tools": [], - }, - "HistoryExpert": { - "type": "worker", - "name": "HistoryExpert", - "backstory": "Studied Singapore history. Well-verse in Singapore architecture. INTJ.", - "role": "Provide places to sight-see with a history/architecture angle", - "tools": [], - }, - }, - }, -} -team_leader = "TravelExpertLeader" - router = APIRouter() @@ -92,7 +50,7 @@ def read_teams( if current_user.is_superuser: count_statement = select(func.count()).select_from(Team) count = session.exec(count_statement).one() - statement = select(Team).offset(skip).limit(limit) + statement = select(Team).offset(skip).limit(limit).order_by(col(Team.id).desc()) teams = session.exec(statement).all() else: count_statement = ( @@ -106,6 +64,7 @@ def read_teams( .where(Team.owner_id == current_user.id) .offset(skip) .limit(limit) + .order_by(col(Team.id).desc()) ) teams = session.exec(statement).all() return TeamsOut(data=teams, count=count) diff --git a/backend/app/api/routes/threads.py b/backend/app/api/routes/threads.py index 9c90f57f..c46d34cc 100644 --- a/backend/app/api/routes/threads.py +++ b/backend/app/api/routes/threads.py @@ -37,7 +37,11 @@ def read_threads( count_statement = select(func.count()).select_from(Thread) count = session.exec(count_statement).one() statement = ( - select(Thread).where(Thread.team_id == team_id).offset(skip).limit(limit) + select(Thread) + .where(Thread.team_id == team_id) + .offset(skip) + .limit(limit) + .order_by(col(Thread.updated_at).desc()) ) threads = session.exec(statement).all() else: diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 9faaacd5..b5440833 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -2,7 +2,7 @@ from app import crud from app.core.config import settings -from app.core.graph.skills import all_skills +from app.core.graph.skills import managed_skills from app.models import Skill, User, UserCreate engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -34,11 +34,13 @@ def init_db(session: Session) -> None: ) user = crud.create_user(session=session, user_create=user_in) - # TODO: Find a way to deal with deleting skills existing_skills = session.exec(select(Skill)).all() existing_skills_dict = {skill.name: skill for skill in existing_skills} - for skill_name, skill_info in all_skills.items(): + current_skill_names = set(managed_skills.keys()) + + # Add or update skills in the database + for skill_name, skill_info in managed_skills.items(): if skill_name in existing_skills_dict: existing_skill = existing_skills_dict[skill_name] if existing_skill.description != skill_info.description: @@ -46,7 +48,21 @@ def init_db(session: Session) -> None: existing_skill.description = skill_info.description session.add(existing_skill) # Mark the modified object for saving else: - new_skill = Skill(name=skill_name, description=skill_info.description) + new_skill = Skill( + name=skill_name, + description=skill_info.description, + managed=True, + owner_id=user.id, + ) session.add(new_skill) # Prepare new skill for addition to the database + # Delete skills that are no longer in the current code and are managed + for skill_name in existing_skills_dict: + if ( + skill_name not in current_skill_names + and existing_skills_dict[skill_name].managed + ): + skill_to_delete = existing_skills_dict[skill_name] + session.delete(skill_to_delete) + session.commit() diff --git a/backend/app/core/graph/build.py b/backend/app/core/graph/build.py index 636f0577..1ae2c8a0 100644 --- a/backend/app/core/graph/build.py +++ b/backend/app/core/graph/build.py @@ -20,6 +20,7 @@ from app.core.graph.members import ( GraphLeader, GraphMember, + GraphSkill, GraphTeam, LeaderNode, SequentialWorkerNode, @@ -27,7 +28,6 @@ TeamState, WorkerNode, ) -from app.core.graph.skills import all_skills from app.models import ChatMessage, InterruptDecision, Member, Team @@ -92,11 +92,19 @@ def convert_hierarchical_team_to_dict( leader = members_lookup[member.source] leader_name = leader.name if member.type == "worker": + tools = [ + GraphSkill( + name=skill.name, + managed=skill.managed, + definition=skill.tool_definition, + ) + for skill in member.skills + ] teams[leader_name].members[member_name] = GraphMember( name=member_name, backstory=member.backstory or "", role=member.role, - tools=[skill.name for skill in member.skills], + tools=tools, provider=member.provider, model=member.model, temperature=member.temperature, @@ -144,11 +152,19 @@ def convert_sequential_team_to_dict(team: Team) -> Mapping[str, GraphMember]: while queue: member_id = queue.popleft() memberModel = members_lookup[member_id] + tools = [ + GraphSkill( + name=skill.name, + managed=skill.managed, + definition=skill.tool_definition, + ) + for skill in member.skills + ] graph_member = GraphMember( name=memberModel.name, backstory=memberModel.backstory or "", role=memberModel.role, - tools=[skill.name for skill in memberModel.skills], + tools=tools, provider=memberModel.provider, model=memberModel.model, temperature=memberModel.temperature, @@ -278,7 +294,7 @@ def create_hierarchical_graph( if len(member.tools) >= 1: build.add_node( f"{name}_tools", - ToolNode([all_skills[tool].tool for tool in member.tools]), + ToolNode([tool.tool for tool in member.tools]), ) # After tools node is called, agent node is called next. build.add_edge(f"{name}_tools", name) @@ -347,7 +363,7 @@ def create_sequential_graph( if len(member.tools) >= 1: graph.add_node( f"{member.name}_tools", - ToolNode([all_skills[tool].tool for tool in member.tools]), + ToolNode([tool.tool for tool in member.tools]), ) # After tools node is called, agent node is called next. graph.add_edge(f"{member.name}_tools", member.name) diff --git a/backend/app/core/graph/members.py b/backend/app/core/graph/members.py index e6e48f36..5ec594f9 100644 --- a/backend/app/core/graph/members.py +++ b/backend/app/core/graph/members.py @@ -11,7 +11,25 @@ from typing_extensions import NotRequired, TypedDict from app.core.graph.models import all_models -from app.core.graph.skills import all_skills +from app.core.graph.skills import managed_skills +from app.core.graph.skills.api_tool import dynamic_api_tool + + +class GraphSkill(BaseModel): + name: str = Field(description="The name of the skill") + definition: dict[str, Any] | None = Field( + description="The skill definition. For api tool calling. Optional." + ) + managed: bool = Field("Whether the skill is managed or user created.") + + @property + def tool(self) -> BaseTool: + if self.managed: + return managed_skills[self.name].tool + elif self.definition: + return dynamic_api_tool(self.definition) + else: + raise ValueError("Skill is not managed and no definition provided.") class GraphPerson(BaseModel): @@ -30,7 +48,9 @@ def persona(self) -> str: class GraphMember(GraphPerson): - tools: list[str] = Field(description="The list of tools that the person can use.") + tools: list[GraphSkill] = Field( + description="The list of tools that the person can use." + ) interrupt: bool = Field( default=False, description="Whether to interrupt the person or not before skill use", @@ -155,7 +175,7 @@ async def work(self, state: TeamState) -> ReturnTeamState: ) # If member has no tools, then use a regular model instead of an agent if len(member.tools) >= 1: - tools: Sequence[BaseTool] = [all_skills[tool].tool for tool in member.tools] + tools: Sequence[BaseTool] = [tool.tool for tool in member.tools] chain = prompt | self.model.bind_tools(tools) else: chain: RunnableSerializable[dict[str, Any], AnyMessage] = ( # type: ignore[no-redef] @@ -211,7 +231,7 @@ async def work(self, state: TeamState) -> ReturnTeamState: ) # If member has no tools, then use a regular model instead of an agent if len(member.tools) >= 1: - tools: Sequence[BaseTool] = [all_skills[tool].tool for tool in member.tools] + tools: Sequence[BaseTool] = [tool.tool for tool in member.tools] chain = prompt | self.model.bind_tools(tools) else: chain: RunnableSerializable[dict[str, Any], AnyMessage] = ( # type: ignore[no-redef] diff --git a/backend/app/core/graph/skills.py b/backend/app/core/graph/skills.py deleted file mode 100644 index 8e29a3cd..00000000 --- a/backend/app/core/graph/skills.py +++ /dev/null @@ -1,57 +0,0 @@ -# Some tools are commented out as they require your SERP_API_KEY and SERPAPI_API_KEY to be added in .env file. -# See https://python.langchain.com/docs/integrations/tools/ for a list of other available tools. -# You can also add your own custom tools. - -from typing import Any - -from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun - -# from langchain_community.tools.google_finance import GoogleFinanceQueryRun -# from langchain_community.tools.google_jobs import GoogleJobsQueryRun -# from langchain_community.tools.google_scholar import GoogleScholarQueryRun -# from langchain_community.tools.google_trends import GoogleTrendsQueryRun -from langchain_community.tools.yahoo_finance_news import YahooFinanceNewsTool -from langchain_community.utilities import ( - # GoogleFinanceAPIWrapper, - # GoogleJobsAPIWrapper, - # GoogleScholarAPIWrapper, - # GoogleTrendsAPIWrapper, - WikipediaAPIWrapper, -) -from pydantic import BaseModel - - -class SkillInfo(BaseModel): - description: str - tool: Any - - -all_skills: dict[str, SkillInfo] = { - "duckduckgo-search": SkillInfo( - description="Searches the web using DuckDuckGo", tool=DuckDuckGoSearchRun() - ), - "wikipedia": SkillInfo( - description="Searches Wikipedia", - tool=WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper()), # type: ignore[call-arg] - ), - # "google-finance": SkillInfo( - # description="Get information from Google Finance Page via SerpApi.", - # tool=GoogleFinanceQueryRun(api_wrapper=GoogleFinanceAPIWrapper()), # type: ignore[call-arg] - # ), - # "google-jobs": SkillInfo( - # description="Fetch current job postings from Google Jobs via SerpApi.", - # tool=GoogleJobsQueryRun(api_wrapper=GoogleJobsAPIWrapper()), # type: ignore[call-arg] - # ), - # "google-scholar": SkillInfo( - # description="Fetch papers from Google Scholar via SerpApi.", - # tool=GoogleScholarQueryRun(api_wrapper=GoogleScholarAPIWrapper()), - # ), - # "google-trends": SkillInfo( - # description="Get information from Google Trends Page via SerpApi.", - # tool=GoogleTrendsQueryRun(api_wrapper=GoogleTrendsAPIWrapper()), # type: ignore[call-arg] - # ), - "yahoo-finance": SkillInfo( - description="Get information from Yahoo Finance News.", - tool=YahooFinanceNewsTool(), - ), -} diff --git a/backend/app/core/graph/skills/__init__.py b/backend/app/core/graph/skills/__init__.py new file mode 100644 index 00000000..37e4af12 --- /dev/null +++ b/backend/app/core/graph/skills/__init__.py @@ -0,0 +1,38 @@ +from langchain.pydantic_v1 import BaseModel +from langchain.tools import BaseTool +from langchain_community.tools import DuckDuckGoSearchRun, WikipediaQueryRun +from langchain_community.tools.yahoo_finance_news import YahooFinanceNewsTool +from langchain_community.utilities import ( + WikipediaAPIWrapper, +) + +# from .calculator import calculator + + +class SkillInfo(BaseModel): + description: str + tool: BaseTool + + +managed_skills: dict[str, SkillInfo] = { + "duckduckgo-search": SkillInfo( + description="Searches the web using DuckDuckGo", tool=DuckDuckGoSearchRun() + ), + "wikipedia": SkillInfo( + description="Searches Wikipedia", + tool=WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper()), # type: ignore[call-arg] + ), + "yahoo-finance": SkillInfo( + description="Get information from Yahoo Finance News.", + tool=YahooFinanceNewsTool(), + ), + # "calculator": SkillInfo( + # description=calculator.description, + # tool=calculator, + # ), +} + +# To add more custom tools, follow these steps: +# 1. Create a new Python file in the `skills` folder (e.g., `calculator.py`). +# 2. Define your tool. Refer to `calculator.py` or see https://python.langchain.com/v0.1/docs/modules/tools/custom_tools/ +# 3. Import your new tool here and add it to the `managed_skills` dictionary above. diff --git a/backend/app/core/graph/skills/api_tool.py b/backend/app/core/graph/skills/api_tool.py new file mode 100644 index 00000000..21eb14e8 --- /dev/null +++ b/backend/app/core/graph/skills/api_tool.py @@ -0,0 +1,177 @@ +import json +import types +from enum import Enum +from typing import Any + +import requests +from langchain.pydantic_v1 import Field, create_model +from langchain.tools import StructuredTool +from langchain_core.tools import ToolException +from pydantic import BaseModel, ValidationError, field_validator + + +class ParameterProperties(BaseModel): + type: str + description: str + enum: list[str | int | float | bool] | None = None + + @field_validator("type") + def type_must_be_valid(cls, v: Any) -> Any: + if v not in {"string", "integer", "number", "boolean"}: + raise ValueError("Unsupported type") + return v + + +class Parameters(BaseModel): + type: str = Field(default="object") + properties: dict[str, ParameterProperties] + required: list[str] | None = None + + @field_validator("type") + def type_must_be_object(cls, v: Any) -> Any: + if v != "object": + raise ValueError("Parameters type must be object") + return v + + +class FunctionInfo(BaseModel): + name: str + description: str + parameters: Parameters + + +class ToolDefinition(BaseModel): + function: FunctionInfo + url: str + method: str = Field(default="GET") + headers: dict[str, str] | None = None + + @field_validator("method") + def method_must_be_valid(cls, v: Any) -> Any: + if v.upper() not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: + raise ValueError("Unsupported HTTP method") + return v.upper() + + +class TempEnum(str, Enum): + """Convert dict into Enum""" + + pass + + +def dynamic_api_tool(tool_definition: dict[str, Any]) -> StructuredTool: + """ + Create a dynamic API tool from a JSON definition. + + :param tool_definition: The JSON definition of the tool. + :return: A StructuredTool instance. + """ + + # Validate the tool definition + try: + validated_tool_definition = ToolDefinition(**tool_definition) + except ValidationError as e: + raise ValueError(f"Invalid tool definition: {e}") + + function_info = validated_tool_definition.function + name = function_info.name + description = function_info.description + parameters = function_info.parameters + required_fields = set(parameters.required or []) + + # Create the argument schema dynamically using pydantic + fields = {} + for arg, properties in parameters.properties.items(): + if properties.enum: + field_type = TempEnum( # type: ignore[call-overload] + f"{arg}Enum", {str(en): en for en in properties.enum} + ) + elif properties.type == "string": + field_type = str + elif properties.type == "integer": + field_type = int + elif properties.type == "number": + field_type = float + elif properties.type == "boolean": + field_type = bool + else: + raise ValueError(f"Unsupported parameter type: {properties.type}") + default = ... if arg in required_fields else None + fields[arg] = ( + field_type, + Field(default=default, description=properties.description), + ) + + DynamicInput = create_model(f"{name}Input", **fields) # type: ignore[call-overload] + + def api_call(**kwargs: Any) -> str: + """ + Executes an API call based on the provided tool definition. + + This function dynamically constructs and executes an API request using the + parameters specified in the tool definition. It supports GET, POST, PUT, + DELETE, PATCH, and any other HTTP method specified. The function handles + response parsing and error handling. + + Args: + **kwargs: Arbitrary keyword arguments representing the parameters to be + sent in the API request. These are used as query parameters + for GET requests and as JSON payload for POST, PUT, PATCH, and + DELETE requests. + + Returns: + str: The JSON response from the API, formatted as a pretty-printed string. + + Raises: + ToolException: If the HTTP request fails, the response cannot be decoded + as JSON, or any other unexpected error occurs during the + API call. + """ + url = validated_tool_definition.url + method = validated_tool_definition.method + headers = validated_tool_definition.headers or {} + + # Prepare data and params based on the HTTP method + if method in ["POST", "PUT", "PATCH", "DELETE"]: + data = kwargs + params = None + else: + data = None + params = kwargs + + try: + response = requests.request( + method, url, headers=headers, json=data, params=params + ) + response.raise_for_status() # Raise an HTTPError for bad responses + return json.dumps(response.json(), indent=2) + except requests.RequestException as e: + raise ToolException(f"HTTP request failed: {e}") + except ValueError as e: + raise ToolException(f"JSON decoding failed: {e}") + except Exception as e: + raise ToolException(f"An unexpected error occurred: {e}") + + api_call.__name__ = name + api_call.__doc__ = description + + # Create a new function object dynamically + dynamic_func = types.FunctionType( + api_call.__code__, + globals(), + name, + api_call.__defaults__, + api_call.__closure__, + ) + + # Create a StructuredTool instance + tool = StructuredTool.from_function( + func=dynamic_func, + name=name, + description=description, + args_schema=DynamicInput, + return_direct=True, + handle_tool_error=True, + ) + + return tool diff --git a/backend/app/core/graph/skills/calculator.py b/backend/app/core/graph/skills/calculator.py new file mode 100644 index 00000000..70a70ab2 --- /dev/null +++ b/backend/app/core/graph/skills/calculator.py @@ -0,0 +1,23 @@ +# This is an example showing how to create a simple calculator skill + +from langchain.pydantic_v1 import BaseModel, Field +from langchain.tools import StructuredTool + + +class CalculatorInput(BaseModel): + a: int = Field(description="first number") + b: int = Field(description="second number") + + +def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + +calculator = StructuredTool.from_function( + func=multiply, + name="Calculator", + description="Useful for when you need to multiply two numbers.", + args_schema=CalculatorInput, + return_direct=True, +) diff --git a/backend/app/models.py b/backend/app/models.py index 9d8bb8d9..06300819 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,11 +1,18 @@ from datetime import datetime from enum import Enum -from typing import Optional +from typing import Any, Optional from uuid import UUID, uuid4 from pydantic import BaseModel from pydantic import Field as PydanticField -from sqlalchemy import Column, DateTime, PrimaryKeyConstraint, UniqueConstraint, func +from sqlalchemy import ( + JSON, + Column, + DateTime, + PrimaryKeyConstraint, + UniqueConstraint, + func, +) from sqlmodel import Field, Relationship, SQLModel @@ -74,6 +81,7 @@ class User(UserBase, table=True): id: int | None = Field(default=None, primary_key=True) hashed_password: str teams: list["Team"] = Relationship(back_populates="owner") + skills: list["Skill"] = Relationship(back_populates="owner") # Properties to return via API, id is always required @@ -279,7 +287,23 @@ class MembersOut(SQLModel): class SkillBase(SQLModel): name: str - description: str | None = None + description: str + managed: bool = False + tool_definition: dict[str, Any] | None = Field( + default_factory=dict, sa_column=Column(JSON) + ) + + +class SkillCreate(SkillBase): + tool_definition: dict[str, Any] # Tool definition is required if not managed + managed: bool = Field(default=False, const=False) # Managed must be False + + +class SkillUpdate(SkillBase): + name: str | None = None # type: ignore[assignment] + description: str | None = None # type: ignore[assignment] + managed: bool | None = None # type: ignore[assignment] + tool_definition: dict[str, Any] | None = None class Skill(SkillBase, table=True): @@ -287,16 +311,21 @@ class Skill(SkillBase, table=True): members: list["Member"] = Relationship( back_populates="skills", link_model=MemberSkillsLink ) + owner_id: int | None = Field(default=None, foreign_key="user.id", nullable=False) + owner: User | None = Relationship(back_populates="skills") class SkillsOut(SQLModel): - data: list[Skill] + data: list["SkillOut"] count: int class SkillOut(SkillBase): id: int - description: str | None + + +class ToolDefinitionValidate(SQLModel): + tool_definition: dict[str, Any] # ==============CHECKPOINT===================== diff --git a/backend/app/tests/api/routes/test_skills.py b/backend/app/tests/api/routes/test_skills.py index 5fee3752..d2331b40 100644 --- a/backend/app/tests/api/routes/test_skills.py +++ b/backend/app/tests/api/routes/test_skills.py @@ -1,33 +1,159 @@ +import json + from fastapi.testclient import TestClient from sqlmodel import Session from app.core.config import settings -from app.models import Skill +from app.models import Skill, SkillCreate from app.tests.utils.utils import random_lower_string +valid_tool_definition = json.loads( + json.dumps( + { + "function": { + "name": "getWeatherForecast", + "description": "Fetches the weather forecast for a given location based on latitude and longitude.", + "parameters": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "description": "Latitude of the location", + }, + "longitude": { + "type": "number", + "description": "Longitude of the location", + }, + "current": { + "type": "string", + "description": "Current weather parameters to fetch", + "enum": ["temperature_2m,wind_speed_10m"], + }, + }, + "required": ["latitude", "longitude"], + }, + }, + "url": "https://api.open-meteo.com/v1/forecast", + "method": "GET", + "headers": {"Content-Type": "application/json"}, + } + ) +) + -def create_skill(db: Session) -> Skill: - skill_data = {"name": random_lower_string(), "description": random_lower_string()} - skill = Skill(**skill_data) +def create_skill(db: Session, user_id: int) -> Skill: + skill_data = { + "name": random_lower_string(), + "description": random_lower_string(), + "managed": False, + "tool_definition": valid_tool_definition, + "owner_id": user_id, + } + skill = Skill.model_validate( + SkillCreate(**skill_data), update={"owner_id": user_id} + ) db.add(skill) db.commit() return skill -def test_read_skills(client: TestClient, db: Session) -> None: - create_skill(db) - response = client.get(f"{settings.API_V1_STR}/skills") +def test_read_skills( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + create_skill(db, 1) + response = client.get( + f"{settings.API_V1_STR}/skills", headers=superuser_token_headers + ) assert response.status_code == 200 data = response.json() assert "count" in data assert "data" in data -def test_read_skill(client: TestClient, db: Session) -> None: - skill = create_skill(db) - - response = client.get(f"{settings.API_V1_STR}/skills/{skill.id}") +def test_read_skill( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + skill = create_skill(db, 1) + response = client.get( + f"{settings.API_V1_STR}/skills/{skill.id}", headers=superuser_token_headers + ) assert response.status_code == 200 data = response.json() assert data["name"] == skill.name assert data["description"] == skill.description + assert data["managed"] == skill.managed + assert data["tool_definition"] == skill.tool_definition + + +def test_create_skill( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + skill_data = { + "name": random_lower_string(), + "description": random_lower_string(), + "tool_definition": valid_tool_definition, + } + response = client.post( + f"{settings.API_V1_STR}/skills", + json=skill_data, + headers=superuser_token_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == skill_data["name"] + assert data["description"] == skill_data["description"] + assert data["tool_definition"] == skill_data["tool_definition"] + + +def test_create_skill_with_invalid_tool_definition( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + skill_data = { + "name": random_lower_string(), + "description": random_lower_string(), + "tool_definition": {"hello": "world"}, + } + response = client.post( + f"{settings.API_V1_STR}/skills", + json=skill_data, + headers=superuser_token_headers, + ) + assert response.status_code == 400 + + +def test_update_skill( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + skill = create_skill(db, 1) + updated_skill_data = {"name": random_lower_string()} + response = client.put( + f"{settings.API_V1_STR}/skills/{skill.id}", + json=updated_skill_data, + headers=superuser_token_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == updated_skill_data["name"] + + +def test_update_skill_with_invalid_tool_definition( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + skill = create_skill(db, 1) + updated_skill_data = {"tool_definition": {"hello": "world"}} + response = client.put( + f"{settings.API_V1_STR}/skills/{skill.id}", + json=updated_skill_data, + headers=superuser_token_headers, + ) + assert response.status_code == 400 + + +def test_delete_skill( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + skill = create_skill(db, 1) + response = client.delete( + f"{settings.API_V1_STR}/skills/{skill.id}", headers=superuser_token_headers + ) + assert response.status_code == 200 diff --git a/backend/app/tests/graph/__init__.py b/backend/app/tests/graph/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/tests/graph/test_api_tool.py b/backend/app/tests/graph/test_api_tool.py new file mode 100644 index 00000000..9d1e235a --- /dev/null +++ b/backend/app/tests/graph/test_api_tool.py @@ -0,0 +1,163 @@ +import pytest +from langchain.pydantic_v1 import ValidationError + +from app.core.graph.skills.api_tool import dynamic_api_tool + +# Sample tool definitions +valid_tool_definition = { + "function": { + "name": "getWeatherForecast", + "description": "Fetches the weather forecast for a given location based on latitude and longitude.", + "parameters": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "description": "Latitude of the location", + }, + "longitude": { + "type": "number", + "description": "Longitude of the location", + }, + "current": { + "type": "string", + "description": "Current weather parameters to fetch", + "enum": ["temperature_2m,wind_speed_10m"], + }, + }, + "required": ["latitude", "longitude"], + }, + }, + "url": "https://api.open-meteo.com/v1/forecast", + "method": "GET", + "headers": {"Content-Type": "application/json"}, +} + +invalid_tool_definition_missing_url = { + "function": { + "name": "getWeatherForecast", + "description": "Fetches the weather forecast for a given location based on latitude and longitude.", + "parameters": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "description": "Latitude of the location", + }, + "longitude": { + "type": "number", + "description": "Longitude of the location", + }, + "current": { + "type": "string", + "description": "Current weather parameters to fetch", + }, + }, + "required": ["latitude", "longitude", "current"], + }, + }, + "method": "GET", + "headers": {"Content-Type": "application/json"}, +} + +invalid_tool_definition_invalid_type = { + "function": { + "name": "getWeatherForecast", + "description": "Fetches the weather forecast for a given location based on latitude and longitude.", + "parameters": { + "type": "object", + "properties": { + "latitude": { + "type": "array", + "description": "Latitude of the location", + }, # Invalid type + "longitude": { + "type": "number", + "description": "Longitude of the location", + }, + "current": { + "type": "string", + "description": "Current weather parameters to fetch", + }, + }, + "required": ["latitude", "longitude", "current"], + }, + }, + "url": "https://api.open-meteo.com/v1/forecast", + "method": "GET", + "headers": {"Content-Type": "application/json"}, +} + + +def test_dynamic_api_tool_valid_definition() -> None: + tool = dynamic_api_tool(valid_tool_definition) + assert tool.name == "getWeatherForecast" + assert ( + tool.description + == "Fetches the weather forecast for a given location based on latitude and longitude." + ) + + # Test invoking tool with valid parameters + result = tool.invoke( + { + "latitude": 52.52, + "longitude": 13.41, + "current": "temperature_2m,wind_speed_10m", + }, + ) + assert isinstance(result, str) + + +def test_dynamic_api_tool_missing_url() -> None: + with pytest.raises(ValueError) as excinfo: + dynamic_api_tool(invalid_tool_definition_missing_url) + assert "Field required" in str(excinfo.value) + + +def test_dynamic_api_tool_invalid_type() -> None: + with pytest.raises(ValueError) as excinfo: + dynamic_api_tool(invalid_tool_definition_invalid_type) + assert "Unsupported type" in str(excinfo.value) + + +def test_dynamic_api_tool_missing_optional_parameter() -> None: + tool = dynamic_api_tool(valid_tool_definition) + + # Test invoking tool with missing optional parameter + result = tool.run( + { + "latitude": 52.52, + "longitude": 13.41, + } + ) + assert isinstance(result, str) + + +def test_dynamic_api_tool_missing_required_parameter() -> None: + tool = dynamic_api_tool(valid_tool_definition) + + # Test invoking tool with missing required parameter + with pytest.raises(ValidationError) as excinfo: + tool.run( + { + "longitude": 13.41, + "current": "temperature_2m,wind_speed_10m", + } + ) + assert "field required" in str(excinfo.value) + + +def test_dynamic_api_tool_handle_tool_error() -> None: + invalid_url_tool_definition = valid_tool_definition.copy() + invalid_url_tool_definition["url"] = "https://invalid-url" + tool_with_invalid_url = dynamic_api_tool(invalid_url_tool_definition) + + res = tool_with_invalid_url.invoke( + { + "latitude": 52.52, + "longitude": 13.41, + "current": "temperature_2m,wind_speed_10m", + }, + ) + assert isinstance(res, str) + assert res.startswith("HTTP request failed:") diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh index 7b0397e7..d9831cd9 100644 --- a/backend/scripts/lint.sh +++ b/backend/scripts/lint.sh @@ -5,4 +5,4 @@ set -x mypy app ruff app -ruff format app --check +ruff format app --check \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 97f0dc25..3f70e430 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@chakra-ui/react": "2.8.2", "@emotion/react": "11.11.3", "@emotion/styled": "11.11.0", + "@microlink/react-json-view": "^1.23.0", "@tanstack/react-router": "1.19.1", "axios": "1.6.2", "chakra-react-select": "^4.7.6", @@ -2103,6 +2104,33 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "node_modules/@microlink/react-json-view": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.23.0.tgz", + "integrity": "sha512-HYJ1nsfO4/qn8afnAMhuk7+5a1vcjEaS8Gm5Vpr1SqdHDY0yLBJGpA+9DvKyxyVKaUkXzKXt3Mif9RcmFSdtYg==", + "dependencies": { + "flux": "~4.0.1", + "react-base16-styling": "~0.6.0", + "react-lifecycles-compat": "~3.0.4", + "react-textarea-autosize": "~8.3.2" + }, + "peerDependencies": { + "react": ">= 15", + "react-dom": ">= 15" + } + }, + "node_modules/@microlink/react-json-view/node_modules/flux": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz", + "integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==", + "dependencies": { + "fbemitter": "^3.0.0", + "fbjs": "^3.0.1" + }, + "peerDependencies": { + "react": "^15.0.2 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2936,9 +2964,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==" + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.5.tgz", + "integrity": "sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw==" }, "node_modules/@types/lodash.mergewith": { "version": "4.6.7", @@ -3069,6 +3097,11 @@ "node": ">=10" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3112,6 +3145,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base16": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", + "integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3321,6 +3359,14 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -3330,9 +3376,9 @@ } }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/d3-color": { "version": "3.1.0", @@ -3591,6 +3637,33 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fbemitter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz", + "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==", + "dependencies": { + "fbjs": "^3.0.0" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -4026,6 +4099,16 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==" + }, + "node_modules/lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==" + }, "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", @@ -4712,6 +4795,25 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4871,6 +4973,14 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4895,6 +5005,11 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/pure-color": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", + "integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -4906,6 +5021,17 @@ "node": ">=0.10.0" } }, + "node_modules/react-base16-styling": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz", + "integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==", + "dependencies": { + "base16": "^1.0.0", + "lodash.curry": "^4.0.1", + "lodash.flow": "^3.3.0", + "pure-color": "^1.2.0" + } + }, "node_modules/react-clientside-effect": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", @@ -4985,6 +5111,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, "node_modules/react-markdown": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", @@ -5122,6 +5253,22 @@ } } }, + "node_modules/react-textarea-autosize": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz", + "integrity": "sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==", + "dependencies": { + "@babel/runtime": "^7.10.2", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -5269,6 +5416,11 @@ "loose-envify": "^1.1.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5356,6 +5508,11 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5392,6 +5549,28 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", + "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", @@ -5543,6 +5722,14 @@ } } }, + "node_modules/use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", @@ -5556,6 +5743,22 @@ } } }, + "node_modules/use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", @@ -5667,6 +5870,20 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -7171,6 +7388,28 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "@microlink/react-json-view": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.23.0.tgz", + "integrity": "sha512-HYJ1nsfO4/qn8afnAMhuk7+5a1vcjEaS8Gm5Vpr1SqdHDY0yLBJGpA+9DvKyxyVKaUkXzKXt3Mif9RcmFSdtYg==", + "requires": { + "flux": "~4.0.1", + "react-base16-styling": "~0.6.0", + "react-lifecycles-compat": "~3.0.4", + "react-textarea-autosize": "~8.3.2" + }, + "dependencies": { + "flux": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz", + "integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==", + "requires": { + "fbemitter": "^3.0.0", + "fbjs": "^3.0.1" + } + } + } + }, "@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -7750,9 +7989,9 @@ "dev": true }, "@types/lodash": { - "version": "4.14.202", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", - "integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==" + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.5.tgz", + "integrity": "sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw==" }, "@types/lodash.mergewith": { "version": "4.6.7", @@ -7877,6 +8116,11 @@ "tslib": "^2.0.0" } }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -7912,6 +8156,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "base16": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", + "integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==" + }, "big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -8061,6 +8310,14 @@ "yaml": "^1.10.0" } }, + "cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "requires": { + "node-fetch": "^2.6.12" + } + }, "css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -8070,9 +8327,9 @@ } }, "csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "d3-color": { "version": "3.1.0", @@ -8255,6 +8512,33 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "fbemitter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz", + "integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==", + "requires": { + "fbjs": "^3.0.0" + } + }, + "fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "requires": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" + }, "find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -8573,6 +8857,16 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "lodash.curry": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz", + "integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==" + }, + "lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==" + }, "lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", @@ -8986,6 +9280,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9096,6 +9398,14 @@ "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, "prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9116,6 +9426,11 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "pure-color": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz", + "integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==" + }, "react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -9124,6 +9439,17 @@ "loose-envify": "^1.1.0" } }, + "react-base16-styling": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz", + "integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==", + "requires": { + "base16": "^1.0.0", + "lodash.curry": "^4.0.1", + "lodash.flow": "^3.3.0", + "pure-color": "^1.2.0" + } + }, "react-clientside-effect": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", @@ -9176,6 +9502,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, "react-markdown": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", @@ -9250,6 +9581,16 @@ "tslib": "^2.0.0" } }, + "react-textarea-autosize": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz", + "integrity": "sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==", + "requires": { + "@babel/runtime": "^7.10.2", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + } + }, "react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -9359,6 +9700,11 @@ "loose-envify": "^1.1.0" } }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9423,6 +9769,11 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -9444,6 +9795,11 @@ "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "dev": true }, + "ua-parser-js": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", + "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + }, "uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", @@ -9546,12 +9902,26 @@ "tslib": "^2.0.0" } }, + "use-composed-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", + "requires": {} + }, "use-isomorphic-layout-effect": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", "requires": {} }, + "use-latest": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.2.1.tgz", + "integrity": "sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==", + "requires": { + "use-isomorphic-layout-effect": "^1.1.1" + } + }, "use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", @@ -9598,6 +9968,20 @@ "rollup": "^4.2.0" } }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2f8ca207..ce5c94c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@chakra-ui/react": "2.8.2", "@emotion/react": "11.11.3", "@emotion/styled": "11.11.0", + "@microlink/react-json-view": "^1.23.0", "@tanstack/react-router": "1.19.1", "axios": "1.6.2", "chakra-react-select": "^4.7.6", diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index c96fd6ff..047ab7b5 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -21,8 +21,10 @@ export type { MemberUpdate } from './models/MemberUpdate'; export type { Message } from './models/Message'; export type { NewPassword } from './models/NewPassword'; export type { Skill } from './models/Skill'; +export type { SkillCreate } from './models/SkillCreate'; export type { SkillOut } from './models/SkillOut'; export type { SkillsOut } from './models/SkillsOut'; +export type { SkillUpdate } from './models/SkillUpdate'; export type { TeamChat } from './models/TeamChat'; export type { TeamCreate } from './models/TeamCreate'; export type { TeamOut } from './models/TeamOut'; @@ -33,6 +35,7 @@ export type { ThreadOut } from './models/ThreadOut'; export type { ThreadsOut } from './models/ThreadsOut'; export type { ThreadUpdate } from './models/ThreadUpdate'; export type { Token } from './models/Token'; +export type { ToolDefinitionValidate } from './models/ToolDefinitionValidate'; export type { UpdatePassword } from './models/UpdatePassword'; export type { UserCreate } from './models/UserCreate'; export type { UserCreateOpen } from './models/UserCreateOpen'; @@ -56,8 +59,10 @@ export { $MemberUpdate } from './schemas/$MemberUpdate'; export { $Message } from './schemas/$Message'; export { $NewPassword } from './schemas/$NewPassword'; export { $Skill } from './schemas/$Skill'; +export { $SkillCreate } from './schemas/$SkillCreate'; export { $SkillOut } from './schemas/$SkillOut'; export { $SkillsOut } from './schemas/$SkillsOut'; +export { $SkillUpdate } from './schemas/$SkillUpdate'; export { $TeamChat } from './schemas/$TeamChat'; export { $TeamCreate } from './schemas/$TeamCreate'; export { $TeamOut } from './schemas/$TeamOut'; @@ -68,6 +73,7 @@ export { $ThreadOut } from './schemas/$ThreadOut'; export { $ThreadsOut } from './schemas/$ThreadsOut'; export { $ThreadUpdate } from './schemas/$ThreadUpdate'; export { $Token } from './schemas/$Token'; +export { $ToolDefinitionValidate } from './schemas/$ToolDefinitionValidate'; export { $UpdatePassword } from './schemas/$UpdatePassword'; export { $UserCreate } from './schemas/$UserCreate'; export { $UserCreateOpen } from './schemas/$UserCreateOpen'; diff --git a/frontend/src/client/models/Skill.ts b/frontend/src/client/models/Skill.ts index 3ec62db4..d1555a22 100644 --- a/frontend/src/client/models/Skill.ts +++ b/frontend/src/client/models/Skill.ts @@ -5,7 +5,10 @@ export type Skill = { name: string; - description?: (string | null); + description: string; + managed?: boolean; + tool_definition?: (Record | null); id?: (number | null); + owner_id?: (number | null); }; diff --git a/frontend/src/client/models/SkillCreate.ts b/frontend/src/client/models/SkillCreate.ts new file mode 100644 index 00000000..91fcebe7 --- /dev/null +++ b/frontend/src/client/models/SkillCreate.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type SkillCreate = { + name: string; + description: string; + managed?: boolean; + tool_definition: Record; +}; + diff --git a/frontend/src/client/models/SkillOut.ts b/frontend/src/client/models/SkillOut.ts index e3565cf8..148f3e94 100644 --- a/frontend/src/client/models/SkillOut.ts +++ b/frontend/src/client/models/SkillOut.ts @@ -5,7 +5,9 @@ export type SkillOut = { name: string; - description: (string | null); + description: string; + managed?: boolean; + tool_definition?: (Record | null); id: number; }; diff --git a/frontend/src/client/models/SkillUpdate.ts b/frontend/src/client/models/SkillUpdate.ts new file mode 100644 index 00000000..5c098dee --- /dev/null +++ b/frontend/src/client/models/SkillUpdate.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type SkillUpdate = { + name?: (string | null); + description?: (string | null); + managed?: (boolean | null); + tool_definition?: (Record | null); +}; + diff --git a/frontend/src/client/models/SkillsOut.ts b/frontend/src/client/models/SkillsOut.ts index dab97540..448f597a 100644 --- a/frontend/src/client/models/SkillsOut.ts +++ b/frontend/src/client/models/SkillsOut.ts @@ -3,10 +3,10 @@ /* tslint:disable */ /* eslint-disable */ -import type { Skill } from './Skill'; +import type { SkillOut } from './SkillOut'; export type SkillsOut = { - data: Array; + data: Array; count: number; }; diff --git a/frontend/src/client/models/ToolDefinitionValidate.ts b/frontend/src/client/models/ToolDefinitionValidate.ts new file mode 100644 index 00000000..5a5e231f --- /dev/null +++ b/frontend/src/client/models/ToolDefinitionValidate.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type ToolDefinitionValidate = { + tool_definition: Record; +}; + diff --git a/frontend/src/client/schemas/$Skill.ts b/frontend/src/client/schemas/$Skill.ts index 5452b8ab..bc626976 100644 --- a/frontend/src/client/schemas/$Skill.ts +++ b/frontend/src/client/schemas/$Skill.ts @@ -9,9 +9,20 @@ export const $Skill = { isRequired: true, }, description: { + type: 'string', + isRequired: true, + }, + managed: { + type: 'boolean', + }, + tool_definition: { type: 'any-of', contains: [{ - type: 'string', + type: 'dictionary', + contains: { + properties: { + }, + }, }, { type: 'null', }], @@ -24,5 +35,13 @@ export const $Skill = { type: 'null', }], }, + owner_id: { + type: 'any-of', + contains: [{ + type: 'number', + }, { + type: 'null', + }], + }, }, } as const; diff --git a/frontend/src/client/schemas/$SkillCreate.ts b/frontend/src/client/schemas/$SkillCreate.ts new file mode 100644 index 00000000..47f47f86 --- /dev/null +++ b/frontend/src/client/schemas/$SkillCreate.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $SkillCreate = { + properties: { + name: { + type: 'string', + isRequired: true, + }, + description: { + type: 'string', + isRequired: true, + }, + managed: { + type: 'boolean', + }, + tool_definition: { + type: 'dictionary', + contains: { + properties: { + }, + }, + isRequired: true, + }, + }, +} as const; diff --git a/frontend/src/client/schemas/$SkillOut.ts b/frontend/src/client/schemas/$SkillOut.ts index dd9a28cf..fdb660e9 100644 --- a/frontend/src/client/schemas/$SkillOut.ts +++ b/frontend/src/client/schemas/$SkillOut.ts @@ -9,13 +9,23 @@ export const $SkillOut = { isRequired: true, }, description: { + type: 'string', + isRequired: true, + }, + managed: { + type: 'boolean', + }, + tool_definition: { type: 'any-of', contains: [{ - type: 'string', + type: 'dictionary', + contains: { + properties: { + }, + }, }, { type: 'null', }], - isRequired: true, }, id: { type: 'number', diff --git a/frontend/src/client/schemas/$SkillUpdate.ts b/frontend/src/client/schemas/$SkillUpdate.ts new file mode 100644 index 00000000..a91f43ac --- /dev/null +++ b/frontend/src/client/schemas/$SkillUpdate.ts @@ -0,0 +1,44 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $SkillUpdate = { + properties: { + name: { + type: 'any-of', + contains: [{ + type: 'string', + }, { + type: 'null', + }], + }, + description: { + type: 'any-of', + contains: [{ + type: 'string', + }, { + type: 'null', + }], + }, + managed: { + type: 'any-of', + contains: [{ + type: 'boolean', + }, { + type: 'null', + }], + }, + tool_definition: { + type: 'any-of', + contains: [{ + type: 'dictionary', + contains: { + properties: { + }, + }, + }, { + type: 'null', + }], + }, + }, +} as const; diff --git a/frontend/src/client/schemas/$SkillsOut.ts b/frontend/src/client/schemas/$SkillsOut.ts index 1d0f6270..bdaca116 100644 --- a/frontend/src/client/schemas/$SkillsOut.ts +++ b/frontend/src/client/schemas/$SkillsOut.ts @@ -7,7 +7,7 @@ export const $SkillsOut = { data: { type: 'array', contains: { - type: 'Skill', + type: 'SkillOut', }, isRequired: true, }, diff --git a/frontend/src/client/schemas/$ToolDefinitionValidate.ts b/frontend/src/client/schemas/$ToolDefinitionValidate.ts new file mode 100644 index 00000000..224f10ae --- /dev/null +++ b/frontend/src/client/schemas/$ToolDefinitionValidate.ts @@ -0,0 +1,16 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ToolDefinitionValidate = { + properties: { + tool_definition: { + type: 'dictionary', + contains: { + properties: { + }, + }, + isRequired: true, + }, + }, +} as const; diff --git a/frontend/src/client/services/SkillsService.ts b/frontend/src/client/services/SkillsService.ts index 0e93b933..877c5e10 100644 --- a/frontend/src/client/services/SkillsService.ts +++ b/frontend/src/client/services/SkillsService.ts @@ -2,8 +2,11 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { SkillCreate } from '../models/SkillCreate'; import type { SkillOut } from '../models/SkillOut'; import type { SkillsOut } from '../models/SkillsOut'; +import type { SkillUpdate } from '../models/SkillUpdate'; +import type { ToolDefinitionValidate } from '../models/ToolDefinitionValidate'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; @@ -13,7 +16,7 @@ export class SkillsService { /** * Read Skills - * Retrieve skills. + * Retrieve skills * @returns SkillsOut Successful Response * @throws ApiError */ @@ -37,6 +40,28 @@ export class SkillsService { }); } + /** + * Create Skill + * Create new skill. + * @returns SkillOut Successful Response + * @throws ApiError + */ + public static createSkill({ + requestBody, + }: { + requestBody: SkillCreate, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/skills/', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** * Read Skill * Get skill by ID. @@ -60,4 +85,76 @@ export class SkillsService { }); } + /** + * Update Skill + * Update a skill. + * @returns SkillOut Successful Response + * @throws ApiError + */ + public static updateSkill({ + id, + requestBody, + }: { + id: number, + requestBody: SkillUpdate, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/skills/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Delete Skill + * Delete a skill. + * @returns any Successful Response + * @throws ApiError + */ + public static deleteSkill({ + id, + }: { + id: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/skills/{id}', + path: { + 'id': id, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + + /** + * Validate Skill + * Validate skill's tool definition. + * @returns any Successful Response + * @throws ApiError + */ + public static validateSkill({ + requestBody, + }: { + requestBody: ToolDefinitionValidate, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/skills/validate', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + } diff --git a/frontend/src/components/Common/ActionsMenu.tsx b/frontend/src/components/Common/ActionsMenu.tsx index fb7b1f08..eef57aec 100644 --- a/frontend/src/components/Common/ActionsMenu.tsx +++ b/frontend/src/components/Common/ActionsMenu.tsx @@ -9,14 +9,15 @@ import { import { BsThreeDotsVertical } from "react-icons/bs" import { FiEdit, FiTrash } from "react-icons/fi" -import type { TeamOut, UserOut } from "../../client" +import type { SkillOut, TeamOut, UserOut } from "../../client" import EditUser from "../Admin/EditUser" import EditTeam from "../Teams/EditTeam" +import EditSkill from "../Skills/EditSkill" import Delete from "./DeleteAlert" interface ActionsMenuProps { type: string - value: UserOut | TeamOut + value: UserOut | TeamOut | SkillOut disabled?: boolean } @@ -37,8 +38,8 @@ const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => { { - e.stopPropagation(); - editUserModal.onOpen(); + e.stopPropagation() + editUserModal.onOpen() }} icon={} > @@ -46,8 +47,8 @@ const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => { { - e.stopPropagation(); - deleteModal.onOpen(); + e.stopPropagation() + deleteModal.onOpen() }} icon={} color="ui.danger" @@ -61,12 +62,18 @@ const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => { isOpen={editUserModal.isOpen} onClose={editUserModal.onClose} /> - ) : ( + ) : type === "Team" ? ( + ) : ( + )} { await UsersService.deleteUser({ userId: id }) } else if (type === "Team") { await TeamsService.deleteTeam({ id: id }) + } else if (type === "Skill") { + await SkillsService.deleteSkill({ id: id }) } else { throw new Error(`Unexpected type: ${type}`) } @@ -57,7 +59,9 @@ const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => { ) }, onSettled: () => { - queryClient.invalidateQueries(type === "User" ? "users" : "teams") + queryClient.invalidateQueries( + type === "User" ? "users" : type === "Team" ? "teams" : "skills", + ) }, }) diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index 3ebde5d9..46bee338 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -3,6 +3,7 @@ import { FaPlus } from "react-icons/fa" import AddUser from "../Admin/AddUser" import AddTeam from "../Teams/AddTeam" +import AddSkill from "../Skills/AddSkill" interface NavbarProps { type: string @@ -11,6 +12,7 @@ interface NavbarProps { const Navbar = ({ type }: NavbarProps) => { const addUserModal = useDisclosure() const addTeamModal = useDisclosure() + const addSkillModal = useDisclosure() return ( <> @@ -26,12 +28,22 @@ const Navbar = ({ type }: NavbarProps) => { variant="primary" gap={1} fontSize={{ base: "sm", md: "inherit" }} - onClick={type === "User" ? addUserModal.onOpen : addTeamModal.onOpen} + onClick={ + type === "User" + ? addUserModal.onOpen + : type === "Team" + ? addTeamModal.onOpen + : addSkillModal.onOpen + } > Add {type} + ) diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx index 5fa0a4e6..a35d1cab 100644 --- a/frontend/src/components/Common/SidebarItems.tsx +++ b/frontend/src/components/Common/SidebarItems.tsx @@ -5,10 +5,12 @@ import { LuNetwork } from "react-icons/lu" import { useQueryClient } from "react-query" import type { UserOut } from "../../client" +import { GiSpellBook } from "react-icons/gi" const items = [ { icon: FiHome, title: "Dashboard", path: "/" }, { icon: LuNetwork, title: "Teams", path: "/teams" }, + { icon: GiSpellBook, title: "Skills", path: "/skills" }, { icon: FiSettings, title: "User Settings", path: "/settings" }, ] diff --git a/frontend/src/components/Members/EditMember.tsx b/frontend/src/components/Members/EditMember.tsx index 4fe966f7..e2e89d4e 100644 --- a/frontend/src/components/Members/EditMember.tsx +++ b/frontend/src/components/Members/EditMember.tsx @@ -105,7 +105,7 @@ export function EditMember({ }) const updateMember = async (data: MemberUpdate) => { - await MembersService.updateMember({ + return await MembersService.updateMember({ id: member.id, teamId: teamId, requestBody: data, @@ -113,8 +113,9 @@ export function EditMember({ } const mutation = useMutation(updateMember, { - onSuccess: () => { + onSuccess: (data) => { showToast("Success!", "Team updated successfully.", "success") + reset(data) // reset isDirty after updating onClose() }, onError: (err: ApiError) => { diff --git a/frontend/src/components/Skills/AddSkill.tsx b/frontend/src/components/Skills/AddSkill.tsx new file mode 100644 index 00000000..04dc6ddc --- /dev/null +++ b/frontend/src/components/Skills/AddSkill.tsx @@ -0,0 +1,174 @@ +import { + Button, + FormControl, + FormErrorMessage, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react" +import { type SubmitHandler, useForm, Controller } from "react-hook-form" +import { useMutation, useQueryClient } from "react-query" + +import { type ApiError, SkillsService, type SkillCreate } from "../../client" +import useCustomToast from "../../hooks/useCustomToast" +import SkillEditor, { skillPlaceholder } from "./SkillEditor" +import { RxReset } from "react-icons/rx" + +interface AddSkillProps { + isOpen: boolean + onClose: () => void +} + +const AddSkill = ({ isOpen, onClose }: AddSkillProps) => { + const queryClient = useQueryClient() + const showToast = useCustomToast() + const { + register, + handleSubmit, + reset, + control, + setValue, + setError, + clearErrors, + formState: { errors, isSubmitting, isValid }, + } = useForm({ + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + name: "", + description: "", + tool_definition: skillPlaceholder, + }, + }) + + const addSkill = async (data: SkillCreate) => { + await SkillsService.createSkill({ requestBody: data }) + } + + const mutation = useMutation(addSkill, { + onSuccess: () => { + showToast("Success!", "Skill created successfully.", "success") + reset() + onClose() + }, + onError: (err: ApiError) => { + const errDetail = err.body?.detail + showToast("Something went wrong.", `${errDetail}`, "error") + }, + onSettled: () => { + queryClient.invalidateQueries("skills") + }, + }) + + const onSubmit: SubmitHandler = (data) => { + mutation.mutate(data) + } + + const resetSkillDefinitionHandler = () => { + setValue("tool_definition", skillPlaceholder) + } + + return ( + <> + + + + Add Skill + + + + Name + + {errors.name && ( + {errors.name.message} + )} + + + Description + + + ( + + + Skill Definition + + + message + ? setError("tool_definition", { message }) + : clearErrors("tool_definition") + } + value={value as object} + /> + {error?.message} + + + )} + /> + + + + + + + + + + ) +} + +export default AddSkill diff --git a/frontend/src/components/Skills/EditSkill.tsx b/frontend/src/components/Skills/EditSkill.tsx new file mode 100644 index 00000000..57d8c8df --- /dev/null +++ b/frontend/src/components/Skills/EditSkill.tsx @@ -0,0 +1,179 @@ +import { + Button, + FormControl, + FormErrorMessage, + FormLabel, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react" +import { type SubmitHandler, useForm, Controller } from "react-hook-form" + +import { useMutation, useQueryClient } from "react-query" +import { + type ApiError, + type SkillOut, + type SkillUpdate, + SkillsService, +} from "../../client" +import useCustomToast from "../../hooks/useCustomToast" +import SkillEditor, { skillPlaceholder } from "./SkillEditor" +import { RxReset } from "react-icons/rx" + +interface EditSkillProps { + skill: SkillOut + isOpen: boolean + onClose: () => void +} + +const EditSkill = ({ skill, isOpen, onClose }: EditSkillProps) => { + const queryClient = useQueryClient() + const showToast = useCustomToast() + const { + register, + handleSubmit, + reset, + control, + setValue, + setError, + clearErrors, + formState: { isSubmitting, errors, isDirty, isValid }, + } = useForm({ + mode: "onBlur", + criteriaMode: "all", + defaultValues: skill, + }) + + const updateSkill = async (data: SkillUpdate) => { + return await SkillsService.updateSkill({ id: skill.id, requestBody: data }) + } + + const mutation = useMutation(updateSkill, { + onSuccess: (data) => { + showToast("Success!", "Skill updated successfully.", "success") + reset(data) // reset isDirty after updating + onClose() + }, + onError: (err: ApiError) => { + const errDetail = err.body?.detail + showToast("Something went wrong.", `${errDetail}`, "error") + }, + onSettled: () => { + queryClient.invalidateQueries("skills") + }, + }) + + const onSubmit: SubmitHandler = async (data) => { + mutation.mutate(data) + } + + const onCancel = () => { + reset() + onClose() + } + + const resetSkillDefinitionHandler = () => { + setValue("tool_definition", skillPlaceholder) + } + + return ( + <> + + + + Edit Skill + + + + Name + + {errors.name && ( + {errors.name.message} + )} + + + Description + + + ( + + + Skill Definition + + + message + ? setError("tool_definition", { message }) + : clearErrors("tool_definition") + } + value={value as object} + /> + {error?.message} + + + )} + /> + + + + + + + + + ) +} + +export default EditSkill diff --git a/frontend/src/components/Skills/SkillEditor.tsx b/frontend/src/components/Skills/SkillEditor.tsx new file mode 100644 index 00000000..4bc4643a --- /dev/null +++ b/frontend/src/components/Skills/SkillEditor.tsx @@ -0,0 +1,87 @@ +import ReactJson from "@microlink/react-json-view" +import { useEffect } from "react" +import { + type ApiError, + SkillsService, + type ToolDefinitionValidate, +} from "../../client" +import { useMutation } from "react-query" + +export const skillPlaceholder = { + url: "https://example.com", + method: "GET", + headers: {}, + type: "function", + function: { + name: "Enter skill name", + description: "Enter skill description", + parameters: { + type: "object", + properties: { + param1: { + type: "integer", + description: "Describe this parameter", + }, + param2: { + type: "string", + enum: ["option1"], + description: "Select from the provided options", + }, + }, + required: ["param1", "param2"], + }, + }, +} + +interface SkillEditorProps { + value: object + onChange: (...event: any[]) => void + onError: (message: string | null) => void +} + +const SkillEditor = ({ + value, + onChange, + onError, + ...props +}: SkillEditorProps) => { + const validateSkill = async (data: ToolDefinitionValidate) => { + await SkillsService.validateSkill({ + requestBody: data, + }) + } + + const mutation = useMutation(validateSkill, { + onError: (err: ApiError) => { + const errDetail = err.body?.detail + onError(errDetail) + }, + onSuccess: () => { + onError(null) + }, + }) + + useEffect(() => { + mutation.mutate({ tool_definition: value }) + }, [value, mutation.mutate]) + + return ( + onChange(e.updated_src)} + onDelete={(e) => onChange(e.updated_src)} + onAdd={(e) => onChange(e.updated_src)} + /> + ) +} + +export default SkillEditor diff --git a/frontend/src/components/Teams/EditTeam.tsx b/frontend/src/components/Teams/EditTeam.tsx index 963707ca..dce41cfc 100644 --- a/frontend/src/components/Teams/EditTeam.tsx +++ b/frontend/src/components/Teams/EditTeam.tsx @@ -44,12 +44,13 @@ const EditTeam = ({ team, isOpen, onClose }: EditTeamProps) => { }) const updateTeam = async (data: TeamUpdate) => { - await TeamsService.updateTeam({ id: team.id, requestBody: data }) + return await TeamsService.updateTeam({ id: team.id, requestBody: data }) } const mutation = useMutation(updateTeam, { - onSuccess: () => { + onSuccess: (data) => { showToast("Success!", "Team updated successfully.", "success") + reset(data) // reset isDirty after updating onClose() }, onError: (err: ApiError) => { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index a1a1dcf0..416e84ae 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as RecoverPasswordImport } from './routes/recover-password' import { Route as LoginImport } from './routes/login' import { Route as LayoutImport } from './routes/_layout' import { Route as LayoutIndexImport } from './routes/_layout/index' +import { Route as LayoutSkillsImport } from './routes/_layout/skills' import { Route as LayoutSettingsImport } from './routes/_layout/settings' import { Route as LayoutAdminImport } from './routes/_layout/admin' import { Route as LayoutTeamsIndexImport } from './routes/_layout/teams.index' @@ -48,6 +49,11 @@ const LayoutIndexRoute = LayoutIndexImport.update({ getParentRoute: () => LayoutRoute, } as any) +const LayoutSkillsRoute = LayoutSkillsImport.update({ + path: '/skills', + getParentRoute: () => LayoutRoute, +} as any) + const LayoutSettingsRoute = LayoutSettingsImport.update({ path: '/settings', getParentRoute: () => LayoutRoute, @@ -96,6 +102,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsImport parentRoute: typeof LayoutImport } + '/_layout/skills': { + preLoaderRoute: typeof LayoutSkillsImport + parentRoute: typeof LayoutImport + } '/_layout/': { preLoaderRoute: typeof LayoutIndexImport parentRoute: typeof LayoutImport @@ -117,6 +127,7 @@ export const routeTree = rootRoute.addChildren([ LayoutRoute.addChildren([ LayoutAdminRoute, LayoutSettingsRoute, + LayoutSkillsRoute, LayoutIndexRoute, LayoutTeamsTeamIdRoute, LayoutTeamsIndexRoute, diff --git a/frontend/src/routes/_layout/skills.tsx b/frontend/src/routes/_layout/skills.tsx new file mode 100644 index 00000000..1f2f4714 --- /dev/null +++ b/frontend/src/routes/_layout/skills.tsx @@ -0,0 +1,107 @@ +import { createFileRoute } from "@tanstack/react-router" +import { + Flex, + Spinner, + Container, + Heading, + TableContainer, + Table, + Thead, + Tr, + Th, + Tbody, + Td, + Box, +} from "@chakra-ui/react" +import { useQuery } from "react-query" +import { SkillsService, type ApiError } from "../../client" +import ActionsMenu from "../../components/Common/ActionsMenu" +import Navbar from "../../components/Common/Navbar" +import useCustomToast from "../../hooks/useCustomToast" + +export const Route = createFileRoute("/_layout/skills")({ + component: Skills, +}) + +function Skills() { + const showToast = useCustomToast() + const { + data: skills, + isLoading, + isError, + error, + } = useQuery("skills", () => SkillsService.readSkills({})) + + if (isError) { + const errDetail = (error as ApiError).body?.detail + showToast("Something went wrong.", `${errDetail}`, "error") + } + + return ( + <> + {isLoading ? ( + // TODO: Add skeleton + + + + ) : ( + skills && ( + + + Skills Management + + + + + + + + + + + + + {skills.data.map((skill) => ( + + + + + + ))} + +
NameDescriptionActions
+ + {skill.name} + + + + {skill.description} + + + {!skill.managed ? ( + + ) : ( + "Managed" + )} +
+
+
+ ) + )} + + ) +} + +export default Skills diff --git a/frontend/src/routes/_layout/teams.index.tsx b/frontend/src/routes/_layout/teams.index.tsx index bdb69ece..e19d5092 100644 --- a/frontend/src/routes/_layout/teams.index.tsx +++ b/frontend/src/routes/_layout/teams.index.tsx @@ -11,6 +11,7 @@ import { Tbody, Td, useColorModeValue, + Box, } from "@chakra-ui/react" import { createFileRoute, useNavigate } from "@tanstack/react-router" import { useQuery } from "react-query" @@ -27,7 +28,7 @@ function Teams() { const showToast = useCustomToast() // TODO: Use theme instead of hard coding this everywhere const rowTint = useColorModeValue("blackAlpha.50", "whiteAlpha.50") - const navigate = useNavigate(); + const navigate = useNavigate() const { data: teams, isLoading, @@ -41,8 +42,8 @@ function Teams() { } const handleRowClick = (teamId: string) => { - navigate({ to: `/teams/$teamId`, params: {teamId} }); - }; + navigate({ to: "/teams/$teamId", params: { teamId } }) + } return ( <> @@ -54,46 +55,65 @@ function Teams() { ) : ( teams && ( - - Teams Management - - - - - - - - - - - - - - - {teams.data.map((team) => ( - handleRowClick(team.id.toString())} - > - - - - - - - ))} - -
IDNameDescriptionWorkflowActions
{team.id}{team.name} - {team.description || "N/A"} - - {team.workflow || "N/A"} - - -
-
-
+ + Teams Management + + + + + + + + + + + + + + {teams.data.map((team) => ( + handleRowClick(team.id.toString())} + > + + + + + + ))} + +
NameDescriptionWorkflowActions
+ + {team.name} + + + + {team.description || "N/A"} + + + {team.workflow || "N/A"} + + +
+
+ ) )}