Merge branch 'main' into st_calenfar_fix
|
|
@ -5,4 +5,26 @@ VITE_PROXY_URL=https://dev.eigent.ai
|
|||
VITE_USE_LOCAL_PROXY=false
|
||||
|
||||
# VITE_PROXY_URL=http://localhost:3001
|
||||
# VITE_USE_LOCAL_PROXY=true
|
||||
# VITE_USE_LOCAL_PROXY=true
|
||||
|
||||
TRACEROOT_TOKEN=your_traceroot_token_here
|
||||
|
||||
TRACEROOT_SERVICE_NAME=eigent
|
||||
|
||||
TRACEROOT_GITHUB_OWNER=eigent
|
||||
|
||||
TRACEROOT_GITHUB_REPO_NAME=eigent-ai
|
||||
|
||||
TRACEROOT_GITHUB_COMMIT_HASH=main
|
||||
|
||||
TRACEROOT_ENABLE_SPAN_CLOUD_EXPORT=true
|
||||
|
||||
TRACEROOT_ENABLE_LOG_CLOUD_EXPORT=true
|
||||
|
||||
TRACEROOT_ENABLE_SPAN_CONSOLE_EXPORT=false
|
||||
|
||||
TRACEROOT_ENABLE_LOG_CONSOLE_EXPORT=true
|
||||
|
||||
TRACEROOT_TRACER_VERBOSE=false
|
||||
|
||||
TRACEROOT_LOGGER_VERBOSE=false
|
||||
1
.gitignore
vendored
|
|
@ -46,3 +46,4 @@ public/
|
|||
|
||||
# Testing
|
||||
coverage/
|
||||
.traceroot-config.yaml
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from app.utils import traceroot_wrapper as traceroot
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import os
|
|||
import re
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from loguru import logger
|
||||
from app.utils import traceroot_wrapper as traceroot
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
from app.component import code
|
||||
from app.exception.exception import UserException
|
||||
from app.model.chat import Chat, HumanReply, McpServers, Status, SupplementChat
|
||||
from app.model.chat import Chat, HumanReply, McpServers, Status, SupplementChat, AddTaskRequest
|
||||
from app.service.chat_service import step_solve
|
||||
from app.service.task import (
|
||||
Action,
|
||||
|
|
@ -17,10 +16,15 @@ from app.service.task import (
|
|||
ActionInstallMcpData,
|
||||
ActionStopData,
|
||||
ActionSupplementData,
|
||||
create_task_lock,
|
||||
ActionAddTaskData,
|
||||
ActionRemoveTaskData,
|
||||
ActionSkipTaskData,
|
||||
get_or_create_task_lock,
|
||||
get_task_lock,
|
||||
)
|
||||
from app.component.environment import set_user_env_path
|
||||
from app.utils.workforce import Workforce
|
||||
from camel.tasks.task import Task
|
||||
|
||||
|
||||
router = APIRouter(tags=["chat"])
|
||||
|
|
@ -32,55 +36,103 @@ chat_logger = traceroot.get_logger('chat_controller')
|
|||
@router.post("/chat", name="start chat")
|
||||
@traceroot.trace()
|
||||
async def post(data: Chat, request: Request):
|
||||
chat_logger.info(f"Starting new chat session for task_id: {data.task_id}, user: {data.email}")
|
||||
task_lock = create_task_lock(data.task_id)
|
||||
|
||||
chat_logger.info("Starting new chat session", extra={"project_id": data.project_id, "task_id": data.task_id, "user": data.email})
|
||||
task_lock = get_or_create_task_lock(data.project_id)
|
||||
|
||||
# Set user-specific environment path for this thread
|
||||
set_user_env_path(data.env_path)
|
||||
load_dotenv(dotenv_path=data.env_path)
|
||||
|
||||
# logger.debug(f"start chat: {data.model_dump_json()}")
|
||||
|
||||
os.environ["file_save_path"] = data.file_save_path()
|
||||
os.environ["browser_port"] = str(data.browser_port)
|
||||
os.environ["OPENAI_API_KEY"] = data.api_key
|
||||
os.environ["OPENAI_API_BASE_URL"] = data.api_url or "https://api.openai.com/v1"
|
||||
os.environ["CAMEL_MODEL_LOG_ENABLED"] = "true"
|
||||
|
||||
email = re.sub(r'[\\/*?:"<>|\s]', "_", data.email.split("@")[0]).strip(".")
|
||||
camel_log = Path.home() / ".eigent" / email / ("task_" + data.task_id) / "camel_logs"
|
||||
email_sanitized = re.sub(r'[\\/*?:"<>|\s]', "_", data.email.split("@")[0]).strip(".")
|
||||
camel_log = Path.home() / ".eigent" / email_sanitized / ("task_" + data.project_id) / "camel_logs"
|
||||
camel_log.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
os.environ["CAMEL_LOG_DIR"] = str(camel_log)
|
||||
|
||||
if data.is_cloud():
|
||||
os.environ["cloud_api_key"] = data.api_key
|
||||
|
||||
chat_logger.info(f"Chat session initialized, starting streaming response for task_id: {data.task_id}")
|
||||
|
||||
# Put initial action in queue to start processing
|
||||
await task_lock.put_queue(ActionImproveData(data=data.question))
|
||||
|
||||
chat_logger.info("Chat session initialized, starting streaming response", extra={"project_id": data.project_id, "task_id": data.task_id, "log_dir": str(camel_log)})
|
||||
return StreamingResponse(step_solve(data, request, task_lock), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/chat/{id}", name="improve chat")
|
||||
@traceroot.trace()
|
||||
def improve(id: str, data: SupplementChat):
|
||||
chat_logger.info(f"Improving chat for task_id: {id} with question: {data.question}")
|
||||
chat_logger.info("Chat improvement requested", extra={"task_id": id, "question_length": len(data.question)})
|
||||
task_lock = get_task_lock(id)
|
||||
|
||||
# Allow continuing conversation even after task is done
|
||||
# This supports multi-turn conversation after complex task completion
|
||||
if task_lock.status == Status.done:
|
||||
raise UserException(code.error, "Task was done")
|
||||
# Reset status to allow processing new messages
|
||||
task_lock.status = Status.confirming
|
||||
# Clear any existing background tasks since workforce was stopped
|
||||
if hasattr(task_lock, 'background_tasks'):
|
||||
task_lock.background_tasks.clear()
|
||||
# Note: conversation_history and last_task_result are preserved
|
||||
|
||||
# Log context preservation
|
||||
if hasattr(task_lock, 'conversation_history'):
|
||||
chat_logger.info(f"[CONTEXT] Preserved {len(task_lock.conversation_history)} conversation entries")
|
||||
if hasattr(task_lock, 'last_task_result'):
|
||||
chat_logger.info(f"[CONTEXT] Preserved task result: {len(task_lock.last_task_result)} chars")
|
||||
|
||||
# Update file save path if task_id is provided
|
||||
new_folder_path = None
|
||||
if data.task_id:
|
||||
try:
|
||||
# Get current environment values needed to construct new path
|
||||
current_email = None
|
||||
|
||||
# Extract email from current file_save_path if available
|
||||
current_file_save_path = os.environ.get("file_save_path", "")
|
||||
if current_file_save_path:
|
||||
path_parts = Path(current_file_save_path).parts
|
||||
if len(path_parts) >= 3 and "eigent" in path_parts:
|
||||
eigent_index = path_parts.index("eigent")
|
||||
if eigent_index + 1 < len(path_parts):
|
||||
current_email = path_parts[eigent_index + 1]
|
||||
|
||||
# If we have the necessary information, update the file_save_path
|
||||
if current_email and id:
|
||||
# Create new path using the existing pattern: email/project_{project_id}/task_{task_id}
|
||||
new_folder_path = Path.home() / "eigent" / current_email / f"project_{id}" / f"task_{data.task_id}"
|
||||
new_folder_path.mkdir(parents=True, exist_ok=True)
|
||||
os.environ["file_save_path"] = str(new_folder_path)
|
||||
chat_logger.info(f"Updated file_save_path to: {new_folder_path}")
|
||||
|
||||
# Store the new folder path in task_lock for potential cleanup and persistence
|
||||
task_lock.new_folder_path = new_folder_path
|
||||
else:
|
||||
chat_logger.warning(f"Could not update file_save_path - email: {current_email}, project_id: {id}")
|
||||
|
||||
except Exception as e:
|
||||
chat_logger.error(f"Error updating file path for project_id: {id}, task_id: {data.task_id}: {e}")
|
||||
|
||||
asyncio.run(task_lock.put_queue(ActionImproveData(data=data.question)))
|
||||
chat_logger.info(f"Improvement request queued for task_id: {id}")
|
||||
chat_logger.info("Improvement request queued with preserved context", extra={"project_id": id})
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
@router.put("/chat/{id}", name="supplement task")
|
||||
@traceroot.trace()
|
||||
def supplement(id: str, data: SupplementChat):
|
||||
chat_logger.info(f"Supplementing task_id: {id} with additional data")
|
||||
chat_logger.info("Chat supplement requested", extra={"task_id": id})
|
||||
task_lock = get_task_lock(id)
|
||||
if task_lock.status != Status.done:
|
||||
raise UserException(code.error, "Please wait task done")
|
||||
asyncio.run(task_lock.put_queue(ActionSupplementData(data=data)))
|
||||
chat_logger.info(f"Supplement data queued for task_id: {id}")
|
||||
chat_logger.debug("Supplement data queued", extra={"task_id": id})
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
|
|
@ -88,28 +140,92 @@ def supplement(id: str, data: SupplementChat):
|
|||
@traceroot.trace()
|
||||
def stop(id: str):
|
||||
"""stop the task"""
|
||||
chat_logger.warning(f"Stopping chat session for task_id: {id}")
|
||||
chat_logger.warning("Stopping chat session", extra={"task_id": id})
|
||||
task_lock = get_task_lock(id)
|
||||
asyncio.run(task_lock.put_queue(ActionStopData(action=Action.stop)))
|
||||
chat_logger.info(f"Stop signal sent for task_id: {id}")
|
||||
chat_logger.info("Chat stop signal sent", extra={"task_id": id})
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/chat/{id}/human-reply")
|
||||
@traceroot.trace()
|
||||
def human_reply(id: str, data: HumanReply):
|
||||
chat_logger.info(f"Human reply received for task_id: {id}, agent: {data.agent}")
|
||||
chat_logger.info("Human reply received", extra={"task_id": id, "reply_length": len(data.reply)})
|
||||
task_lock = get_task_lock(id)
|
||||
asyncio.run(task_lock.put_human_input(data.agent, data.reply))
|
||||
chat_logger.info(f"Human reply processed for task_id: {id}")
|
||||
chat_logger.debug("Human reply processed", extra={"task_id": id})
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
@router.post("/chat/{id}/install-mcp")
|
||||
@traceroot.trace()
|
||||
def install_mcp(id: str, data: McpServers):
|
||||
chat_logger.info(f"Installing MCP servers for task_id: {id}, servers count: {len(data.get('mcpServers', {}))}")
|
||||
chat_logger.info("Installing MCP servers", extra={"task_id": id, "servers_count": len(data.get('mcpServers', {}))})
|
||||
task_lock = get_task_lock(id)
|
||||
asyncio.run(task_lock.put_queue(ActionInstallMcpData(action=Action.install_mcp, data=data)))
|
||||
chat_logger.info(f"MCP installation queued for task_id: {id}")
|
||||
chat_logger.info("MCP installation queued", extra={"task_id": id})
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
@router.post("/chat/{id}/add-task", name="add task to workforce")
|
||||
@traceroot.trace()
|
||||
def add_task(id: str, data: AddTaskRequest):
|
||||
"""Add a new task to the workforce"""
|
||||
chat_logger.info(f"Adding task to workforce for task_id: {id}, content: {data.content[:100]}...")
|
||||
task_lock = get_task_lock(id)
|
||||
|
||||
try:
|
||||
# Queue the add task action
|
||||
add_task_action = ActionAddTaskData(
|
||||
content=data.content,
|
||||
project_id=data.project_id,
|
||||
task_id=data.task_id,
|
||||
additional_info=data.additional_info,
|
||||
insert_position=data.insert_position
|
||||
)
|
||||
asyncio.run(task_lock.put_queue(add_task_action))
|
||||
return Response(status_code=201)
|
||||
|
||||
except Exception as e:
|
||||
chat_logger.error(f"Error adding task for task_id: {id}: {e}")
|
||||
raise UserException(code.error, f"Failed to add task: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/chat/{project_id}/remove-task/{task_id}", name="remove task from workforce")
|
||||
@traceroot.trace()
|
||||
def remove_task(project_id: str, task_id: str):
|
||||
"""Remove a task from the workforce"""
|
||||
chat_logger.info(f"Removing task {task_id} from workforce for project_id: {project_id}")
|
||||
task_lock = get_task_lock(project_id)
|
||||
|
||||
try:
|
||||
# Queue the remove task action
|
||||
remove_task_action = ActionRemoveTaskData(task_id=task_id, project_id=project_id)
|
||||
asyncio.run(task_lock.put_queue(remove_task_action))
|
||||
|
||||
chat_logger.info(f"Task removal request queued for project_id: {project_id}, removing task: {task_id}")
|
||||
return Response(status_code=204)
|
||||
|
||||
except Exception as e:
|
||||
chat_logger.error(f"Error removing task {task_id} for project_id: {project_id}: {e}")
|
||||
raise UserException(code.error, f"Failed to remove task: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/chat/{project_id}/skip-task", name="skip task in workforce")
|
||||
@traceroot.trace()
|
||||
def skip_task(project_id: str):
|
||||
"""Skip a task in the workforce"""
|
||||
chat_logger.info(f"Skipping task in workforce for project_id: {project_id}")
|
||||
task_lock = get_task_lock(project_id)
|
||||
|
||||
try:
|
||||
# Queue the skip task action
|
||||
skip_task_action = ActionSkipTaskData(project_id=project_id)
|
||||
asyncio.run(task_lock.put_queue(skip_task_action))
|
||||
|
||||
chat_logger.info(f"Task skip request queued for project_id: {project_id}")
|
||||
return Response(status_code=201)
|
||||
|
||||
except Exception as e:
|
||||
chat_logger.error(f"Error skipping task for project_id: {project_id}: {e}")
|
||||
raise UserException(code.error, f"Failed to skip task: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from app.component.model_validation import create_agent
|
||||
from camel.types import ModelType
|
||||
from app.component.error_format import normalize_error_to_openai_format
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("model_controller")
|
||||
|
||||
|
||||
router = APIRouter(tags=["model"])
|
||||
|
|
@ -26,33 +29,46 @@ class ValidateModelResponse(BaseModel):
|
|||
|
||||
|
||||
@router.post("/model/validate")
|
||||
@traceroot.trace()
|
||||
async def validate_model(request: ValidateModelRequest):
|
||||
try:
|
||||
# API key validation
|
||||
if request.api_key is not None and str(request.api_key).strip() == "":
|
||||
return ValidateModelResponse(
|
||||
is_valid=False,
|
||||
is_tool_calls=False,
|
||||
message="Invalid key. Validation failed.",
|
||||
error_code="invalid_api_key",
|
||||
error={
|
||||
"message": "Invalid key. Validation failed.",
|
||||
"""Validate model configuration and tool call support."""
|
||||
platform = request.model_platform
|
||||
model_type = request.model_type
|
||||
has_custom_url = request.url is not None
|
||||
has_config = request.model_config_dict is not None
|
||||
|
||||
logger.info("Model validation started", extra={"platform": platform, "model_type": model_type, "has_url": has_custom_url, "has_config": has_config})
|
||||
|
||||
# API key validation
|
||||
if request.api_key is not None and str(request.api_key).strip() == "":
|
||||
logger.warning("Model validation failed: empty API key", extra={"platform": platform, "model_type": model_type})
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"message": "Invalid key. Validation failed.",
|
||||
"error_code": "invalid_api_key",
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "invalid_api_key",
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
extra = request.extra_params or {}
|
||||
|
||||
logger.debug("Creating agent for validation", extra={"platform": platform, "model_type": model_type})
|
||||
agent = create_agent(
|
||||
request.model_platform,
|
||||
request.model_type,
|
||||
platform,
|
||||
model_type,
|
||||
api_key=request.api_key,
|
||||
url=request.url,
|
||||
model_config_dict=request.model_config_dict,
|
||||
**extra,
|
||||
)
|
||||
|
||||
logger.debug("Agent created, executing test step", extra={"platform": platform, "model_type": model_type})
|
||||
response = agent.step(
|
||||
input_message="""
|
||||
Get the content of https://www.camel-ai.org,
|
||||
|
|
@ -61,17 +77,23 @@ async def validate_model(request: ValidateModelRequest):
|
|||
you must call the get_website_content tool only once.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Normalize error to OpenAI-style error structure
|
||||
logger.error("Model validation failed", extra={"platform": platform, "model_type": model_type, "error": str(e)}, exc_info=True)
|
||||
message, error_code, error_obj = normalize_error_to_openai_format(e)
|
||||
|
||||
return ValidateModelResponse(
|
||||
is_valid=False,
|
||||
is_tool_calls=False,
|
||||
message=message,
|
||||
error_code=error_code,
|
||||
error=error_obj,
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"message": message,
|
||||
"error_code": error_code,
|
||||
"error": error_obj,
|
||||
}
|
||||
)
|
||||
|
||||
# Check validation results
|
||||
is_valid = bool(response)
|
||||
is_tool_calls = False
|
||||
|
||||
|
|
@ -83,7 +105,7 @@ async def validate_model(request: ValidateModelRequest):
|
|||
== "Tool execution completed successfully for https://www.camel-ai.org, Website Content: Welcome to CAMEL AI!"
|
||||
)
|
||||
|
||||
return ValidateModelResponse(
|
||||
result = ValidateModelResponse(
|
||||
is_valid=is_valid,
|
||||
is_tool_calls=is_tool_calls,
|
||||
message="Validation Success"
|
||||
|
|
@ -92,3 +114,7 @@ async def validate_model(request: ValidateModelRequest):
|
|||
error_code=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
logger.info("Model validation completed", extra={"platform": platform, "model_type": model_type, "is_valid": is_valid, "is_tool_calls": is_tool_calls})
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from typing import Literal
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import APIRouter, Response
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from app.model.chat import NewAgent, UpdateData
|
||||
from app.service.task import (
|
||||
|
|
@ -16,24 +15,32 @@ from app.service.task import (
|
|||
)
|
||||
import asyncio
|
||||
from app.component.environment import set_user_env_path
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("task_controller")
|
||||
|
||||
|
||||
router = APIRouter(tags=["task"])
|
||||
|
||||
|
||||
@router.post("/task/{id}/start", name="start task")
|
||||
@traceroot.trace()
|
||||
def start(id: str):
|
||||
task_lock = get_task_lock(id)
|
||||
logger.debug(f"start task {id}")
|
||||
logger.info("Starting task", extra={"task_id": id})
|
||||
asyncio.run(task_lock.put_queue(ActionStartData(action=Action.start)))
|
||||
logger.debug(f"start task {id} success")
|
||||
logger.info("Task started successfully", extra={"task_id": id})
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
@router.put("/task/{id}", name="update task")
|
||||
@traceroot.trace()
|
||||
def put(id: str, data: UpdateData):
|
||||
logger.info("Updating task", extra={"task_id": id, "task_items_count": len(data.task)})
|
||||
logger.debug("Update task data", extra={"task_id": id, "data": data.model_dump_json()})
|
||||
task_lock = get_task_lock(id)
|
||||
asyncio.run(task_lock.put_queue(ActionUpdateTaskData(action=Action.update_task, data=data)))
|
||||
logger.info("Task updated successfully", extra={"task_id": id})
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
|
|
@ -42,23 +49,33 @@ class TakeControl(BaseModel):
|
|||
|
||||
|
||||
@router.put("/task/{id}/take-control", name="take control pause or resume")
|
||||
@traceroot.trace()
|
||||
def take_control(id: str, data: TakeControl):
|
||||
logger.info("Task control action", extra={"task_id": id, "action": data.action})
|
||||
task_lock = get_task_lock(id)
|
||||
asyncio.run(task_lock.put_queue(ActionTakeControl(action=data.action)))
|
||||
logger.info("Task control action completed", extra={"task_id": id, "action": data.action})
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/task/{id}/add-agent", name="add new agent")
|
||||
@traceroot.trace()
|
||||
def add_agent(id: str, data: NewAgent):
|
||||
logger.info("Adding new agent to task", extra={"task_id": id, "agent_name": data.name})
|
||||
logger.debug("New agent data", extra={"task_id": id, "agent_data": data.model_dump_json()})
|
||||
# Set user-specific environment path for this thread
|
||||
set_user_env_path(data.env_path)
|
||||
load_dotenv(dotenv_path=data.env_path)
|
||||
asyncio.run(get_task_lock(id).put_queue(ActionNewAgent(**data.model_dump())))
|
||||
logger.info("Agent added to task", extra={"task_id": id, "agent_name": data.name})
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.delete("/task/stop-all", name="stop all tasks")
|
||||
@traceroot.trace()
|
||||
def stop_all():
|
||||
logger.warning("Stopping all tasks", extra={"task_count": len(task_locks)})
|
||||
for task_lock in task_locks.values():
|
||||
asyncio.run(task_lock.put_queue(ActionStopData()))
|
||||
logger.info("All tasks stopped", extra={"task_count": len(task_locks)})
|
||||
return Response(status_code=204)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
from app.utils.toolkit.notion_mcp_toolkit import NotionMCPToolkit
|
||||
from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
|
||||
from app.utils.oauth_state_manager import oauth_state_manager
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
|
||||
logger = traceroot.get_logger("tool_controller")
|
||||
router = APIRouter(tags=["task"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,22 @@ from fastapi import Request
|
|||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from app import api
|
||||
from app.component import code
|
||||
from app.exception.exception import NoPermissionException, ProgramException, TokenException
|
||||
from app.component.pydantic.i18n import trans, get_language
|
||||
from app.exception.exception import UserException
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("exception_handler")
|
||||
|
||||
|
||||
@api.exception_handler(RequestValidationError)
|
||||
async def request_exception(request: Request, e: RequestValidationError):
|
||||
if (lang := get_language(request.headers.get("Accept-Language"))) is None:
|
||||
lang = "en_US"
|
||||
logger.warning(f"Validation error on {request.url.path}: {e.errors()}")
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"code": code.form_error,
|
||||
|
|
@ -25,16 +29,19 @@ async def request_exception(request: Request, e: RequestValidationError):
|
|||
|
||||
@api.exception_handler(TokenException)
|
||||
async def token_exception(request: Request, e: TokenException):
|
||||
logger.warning(f"Token exception on {request.url.path}: {e.text}")
|
||||
return JSONResponse(content={"code": e.code, "text": e.text})
|
||||
|
||||
|
||||
@api.exception_handler(UserException)
|
||||
async def user_exception(request: Request, e: UserException):
|
||||
logger.info(f"User exception on {request.url.path}: {e.description}")
|
||||
return JSONResponse(content={"code": e.code, "text": e.description})
|
||||
|
||||
|
||||
@api.exception_handler(NoPermissionException)
|
||||
async def no_permission(request: Request, exception: NoPermissionException):
|
||||
logger.warning(f"No permission on {request.url.path}: {exception.text}")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": code.no_permission_error, "text": exception.text},
|
||||
|
|
@ -43,6 +50,7 @@ async def no_permission(request: Request, exception: NoPermissionException):
|
|||
|
||||
@api.exception_handler(ProgramException)
|
||||
async def program_exception(request: Request, exception: NoPermissionException):
|
||||
logger.error(f"Program exception on {request.url.path}: {exception.text}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"code": code.program_error, "text": exception.text},
|
||||
|
|
@ -51,8 +59,16 @@ async def program_exception(request: Request, exception: NoPermissionException):
|
|||
|
||||
@api.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled error: {exc}")
|
||||
traceback.print_exc() # output to electron log
|
||||
logger.error(
|
||||
f"Unhandled exception on {request.method} {request.url.path}: {exc}",
|
||||
exc_info=True,
|
||||
extra={
|
||||
"request_method": request.method,
|
||||
"request_path": str(request.url.path),
|
||||
"request_query": str(request.url.query),
|
||||
"client_host": request.client.host if request.client else None,
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import json
|
|||
from pathlib import Path
|
||||
import re
|
||||
from typing import Literal
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from camel.types import ModelType, RoleType
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("chat_model")
|
||||
|
||||
|
||||
class Status(str, Enum):
|
||||
|
|
@ -20,11 +22,22 @@ class ChatHistory(BaseModel):
|
|||
content: str
|
||||
|
||||
|
||||
class QuestionAnalysisResult(BaseModel):
|
||||
type: Literal["simple", "complex"] = Field(
|
||||
description="Whether this is a simple question or complex task"
|
||||
)
|
||||
answer: str | None = Field(
|
||||
default=None,
|
||||
description="Direct answer for simple questions. None for complex tasks."
|
||||
)
|
||||
|
||||
|
||||
McpServers = dict[Literal["mcpServers"], dict[str, dict]]
|
||||
|
||||
|
||||
class Chat(BaseModel):
|
||||
task_id: str
|
||||
project_id: str
|
||||
question: str
|
||||
email: str
|
||||
attaches: list[str] = []
|
||||
|
|
@ -72,7 +85,8 @@ class Chat(BaseModel):
|
|||
|
||||
def file_save_path(self, path: str | None = None):
|
||||
email = re.sub(r'[\\/*?:"<>|\s]', "_", self.email.split("@")[0]).strip(".")
|
||||
save_path = Path.home() / "eigent" / email / ("task_" + self.task_id)
|
||||
# Use project-based structure: project_{project_id}/task_{task_id}
|
||||
save_path = Path.home() / "eigent" / email / f"project_{self.project_id}" / f"task_{self.task_id}"
|
||||
if path is not None:
|
||||
save_path = save_path / path
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -82,6 +96,7 @@ class Chat(BaseModel):
|
|||
|
||||
class SupplementChat(BaseModel):
|
||||
question: str
|
||||
task_id: str | None = None
|
||||
|
||||
|
||||
class HumanReply(BaseModel):
|
||||
|
|
@ -106,6 +121,18 @@ class NewAgent(BaseModel):
|
|||
env_path: str | None = None
|
||||
|
||||
|
||||
class AddTaskRequest(BaseModel):
|
||||
content: str
|
||||
project_id: str | None = None
|
||||
task_id: str | None = None
|
||||
additional_info: dict | None = None
|
||||
insert_position: int = -1
|
||||
is_independent: bool = False
|
||||
|
||||
|
||||
class RemoveTaskRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
def sse_json(step: str, data):
|
||||
res_format = {"step": step, "data": data}
|
||||
return f"data: {json.dumps(res_format, ensure_ascii=False)}\n\n"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
from typing import Literal
|
||||
|
|
@ -8,6 +9,7 @@ from inflection import titleize
|
|||
from pydash import chain
|
||||
from app.component.debug import dump_class
|
||||
from app.component.environment import env
|
||||
from app.utils.file_utils import get_working_directory
|
||||
from app.service.task import (
|
||||
ActionImproveData,
|
||||
ActionInstallMcpData,
|
||||
|
|
@ -19,7 +21,6 @@ from camel.toolkits import AgentCommunicationToolkit, ToolkitMessageIntegration
|
|||
from app.utils.toolkit.human_toolkit import HumanToolkit
|
||||
from app.utils.toolkit.note_taking_toolkit import NoteTakingToolkit
|
||||
from app.utils.workforce import Workforce
|
||||
from loguru import logger
|
||||
from app.model.chat import Chat, NewAgent, Status, sse_json, TaskContent
|
||||
from camel.tasks import Task
|
||||
from app.utils.agent import (
|
||||
|
|
@ -40,9 +41,199 @@ from app.service.task import Action, Agents
|
|||
from app.utils.server.sync_step import sync_step
|
||||
from camel.types import ModelPlatformType
|
||||
from camel.models import ModelProcessingError
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
import os
|
||||
|
||||
logger = traceroot.get_logger("chat_service")
|
||||
|
||||
|
||||
def format_task_context(task_data: dict, seen_files: set | None = None, skip_files: bool = False) -> str:
|
||||
"""Format structured task data into a readable context string.
|
||||
|
||||
Args:
|
||||
task_data: Dictionary containing task content, result, and working directory
|
||||
seen_files: Optional set to track already-listed files and avoid duplicates (deprecated, use skip_files instead)
|
||||
skip_files: If True, skip the file listing entirely
|
||||
"""
|
||||
context_parts = []
|
||||
|
||||
if task_data.get('task_content'):
|
||||
context_parts.append(f"Previous Task: {task_data['task_content']}")
|
||||
|
||||
if task_data.get('task_result'):
|
||||
context_parts.append(f"Previous Task Result: {task_data['task_result']}")
|
||||
|
||||
# Skip file listing if requested
|
||||
if not skip_files:
|
||||
working_directory = task_data.get('working_directory')
|
||||
if working_directory:
|
||||
try:
|
||||
if os.path.exists(working_directory):
|
||||
generated_files = []
|
||||
for root, dirs, files in os.walk(working_directory):
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', '__pycache__', 'venv']]
|
||||
for file in files:
|
||||
if not file.startswith('.') and not file.endswith(('.pyc', '.tmp')):
|
||||
file_path = os.path.join(root, file)
|
||||
absolute_path = os.path.abspath(file_path)
|
||||
|
||||
# Only add if not seen before (or if we're not tracking seen files)
|
||||
if seen_files is None or absolute_path not in seen_files:
|
||||
generated_files.append(absolute_path)
|
||||
if seen_files is not None:
|
||||
seen_files.add(absolute_path)
|
||||
|
||||
if generated_files:
|
||||
context_parts.append("Generated Files from Previous Task:")
|
||||
for file_path in sorted(generated_files):
|
||||
context_parts.append(f" - {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect generated files: {e}")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
|
||||
def collect_previous_task_context(working_directory: str, previous_task_content: str, previous_task_result: str, previous_summary: str = "") -> str:
|
||||
"""
|
||||
Collect context from previous task including content, result, summary, and generated files.
|
||||
|
||||
Args:
|
||||
working_directory: The working directory to scan for generated files
|
||||
previous_task_content: The content of the previous task
|
||||
previous_task_result: The result/output of the previous task
|
||||
previous_summary: The summary of the previous task
|
||||
|
||||
Returns:
|
||||
Formatted context string to prepend to new task
|
||||
"""
|
||||
|
||||
context_parts = []
|
||||
|
||||
# Add previous task information
|
||||
context_parts.append("=== CONTEXT FROM PREVIOUS TASK ===\n")
|
||||
|
||||
# Add previous task content
|
||||
if previous_task_content:
|
||||
context_parts.append(f"Previous Task:\n{previous_task_content}\n")
|
||||
|
||||
# Add previous task summary
|
||||
if previous_summary:
|
||||
context_parts.append(f"Previous Task Summary:\n{previous_summary}\n")
|
||||
|
||||
# Add previous task result
|
||||
if previous_task_result:
|
||||
context_parts.append(f"Previous Task Result:\n{previous_task_result}\n")
|
||||
|
||||
# Collect generated files from working directory
|
||||
try:
|
||||
if os.path.exists(working_directory):
|
||||
generated_files = []
|
||||
for root, dirs, files in os.walk(working_directory):
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', '__pycache__', 'venv']]
|
||||
for file in files:
|
||||
if not file.startswith('.') and not file.endswith(('.pyc', '.tmp')):
|
||||
file_path = os.path.join(root, file)
|
||||
absolute_path = os.path.abspath(file_path)
|
||||
generated_files.append(absolute_path)
|
||||
|
||||
if generated_files:
|
||||
context_parts.append("Generated Files from Previous Task:")
|
||||
for file_path in sorted(generated_files):
|
||||
context_parts.append(f" - {file_path}")
|
||||
context_parts.append("")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect generated files: {e}")
|
||||
|
||||
context_parts.append("=== END OF PREVIOUS TASK CONTEXT ===\n")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
|
||||
def check_conversation_history_length(task_lock: TaskLock, max_length: int = 100000) -> tuple[bool, int]:
|
||||
"""
|
||||
Check if conversation history exceeds maximum length
|
||||
|
||||
Returns:
|
||||
tuple: (is_exceeded, total_length)
|
||||
"""
|
||||
if not hasattr(task_lock, 'conversation_history') or not task_lock.conversation_history:
|
||||
return False, 0
|
||||
|
||||
total_length = 0
|
||||
for entry in task_lock.conversation_history:
|
||||
total_length += len(entry.get('content', ''))
|
||||
|
||||
is_exceeded = total_length > max_length
|
||||
|
||||
if is_exceeded:
|
||||
logger.warning(f"Conversation history length {total_length} exceeds maximum {max_length}")
|
||||
|
||||
return is_exceeded, total_length
|
||||
|
||||
|
||||
def build_conversation_context(task_lock: TaskLock, header: str = "=== CONVERSATION HISTORY ===") -> str:
|
||||
"""Build conversation context from task_lock history with files listed only once at the end.
|
||||
|
||||
Args:
|
||||
task_lock: TaskLock containing conversation history
|
||||
header: Header text for the context section
|
||||
|
||||
Returns:
|
||||
Formatted context string with task history and files listed once at the end
|
||||
"""
|
||||
context = ""
|
||||
working_directory = None
|
||||
|
||||
if task_lock.conversation_history:
|
||||
context = f"{header}\n"
|
||||
|
||||
for entry in task_lock.conversation_history:
|
||||
if entry['role'] == 'task_result':
|
||||
if isinstance(entry['content'], dict):
|
||||
# Format without file listing
|
||||
formatted_context = format_task_context(entry['content'], skip_files=True)
|
||||
context += formatted_context + "\n\n"
|
||||
# Remember the working directory from the last task
|
||||
if entry['content'].get('working_directory'):
|
||||
working_directory = entry['content']['working_directory']
|
||||
else:
|
||||
context += entry['content'] + "\n"
|
||||
elif entry['role'] == 'assistant':
|
||||
context += f"Assistant: {entry['content']}\n\n"
|
||||
|
||||
# Add all generated files at the end, only once
|
||||
if working_directory:
|
||||
try:
|
||||
if os.path.exists(working_directory):
|
||||
generated_files = []
|
||||
for root, dirs, files in os.walk(working_directory):
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', '__pycache__', 'venv']]
|
||||
for file in files:
|
||||
if not file.startswith('.') and not file.endswith(('.pyc', '.tmp')):
|
||||
file_path = os.path.join(root, file)
|
||||
absolute_path = os.path.abspath(file_path)
|
||||
generated_files.append(absolute_path)
|
||||
|
||||
if generated_files:
|
||||
context += "Generated Files from Previous Tasks:\n"
|
||||
for file_path in sorted(generated_files):
|
||||
context += f" - {file_path}\n"
|
||||
context += "\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect generated files: {e}")
|
||||
|
||||
context += "\n"
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def build_context_for_workforce(task_lock: TaskLock, options: Chat) -> str:
|
||||
"""Build context information for workforce."""
|
||||
return build_conversation_context(task_lock, header="=== CONVERSATION HISTORY ===")
|
||||
|
||||
|
||||
@sync_step
|
||||
@traceroot.trace()
|
||||
async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
||||
# if True:
|
||||
# import faulthandler
|
||||
|
|
@ -52,12 +243,40 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
# faulthandler.dump_traceback_later(second)
|
||||
|
||||
start_event_loop = True
|
||||
question_agent = question_confirm_agent(options)
|
||||
|
||||
if not hasattr(task_lock, 'conversation_history'):
|
||||
task_lock.conversation_history = []
|
||||
if not hasattr(task_lock, 'last_task_result'):
|
||||
task_lock.last_task_result = ""
|
||||
if not hasattr(task_lock, 'question_agent'):
|
||||
task_lock.question_agent = None
|
||||
if not hasattr(task_lock, 'summary_generated'):
|
||||
task_lock.summary_generated = False
|
||||
|
||||
# Create or reuse persistent question_agent
|
||||
if task_lock.question_agent is None:
|
||||
task_lock.question_agent = question_confirm_agent(options)
|
||||
logger.info(f"Created new persistent question_agent for project {options.project_id}")
|
||||
else:
|
||||
logger.info(f"Reusing existing question_agent with {len(task_lock.conversation_history)} history entries")
|
||||
|
||||
question_agent = task_lock.question_agent
|
||||
|
||||
# Other variables
|
||||
camel_task = None
|
||||
workforce = None
|
||||
last_completed_task_result = "" # Track the last completed task result
|
||||
summary_task_content = "" # Track task summary
|
||||
loop_iteration = 0
|
||||
|
||||
logger.info("Starting step_solve", extra={"project_id": options.project_id, "task_id": options.task_id})
|
||||
logger.debug("Step solve options", extra={"task_id": options.task_id, "model_platform": options.model_platform})
|
||||
|
||||
while True:
|
||||
loop_iteration += 1
|
||||
|
||||
if await request.is_disconnected():
|
||||
logger.warning(f"Client disconnected for task {options.task_id}")
|
||||
logger.warning(f"Client disconnected for project {options.project_id}")
|
||||
if workforce is not None:
|
||||
if workforce._running:
|
||||
workforce.stop()
|
||||
|
|
@ -70,10 +289,10 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
break
|
||||
try:
|
||||
item = await task_lock.get_queue()
|
||||
# logger.info(f"item: {dump_class(item)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting item from queue: {e}")
|
||||
break
|
||||
logger.error("Error getting item from queue", extra={"project_id": options.project_id, "task_id": options.task_id, "error": str(e)}, exc_info=True)
|
||||
# Continue waiting instead of breaking on queue error
|
||||
continue
|
||||
|
||||
try:
|
||||
if item.action == Action.improve or start_event_loop:
|
||||
|
|
@ -87,33 +306,116 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
else:
|
||||
assert isinstance(item, ActionImproveData)
|
||||
question = item.data
|
||||
if len(question) < 12 and len(options.attaches) == 0:
|
||||
confirm = await question_confirm(question_agent, question)
|
||||
else:
|
||||
confirm = True
|
||||
|
||||
if confirm is not True:
|
||||
yield confirm
|
||||
is_exceeded, total_length = check_conversation_history_length(task_lock)
|
||||
if is_exceeded:
|
||||
logger.error("Conversation history too long", extra={"project_id": options.project_id, "current_length": total_length, "max_length": 100000})
|
||||
yield sse_json("context_too_long", {
|
||||
"message": "The conversation history is too long. Please create a new project to continue.",
|
||||
"current_length": total_length,
|
||||
"max_length": 100000
|
||||
})
|
||||
continue
|
||||
|
||||
# Simplified logic: attachments mean workforce, otherwise let agent decide
|
||||
is_complex_task: bool
|
||||
if len(options.attaches) > 0:
|
||||
# Questions with attachments always need workforce
|
||||
is_complex_task = True
|
||||
else:
|
||||
yield sse_json("confirmed", "")
|
||||
is_complex_task = await question_confirm(question_agent, question, task_lock)
|
||||
|
||||
if not is_complex_task:
|
||||
simple_answer_prompt = f"{build_conversation_context(task_lock, header='=== Previous Conversation ===')}User Query: {question}\n\nProvide a direct, helpful answer to this simple question."
|
||||
|
||||
try:
|
||||
simple_resp = question_agent.step(simple_answer_prompt)
|
||||
answer_content = simple_resp.msgs[0].content if simple_resp and simple_resp.msgs else "I understand your question, but I'm having trouble generating a response right now."
|
||||
|
||||
task_lock.add_conversation('assistant', answer_content)
|
||||
|
||||
yield sse_json("wait_confirm", {"content": answer_content, "question": question})
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating simple answer: {e}")
|
||||
yield sse_json("wait_confirm", {"content": "I encountered an error while processing your question.", "question": question})
|
||||
|
||||
# Clean up empty folder if it was created for this task
|
||||
if hasattr(task_lock, 'new_folder_path') and task_lock.new_folder_path:
|
||||
try:
|
||||
folder_path = Path(task_lock.new_folder_path)
|
||||
if folder_path.exists() and folder_path.is_dir():
|
||||
# Check if folder is empty
|
||||
if not any(folder_path.iterdir()):
|
||||
folder_path.rmdir()
|
||||
logger.info(f"Cleaned up empty folder: {folder_path}")
|
||||
# Also clean up parent project folder if it becomes empty
|
||||
project_folder = folder_path.parent
|
||||
if project_folder.exists() and not any(project_folder.iterdir()):
|
||||
project_folder.rmdir()
|
||||
logger.info(f"Cleaned up empty project folder: {project_folder}")
|
||||
else:
|
||||
logger.info(f"Folder not empty, keeping: {folder_path}")
|
||||
# Reset the folder path
|
||||
task_lock.new_folder_path = None
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up folder: {e}")
|
||||
else:
|
||||
yield sse_json("confirmed", {"question": question})
|
||||
|
||||
context_for_coordinator = build_context_for_workforce(task_lock, options)
|
||||
|
||||
(workforce, mcp) = await construct_workforce(options)
|
||||
for new_agent in options.new_agents:
|
||||
workforce.add_single_agent_worker(
|
||||
format_agent_description(new_agent), await new_agent_model(new_agent, options)
|
||||
)
|
||||
summary_task_agent = task_summary_agent(options)
|
||||
task_lock.status = Status.confirmed
|
||||
question = question + options.summary_prompt
|
||||
camel_task = Task(content=question, id=options.task_id)
|
||||
|
||||
clean_task_content = question + options.summary_prompt
|
||||
camel_task = Task(content=clean_task_content, id=options.task_id)
|
||||
if len(options.attaches) > 0:
|
||||
camel_task.additional_info = {Path(file_path).name: file_path for file_path in options.attaches}
|
||||
|
||||
sub_tasks = await asyncio.to_thread(workforce.eigent_make_sub_tasks, camel_task)
|
||||
summary_task_content = await summary_task(summary_task_agent, camel_task)
|
||||
sub_tasks = await asyncio.to_thread(
|
||||
workforce.eigent_make_sub_tasks,
|
||||
camel_task,
|
||||
context_for_coordinator
|
||||
)
|
||||
|
||||
if not task_lock.summary_generated:
|
||||
summary_task_agent = task_summary_agent(options)
|
||||
try:
|
||||
summary_task_content = await asyncio.wait_for(
|
||||
summary_task(summary_task_agent, camel_task), timeout=10
|
||||
)
|
||||
task_lock.summary_generated = True
|
||||
logger.info("Generated summary for first task", extra={"project_id": options.project_id})
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("summary_task timeout", extra={"project_id": options.project_id, "task_id": options.task_id})
|
||||
# Fallback to a minimal summary to unblock UI
|
||||
fallback_name = "Task"
|
||||
content_preview = camel_task.content if hasattr(camel_task, "content") else ""
|
||||
if content_preview is None:
|
||||
content_preview = ""
|
||||
fallback_summary = (
|
||||
(content_preview[:80] + "...") if len(content_preview) > 80 else content_preview
|
||||
)
|
||||
summary_task_content = f"{fallback_name}|{fallback_summary}"
|
||||
task_lock.summary_generated = True
|
||||
else:
|
||||
if len(question) > 100:
|
||||
summary_task_content = f"Task|{question[:97]}..."
|
||||
else:
|
||||
summary_task_content = f"Task|{question}"
|
||||
logger.info("Skipped summary generation for subsequent task", extra={"project_id": options.project_id})
|
||||
|
||||
yield to_sub_tasks(camel_task, summary_task_content)
|
||||
# tracer.stop()
|
||||
# tracer.save("trace.json")
|
||||
|
||||
# Only auto-start in debug mode
|
||||
if env("debug") == "on":
|
||||
logger.info(f"[DEBUG] Auto-starting workforce in debug mode")
|
||||
task_lock.status = Status.processing
|
||||
task = asyncio.create_task(workforce.eigent_start(sub_tasks))
|
||||
task_lock.add_background_task(task)
|
||||
|
|
@ -124,12 +426,185 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
sub_tasks = update_sub_tasks(sub_tasks, update_tasks)
|
||||
add_sub_tasks(camel_task, item.data.task)
|
||||
yield to_sub_tasks(camel_task, summary_task_content)
|
||||
elif item.action == Action.add_task:
|
||||
|
||||
# Check if this might be a misrouted second question
|
||||
if camel_task is None and workforce is None:
|
||||
continue
|
||||
|
||||
assert camel_task is not None
|
||||
if workforce is None:
|
||||
logger.error(f"Cannot add task: workforce not initialized for project {options.project_id}")
|
||||
yield sse_json("error", {"message": "Workforce not initialized. Please start the task first."})
|
||||
continue
|
||||
|
||||
# Add task to the workforce queue
|
||||
workforce.add_task(
|
||||
item.content,
|
||||
item.task_id,
|
||||
item.additional_info
|
||||
)
|
||||
|
||||
returnData = {
|
||||
"project_id": item.project_id,
|
||||
"task_id": item.task_id or (len(camel_task.subtasks) + 1)
|
||||
}
|
||||
yield sse_json("add_task", returnData)
|
||||
elif item.action == Action.remove_task:
|
||||
assert camel_task is not None
|
||||
if workforce is None:
|
||||
logger.error(f"Cannot remove task: workforce not initialized for project {options.project_id}")
|
||||
yield sse_json("error", {"message": "Workforce not initialized. Please start the task first."})
|
||||
continue
|
||||
|
||||
workforce.remove_task(item.task_id)
|
||||
returnData = {
|
||||
"project_id": item.project_id,
|
||||
"task_id": item.task_id
|
||||
}
|
||||
yield sse_json("remove_task", returnData)
|
||||
elif item.action == Action.skip_task:
|
||||
if workforce is not None and item.project_id == options.project_id:
|
||||
if workforce._state.name == 'PAUSED':
|
||||
# Resume paused workforce to skip the task
|
||||
workforce.resume()
|
||||
workforce.skip_gracefully()
|
||||
elif item.action == Action.start:
|
||||
# Check conversation history length before starting task
|
||||
is_exceeded, total_length = check_conversation_history_length(task_lock)
|
||||
if is_exceeded:
|
||||
logger.error(f"Cannot start task: conversation history too long ({total_length} chars) for project {options.project_id}")
|
||||
yield sse_json("context_too_long", {
|
||||
"message": "The conversation history is too long. Please create a new project to continue.",
|
||||
"current_length": total_length,
|
||||
"max_length": 100000
|
||||
})
|
||||
continue
|
||||
|
||||
if workforce is not None:
|
||||
if workforce._state.name == 'PAUSED':
|
||||
# Resume paused workforce - subtasks should already be loaded
|
||||
workforce.resume()
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
task_lock.status = Status.processing
|
||||
task = asyncio.create_task(workforce.eigent_start(sub_tasks))
|
||||
task_lock.add_background_task(task)
|
||||
elif item.action == Action.task_state:
|
||||
# Track completed task results for the end event
|
||||
task_id = item.data.get('task_id', 'unknown')
|
||||
task_state = item.data.get('state', 'unknown')
|
||||
task_result = item.data.get('result', '')
|
||||
|
||||
|
||||
if task_state == 'DONE' and task_result:
|
||||
last_completed_task_result = task_result
|
||||
|
||||
yield sse_json("task_state", item.data)
|
||||
elif item.action == Action.new_task_state:
|
||||
|
||||
# Log new task state details
|
||||
new_task_id = item.data.get('task_id', 'unknown')
|
||||
new_task_state = item.data.get('state', 'unknown')
|
||||
new_task_result = item.data.get('result', '')
|
||||
|
||||
|
||||
assert camel_task is not None
|
||||
|
||||
old_task_content: str = camel_task.content
|
||||
old_task_result: str = await get_task_result_with_optional_summary(camel_task, options)
|
||||
|
||||
old_task_content_clean: str = old_task_content
|
||||
if "=== CURRENT TASK ===" in old_task_content_clean:
|
||||
old_task_content_clean = old_task_content_clean.split("=== CURRENT TASK ===")[-1].strip()
|
||||
|
||||
task_lock.add_conversation('task_result', {
|
||||
'task_content': old_task_content_clean,
|
||||
'task_result': old_task_result,
|
||||
'working_directory': get_working_directory(options, task_lock)
|
||||
})
|
||||
|
||||
new_task_content = item.data.get('content', '')
|
||||
|
||||
if new_task_content:
|
||||
import time
|
||||
task_id = item.data.get('task_id', f"{int(time.time() * 1000)}-multi")
|
||||
new_camel_task = Task(content=new_task_content, id=task_id)
|
||||
if hasattr(camel_task, 'additional_info') and camel_task.additional_info:
|
||||
new_camel_task.additional_info = camel_task.additional_info
|
||||
camel_task = new_camel_task
|
||||
|
||||
# Now trigger end of previous task using stored result
|
||||
yield sse_json("end", old_task_result)
|
||||
# Always yield new_task_state first - this is not optional
|
||||
yield sse_json("new_task_state", item.data)
|
||||
# Trigger Queue Removal
|
||||
yield sse_json("remove_task", {"task_id": item.data.get("task_id")})
|
||||
|
||||
# Then handle multi-turn processing
|
||||
if workforce is not None and new_task_content:
|
||||
task_lock.status = Status.confirming
|
||||
workforce.pause()
|
||||
|
||||
try:
|
||||
is_multi_turn_complex = await question_confirm(question_agent, new_task_content, task_lock)
|
||||
|
||||
if not is_multi_turn_complex:
|
||||
simple_answer_prompt = f"{build_conversation_context(task_lock, header='=== Previous Conversation ===')}User Query: {new_task_content}\n\nProvide a direct, helpful answer to this simple question."
|
||||
|
||||
try:
|
||||
simple_resp = question_agent.step(simple_answer_prompt)
|
||||
answer_content = simple_resp.msgs[0].content if simple_resp and simple_resp.msgs else "I understand your question, but I'm having trouble generating a response right now."
|
||||
|
||||
task_lock.add_conversation('assistant', answer_content)
|
||||
|
||||
# Send response to user
|
||||
yield sse_json("confirmed", {"question": new_task_content})
|
||||
yield sse_json("wait_confirm", {"content": answer_content, "question": new_task_content})
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating simple answer in multi-turn: {e}")
|
||||
yield sse_json("wait_confirm", {"content": "I encountered an error while processing your question.", "question": new_task_content})
|
||||
|
||||
workforce.resume()
|
||||
continue # This continues the main while loop, waiting for next action
|
||||
|
||||
yield sse_json("confirmed", {"question": new_task_content})
|
||||
task_lock.status = Status.confirmed
|
||||
|
||||
context_for_multi_turn = build_context_for_workforce(task_lock, options)
|
||||
|
||||
new_sub_tasks = await workforce.handle_decompose_append_task(
|
||||
camel_task,
|
||||
reset=False,
|
||||
coordinator_context=context_for_multi_turn
|
||||
)
|
||||
|
||||
task_content_for_summary = new_task_content
|
||||
if len(task_content_for_summary) > 100:
|
||||
new_summary_content = f"Follow-up Task|{task_content_for_summary[:97]}..."
|
||||
else:
|
||||
new_summary_content = f"Follow-up Task|{task_content_for_summary}"
|
||||
|
||||
# Send the extracted events
|
||||
yield to_sub_tasks(camel_task, new_summary_content)
|
||||
|
||||
# Update the context with new task data
|
||||
sub_tasks = new_sub_tasks
|
||||
summary_task_content = new_summary_content
|
||||
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"[TRACE] Traceback: {traceback.format_exc()}")
|
||||
# Continue with existing context if decomposition fails
|
||||
yield sse_json("error", {"message": f"Failed to process task: {str(e)}"})
|
||||
else:
|
||||
if workforce is None:
|
||||
logger.warning(f"[TRACE] Workforce is None - this might be the issue")
|
||||
if not new_task_content:
|
||||
logger.warning(f"[TRACE] No new task content provided")
|
||||
elif item.action == Action.create_agent:
|
||||
yield sse_json("create_agent", item.data)
|
||||
elif item.action == Action.activate_agent:
|
||||
|
|
@ -167,9 +642,15 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
elif item.action == Action.pause:
|
||||
if workforce is not None:
|
||||
workforce.pause()
|
||||
logger.info(f"Workforce paused for project {options.project_id}")
|
||||
else:
|
||||
logger.warning(f"Cannot pause: workforce is None for project {options.project_id}")
|
||||
elif item.action == Action.resume:
|
||||
if workforce is not None:
|
||||
workforce.resume()
|
||||
logger.info(f"Workforce resumed for project {options.project_id}")
|
||||
else:
|
||||
logger.warning(f"Cannot resume: workforce is None for project {options.project_id}")
|
||||
elif item.action == Action.new_agent:
|
||||
if workforce is not None:
|
||||
workforce.pause()
|
||||
|
|
@ -180,21 +661,52 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
elif item.action == Action.end:
|
||||
assert camel_task is not None
|
||||
task_lock.status = Status.done
|
||||
yield sse_json("end", str(camel_task.result))
|
||||
final_result: str = await get_task_result_with_optional_summary(camel_task, options)
|
||||
|
||||
task_lock.last_task_result = final_result
|
||||
|
||||
task_content: str = camel_task.content
|
||||
if "=== CURRENT TASK ===" in task_content:
|
||||
task_content = task_content.split("=== CURRENT TASK ===")[-1].strip()
|
||||
|
||||
task_lock.add_conversation('task_result', {
|
||||
'task_content': task_content,
|
||||
'task_result': final_result,
|
||||
'working_directory': get_working_directory(options, task_lock)
|
||||
})
|
||||
|
||||
|
||||
yield sse_json("end", final_result)
|
||||
|
||||
if workforce is not None:
|
||||
workforce.stop_gracefully()
|
||||
break
|
||||
logger.info(f"Workforce stopped gracefully for project {options.project_id}")
|
||||
workforce = None
|
||||
else:
|
||||
logger.warning(f"Workforce already None at end action for project {options.project_id}")
|
||||
|
||||
camel_task = None
|
||||
|
||||
if question_agent is not None:
|
||||
question_agent.reset()
|
||||
logger.info(f"Reset question_agent for project {options.project_id}")
|
||||
elif item.action == Action.supplement:
|
||||
assert camel_task is not None
|
||||
task_lock.status = Status.processing
|
||||
camel_task.add_subtask(
|
||||
Task(
|
||||
content=item.data.question,
|
||||
id=f"{camel_task.id}.{len(camel_task.subtasks)}",
|
||||
|
||||
# Check if this might be a misrouted second question
|
||||
if camel_task is None:
|
||||
logger.warning(f"SUPPLEMENT action received but camel_task is None for project {options.project_id}")
|
||||
else:
|
||||
assert camel_task is not None
|
||||
task_lock.status = Status.processing
|
||||
camel_task.add_subtask(
|
||||
Task(
|
||||
content=item.data.question,
|
||||
id=f"{camel_task.id}.{len(camel_task.subtasks)}",
|
||||
)
|
||||
)
|
||||
)
|
||||
task = asyncio.create_task(workforce.eigent_start(camel_task.subtasks))
|
||||
task_lock.add_background_task(task)
|
||||
if workforce is not None:
|
||||
task = asyncio.create_task(workforce.eigent_start(camel_task.subtasks))
|
||||
task_lock.add_background_task(task)
|
||||
elif item.action == Action.budget_not_enough:
|
||||
if workforce is not None:
|
||||
workforce.pause()
|
||||
|
|
@ -204,32 +716,43 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
|
|||
if workforce._running:
|
||||
workforce.stop()
|
||||
workforce.stop_gracefully()
|
||||
logger.info(f"Workforce stopped for project {options.project_id}")
|
||||
else:
|
||||
logger.warning(f"Workforce is None at stop action for project {options.project_id}")
|
||||
await delete_task_lock(task_lock.id)
|
||||
break
|
||||
else:
|
||||
logger.warning(f"Unknown action: {item.action}")
|
||||
except ModelProcessingError as e:
|
||||
if "Budget has been exceeded" in str(e):
|
||||
logger.warning(f"Budget exceeded for task {options.task_id}, action: {item.action}")
|
||||
# workforce decompose task don't use ListenAgent, this need return sse
|
||||
if "workforce" in locals() and workforce is not None:
|
||||
workforce.pause()
|
||||
yield sse_json(Action.budget_not_enough, {"message": "budget not enouth"})
|
||||
else:
|
||||
logger.error(f"Error processing action {item.action}: {e}")
|
||||
logger.error(f"ModelProcessingError for task {options.task_id}, action {item.action}: {e}", exc_info=True)
|
||||
yield sse_json("error", {"message": str(e)})
|
||||
if "workforce" in locals() and workforce is not None and workforce._running:
|
||||
workforce.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing action {item.action}: {e}")
|
||||
logger.error(f"Unhandled exception for task {options.task_id}, action {item.action}: {e}", exc_info=True)
|
||||
yield sse_json("error", {"message": str(e)})
|
||||
# Continue processing other items instead of breaking
|
||||
|
||||
|
||||
@traceroot.trace()
|
||||
async def install_mcp(
|
||||
mcp: ListenChatAgent,
|
||||
install_mcp: ActionInstallMcpData,
|
||||
):
|
||||
mcp.add_tools(await get_mcp_tools(install_mcp.data))
|
||||
logger.info(f"Installing MCP tools: {list(install_mcp.data.get('mcpServers', {}).keys())}")
|
||||
try:
|
||||
mcp.add_tools(await get_mcp_tools(install_mcp.data))
|
||||
logger.info("MCP tools installed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error installing MCP tools: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def to_sub_tasks(task: Task, summary_task_content: str):
|
||||
|
|
@ -287,30 +810,53 @@ def add_sub_tasks(camel_task: Task, update_tasks: list[TaskContent]):
|
|||
)
|
||||
|
||||
|
||||
async def question_confirm(agent: ListenChatAgent, prompt: str) -> str | Literal[True]:
|
||||
prompt = f"""
|
||||
> **Your Role:** You are a highly capable agent. Your primary function is to analyze a user's request and determine the appropriate course of action.
|
||||
>
|
||||
> **Your Process:**
|
||||
>
|
||||
> 1. **Analyze the User's Query:** Carefully examine the user's request: `{prompt}`.
|
||||
>
|
||||
> 2. **Categorize the Query:**
|
||||
> * **Simple Query:** Is this a simple greeting, a question that can be answered directly, or a conversational interaction (e.g., "hello", "thank you")?
|
||||
> * **Complex Task:** Is this a request that requires a series of steps, code execution, or interaction with tools to complete?
|
||||
>
|
||||
> 3. **Execute Your Decision:**
|
||||
> * **For a Simple Query:** Provide a direct and helpful response.
|
||||
> * **For a Complex Task:** Your *only* response should be "yes". This will trigger a specialized workforce to handle the task. Do not include any other text, punctuation, or pleasantries.
|
||||
"""
|
||||
resp = agent.step(prompt)
|
||||
logger.info(f"resp: {agent.chat_history}")
|
||||
if resp.msgs[0].content.lower() != "yes":
|
||||
return sse_json("wait_confirm", {"content": resp.msgs[0].content})
|
||||
else:
|
||||
async def question_confirm(agent: ListenChatAgent, prompt: str, task_lock: TaskLock | None = None) -> bool:
|
||||
"""Simple question confirmation - returns True for complex tasks, False for simple questions."""
|
||||
|
||||
context_prompt = ""
|
||||
if task_lock:
|
||||
context_prompt = build_conversation_context(task_lock, header="=== Previous Conversation ===")
|
||||
|
||||
full_prompt = f"""{context_prompt}User Query: {prompt}
|
||||
|
||||
Determine if this user query is a complex task or a simple question.
|
||||
|
||||
**Complex task** (answer "yes"): Requires tools, code execution, file operations, multi-step planning, or creating/modifying content
|
||||
- Examples: "create a file", "search for X", "implement feature Y", "write code", "analyze data", "build something"
|
||||
|
||||
**Simple question** (answer "no"): Can be answered directly with knowledge or conversation history, no action needed
|
||||
- Examples: greetings ("hello", "hi"), fact queries ("what is X?"), clarifications ("what did you mean?"), status checks ("how are you?")
|
||||
|
||||
Answer only "yes" or "no". Do not provide any explanation.
|
||||
|
||||
Is this a complex task? (yes/no):"""
|
||||
|
||||
try:
|
||||
resp = agent.step(full_prompt)
|
||||
|
||||
if not resp or not resp.msgs or len(resp.msgs) == 0:
|
||||
logger.warning("No response from agent, defaulting to complex task")
|
||||
return True
|
||||
|
||||
content = resp.msgs[0].content
|
||||
if not content:
|
||||
logger.warning("Empty content from agent, defaulting to complex task")
|
||||
return True
|
||||
|
||||
normalized = content.strip().lower()
|
||||
is_complex = "yes" in normalized
|
||||
|
||||
logger.info(f"Question confirm result: {'complex task' if is_complex else 'simple question'}",
|
||||
extra={"response": content, "is_complex": is_complex})
|
||||
|
||||
return is_complex
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in question_confirm: {e}")
|
||||
return True
|
||||
|
||||
|
||||
@traceroot.trace()
|
||||
async def summary_task(agent: ListenChatAgent, task: Task) -> str:
|
||||
prompt = f"""The user's task is:
|
||||
---
|
||||
|
|
@ -324,13 +870,100 @@ Your instructions are:
|
|||
Example format: "Task Name|This is the summary of the task."
|
||||
Do not include any other text or formatting.
|
||||
"""
|
||||
logger.debug("Generating task summary", extra={"task_id": task.id})
|
||||
try:
|
||||
res = agent.step(prompt)
|
||||
summary = res.msgs[0].content
|
||||
logger.info("Task summary generated", extra={"summary": summary})
|
||||
return summary
|
||||
except Exception as e:
|
||||
logger.error("Error generating task summary", extra={"error": str(e)}, exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str:
|
||||
"""
|
||||
Summarize the aggregated results from all subtasks into a concise summary.
|
||||
|
||||
Args:
|
||||
agent: The summary agent to use
|
||||
task: The main task containing subtasks and their aggregated results
|
||||
|
||||
Returns:
|
||||
A concise summary of all subtask results
|
||||
"""
|
||||
subtasks_info = ""
|
||||
for i, subtask in enumerate(task.subtasks, 1):
|
||||
subtasks_info += f"\n**Subtask {i}**\n"
|
||||
subtasks_info += f"Description: {subtask.content}\n"
|
||||
subtasks_info += f"Result: {subtask.result or 'No result'}\n"
|
||||
subtasks_info += "---\n"
|
||||
|
||||
prompt = f"""You are a professional summarizer. Summarize the results of the following subtasks.
|
||||
|
||||
Main Task: {task.content}
|
||||
|
||||
Subtasks (with descriptions and results):
|
||||
---
|
||||
{subtasks_info}
|
||||
---
|
||||
|
||||
Instructions:
|
||||
1. Provide a concise summary of what was accomplished
|
||||
2. Highlight key findings or outputs from each subtask
|
||||
3. Mention any important files created or actions taken
|
||||
4. Use bullet points or sections for clarity
|
||||
5. DO NOT repeat the task name in your summary - go straight to the results
|
||||
6. Keep it professional but conversational
|
||||
|
||||
Summary:
|
||||
"""
|
||||
|
||||
res = agent.step(prompt)
|
||||
logger.info(f"summary_task: {res.msgs[0].content}")
|
||||
return res.msgs[0].content
|
||||
summary = res.msgs[0].content
|
||||
|
||||
logger.info(f"Generated subtasks summary for task {task.id} with {len(task.subtasks)} subtasks")
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
async def get_task_result_with_optional_summary(task: Task, options: Chat) -> str:
|
||||
"""
|
||||
Get the task result, with LLM summary if there are multiple subtasks.
|
||||
|
||||
Args:
|
||||
task: The task to get result from
|
||||
options: Chat options for creating summary agent
|
||||
|
||||
Returns:
|
||||
The task result (summarized if multiple subtasks, raw otherwise)
|
||||
"""
|
||||
result = str(task.result or "")
|
||||
|
||||
if task.subtasks and len(task.subtasks) > 1:
|
||||
logger.info(f"Task {task.id} has {len(task.subtasks)} subtasks, generating summary")
|
||||
try:
|
||||
summary_agent = task_summary_agent(options)
|
||||
summarized_result = await summary_subtasks_result(summary_agent, task)
|
||||
result = summarized_result
|
||||
logger.info(f"Successfully generated summary for task {task.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate summary for task {task.id}: {e}")
|
||||
elif task.subtasks and len(task.subtasks) == 1:
|
||||
logger.info(f"Task {task.id} has only 1 subtask, skipping LLM summary")
|
||||
if result and "--- Subtask" in result and "Result ---" in result:
|
||||
parts = result.split("Result ---", 1)
|
||||
if len(parts) > 1:
|
||||
result = parts[1].strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@traceroot.trace()
|
||||
async def construct_workforce(options: Chat) -> tuple[Workforce, ListenChatAgent]:
|
||||
working_directory = options.file_save_path()
|
||||
logger.info("Constructing workforce", extra={"project_id": options.project_id, "task_id": options.task_id})
|
||||
working_directory = get_working_directory(options)
|
||||
logger.debug("Working directory set", extra={"working_directory": working_directory})
|
||||
[coordinator_agent, task_agent] = [
|
||||
agent_model(
|
||||
key,
|
||||
|
|
@ -339,8 +972,8 @@ async def construct_workforce(options: Chat) -> tuple[Workforce, ListenChatAgent
|
|||
[
|
||||
*(
|
||||
ToolkitMessageIntegration(
|
||||
message_handler=HumanToolkit(options.task_id, key).send_message_to_user
|
||||
).register_toolkits(NoteTakingToolkit(options.task_id, working_directory=working_directory))
|
||||
message_handler=HumanToolkit(options.project_id, key).send_message_to_user
|
||||
).register_toolkits(NoteTakingToolkit(options.project_id, working_directory=working_directory))
|
||||
).get_tools()
|
||||
],
|
||||
)
|
||||
|
|
@ -373,11 +1006,11 @@ The current date is {datetime.date.today()}. For any date-related tasks, you MUS
|
|||
""",
|
||||
options,
|
||||
[
|
||||
*HumanToolkit.get_can_use_tools(options.task_id, Agents.new_worker_agent),
|
||||
*HumanToolkit.get_can_use_tools(options.project_id, Agents.new_worker_agent),
|
||||
*(
|
||||
ToolkitMessageIntegration(
|
||||
message_handler=HumanToolkit(options.task_id, Agents.new_worker_agent).send_message_to_user
|
||||
).register_toolkits(NoteTakingToolkit(options.task_id, working_directory=working_directory))
|
||||
message_handler=HumanToolkit(options.project_id, Agents.new_worker_agent).send_message_to_user
|
||||
).register_toolkits(NoteTakingToolkit(options.project_id, working_directory=working_directory))
|
||||
).get_tools(),
|
||||
],
|
||||
)
|
||||
|
|
@ -402,7 +1035,7 @@ The current date is {datetime.date.today()}. For any date-related tasks, you MUS
|
|||
model_platform_enum = None
|
||||
|
||||
workforce = Workforce(
|
||||
options.task_id,
|
||||
options.project_id,
|
||||
"A workforce",
|
||||
graceful_shutdown_timeout=3, # 30 seconds for debugging
|
||||
share_memory=False,
|
||||
|
|
@ -481,10 +1114,13 @@ def format_agent_description(agent_data: NewAgent | ActionNewAgent) -> str:
|
|||
return " ".join(description_parts)
|
||||
|
||||
|
||||
@traceroot.trace()
|
||||
async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat):
|
||||
working_directory = options.file_save_path()
|
||||
logger.info("Creating new agent", extra={"agent_name": data.name, "project_id": options.project_id, "task_id": options.task_id})
|
||||
logger.debug("New agent data", extra={"agent_data": data.model_dump_json()})
|
||||
working_directory = get_working_directory(options)
|
||||
tool_names = []
|
||||
tools = [*await get_toolkits(data.tools, data.name, options.task_id)]
|
||||
tools = [*await get_toolkits(data.tools, data.name, options.project_id)]
|
||||
for item in data.tools:
|
||||
tool_names.append(titleize(item))
|
||||
if data.mcp_tools is not None:
|
||||
|
|
@ -492,7 +1128,8 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat):
|
|||
for item in data.mcp_tools["mcpServers"].keys():
|
||||
tool_names.append(titleize(item))
|
||||
for item in tools:
|
||||
logger.debug(f"new agent function tool ====== {item.func.__name__}")
|
||||
logger.debug(f"Agent {data.name} tool: {item.func.__name__}")
|
||||
logger.info(f"Agent {data.name} created with {len(tools)} tools: {tool_names}")
|
||||
# Enhanced system message with platform information
|
||||
enhanced_description = f"""{data.description}
|
||||
- You are now working in system {platform.system()} with architecture
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from typing_extensions import Any, Literal, TypedDict
|
||||
from typing import List, Dict, Optional
|
||||
from pydantic import BaseModel
|
||||
from app.exception.exception import ProgramException
|
||||
from app.model.chat import McpServers, Status, SupplementChat, Chat, UpdateData
|
||||
|
|
@ -9,13 +10,16 @@ from contextlib import contextmanager
|
|||
from contextvars import ContextVar
|
||||
from datetime import datetime, timedelta
|
||||
import weakref
|
||||
from loguru import logger
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("task_service")
|
||||
|
||||
|
||||
class Action(str, Enum):
|
||||
improve = "improve" # user -> backend
|
||||
update_task = "update_task" # user -> backend
|
||||
task_state = "task_state" # backend -> user
|
||||
new_task_state = "new_task_state" # backend -> user
|
||||
start = "start" # user -> backend
|
||||
create_agent = "create_agent" # backend -> user
|
||||
activate_agent = "activate_agent" # backend -> user
|
||||
|
|
@ -36,6 +40,9 @@ class Action(str, Enum):
|
|||
resume = "resume" # user -> backend user take control
|
||||
new_agent = "new_agent" # user -> backend
|
||||
budget_not_enough = "budget_not_enough" # backend -> user
|
||||
add_task = "add_task" # user -> backend
|
||||
remove_task = "remove_task" # user -> backend
|
||||
skip_task = "skip_task" # user -> backend
|
||||
|
||||
|
||||
class ActionImproveData(BaseModel):
|
||||
|
|
@ -56,6 +63,10 @@ class ActionTaskStateData(BaseModel):
|
|||
action: Literal[Action.task_state] = Action.task_state
|
||||
data: dict[Literal["task_id", "content", "state", "result", "failure_count"], str | int]
|
||||
|
||||
class ActionNewTaskStateData(BaseModel):
|
||||
action: Literal[Action.new_task_state] = Action.new_task_state
|
||||
data: dict[Literal["task_id", "content", "state", "result", "failure_count"], str | int]
|
||||
|
||||
|
||||
class ActionAskData(BaseModel):
|
||||
action: Literal[Action.ask] = Action.ask
|
||||
|
|
@ -169,6 +180,26 @@ class ActionBudgetNotEnough(BaseModel):
|
|||
action: Literal[Action.budget_not_enough] = Action.budget_not_enough
|
||||
|
||||
|
||||
class ActionAddTaskData(BaseModel):
|
||||
action: Literal[Action.add_task] = Action.add_task
|
||||
content: str
|
||||
project_id: str | None = None
|
||||
task_id: str | None = None
|
||||
additional_info: dict | None = None
|
||||
insert_position: int = -1
|
||||
|
||||
|
||||
class ActionRemoveTaskData(BaseModel):
|
||||
action: Literal[Action.remove_task] = Action.remove_task
|
||||
task_id: str
|
||||
project_id: str
|
||||
|
||||
|
||||
class ActionSkipTaskData(BaseModel):
|
||||
action: Literal[Action.skip_task] = Action.skip_task
|
||||
project_id: str
|
||||
|
||||
|
||||
ActionData = (
|
||||
ActionImproveData
|
||||
| ActionStartData
|
||||
|
|
@ -192,6 +223,9 @@ ActionData = (
|
|||
| ActionTakeControl
|
||||
| ActionNewAgent
|
||||
| ActionBudgetNotEnough
|
||||
| ActionAddTaskData
|
||||
| ActionRemoveTaskData
|
||||
| ActionSkipTaskData
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -221,6 +255,16 @@ class TaskLock:
|
|||
background_tasks: set[asyncio.Task]
|
||||
"""Track all background tasks for cleanup"""
|
||||
|
||||
# Context management fields
|
||||
conversation_history: List[Dict[str, Any]]
|
||||
"""Store conversation history for context"""
|
||||
last_task_result: str
|
||||
"""Store the last task execution result"""
|
||||
question_agent: Optional[Any]
|
||||
"""Persistent question confirmation agent"""
|
||||
summary_generated: bool
|
||||
"""Track if summary has been generated for this project"""
|
||||
|
||||
def __init__(self, id: str, queue: asyncio.Queue, human_input: dict) -> None:
|
||||
self.id = id
|
||||
self.queue = queue
|
||||
|
|
@ -229,6 +273,12 @@ class TaskLock:
|
|||
self.last_accessed = datetime.now()
|
||||
self.background_tasks = set()
|
||||
|
||||
# Initialize context management fields
|
||||
self.conversation_history = []
|
||||
self.last_task_result = ""
|
||||
self.last_task_summary = ""
|
||||
self.question_agent = None
|
||||
|
||||
async def put_queue(self, data: ActionData):
|
||||
self.last_accessed = datetime.now()
|
||||
await self.queue.put(data)
|
||||
|
|
@ -262,6 +312,25 @@ class TaskLock:
|
|||
pass
|
||||
self.background_tasks.clear()
|
||||
|
||||
def add_conversation(self, role: str, content: str | dict):
|
||||
"""Add a conversation entry to history"""
|
||||
self.conversation_history.append({
|
||||
'role': role,
|
||||
'content': content,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
def get_recent_context(self, max_entries: int = None) -> str:
|
||||
"""Get recent conversation context as a formatted string"""
|
||||
if not self.conversation_history:
|
||||
return ""
|
||||
|
||||
context = "=== Recent Conversation ===\n"
|
||||
history_to_use = self.conversation_history if max_entries is None else self.conversation_history[-max_entries:]
|
||||
for entry in history_to_use:
|
||||
context += f"{entry['role']}: {entry['content']}\n"
|
||||
return context
|
||||
|
||||
|
||||
task_locks = dict[str, TaskLock]()
|
||||
# Cleanup task for removing stale task locks
|
||||
|
|
@ -275,6 +344,11 @@ def get_task_lock(id: str) -> TaskLock:
|
|||
return task_locks[id]
|
||||
|
||||
|
||||
def get_task_lock_if_exists(id: str) -> TaskLock | None:
|
||||
"""Get task lock if it exists, otherwise return None"""
|
||||
return task_locks.get(id)
|
||||
|
||||
|
||||
def create_task_lock(id: str) -> TaskLock:
|
||||
if id in task_locks:
|
||||
raise ProgramException("Task already exists")
|
||||
|
|
@ -288,6 +362,13 @@ def create_task_lock(id: str) -> TaskLock:
|
|||
return task_locks[id]
|
||||
|
||||
|
||||
def get_or_create_task_lock(id: str) -> TaskLock:
|
||||
"""Get existing task lock or create a new one if it doesn't exist"""
|
||||
if id in task_locks:
|
||||
return task_locks[id]
|
||||
return create_task_lock(id)
|
||||
|
||||
|
||||
async def delete_task_lock(id: str):
|
||||
if id not in task_locks:
|
||||
raise ProgramException("Task not found")
|
||||
|
|
|
|||
0
backend/app/utils/__init__.py
Normal file
|
|
@ -6,7 +6,7 @@ from threading import Event
|
|||
import traceback
|
||||
from typing import Any, Callable, Dict, List, Tuple
|
||||
import uuid
|
||||
from app.utils import traceroot_wrapper as traceroot
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
from camel.agents import ChatAgent
|
||||
from camel.agents.chat_agent import StreamingChatAgentResponse, AsyncStreamingChatAgentResponse
|
||||
from camel.agents._types import ToolCallRequest
|
||||
|
|
@ -18,6 +18,7 @@ from camel.terminators import ResponseTerminator
|
|||
from camel.toolkits import FunctionTool, RegisteredAgentToolkit
|
||||
from camel.types.agents import ToolCallingRecord
|
||||
from app.component.environment import env
|
||||
from app.utils.file_utils import get_working_directory
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from app.utils.toolkit.hybrid_browser_toolkit import HybridBrowserToolkit
|
||||
from app.utils.toolkit.excel_toolkit import ExcelToolkit
|
||||
|
|
@ -50,7 +51,6 @@ from camel.types import ModelPlatformType, ModelType
|
|||
from camel.toolkits import MCPToolkit, ToolkitMessageIntegration
|
||||
import datetime
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
from app.model.chat import Chat, McpServers
|
||||
|
||||
# Create traceroot logger for agent tracking
|
||||
|
|
@ -173,7 +173,6 @@ class ListenChatAgent(ChatAgent):
|
|||
except Exception as e:
|
||||
res = None
|
||||
error_info = e
|
||||
logger.exception(e)
|
||||
traceroot_logger.error(f"Agent {self.agent_name} unexpected error in step: {e}", exc_info=True)
|
||||
message = f"Error processing message: {e!s}"
|
||||
total_tokens = 0
|
||||
|
|
@ -248,8 +247,7 @@ class ListenChatAgent(ChatAgent):
|
|||
except Exception as e:
|
||||
res = None
|
||||
error_info = e
|
||||
logger.exception(e)
|
||||
traceroot_logger.error(f"Agent {self.agent_name} unexpected error in step: {e}", exc_info=True)
|
||||
traceroot_logger.error(f"Agent {self.agent_name} unexpected error in async step: {e}", exc_info=True)
|
||||
message = f"Error processing message: {e!s}"
|
||||
total_tokens = 0
|
||||
|
||||
|
|
@ -325,6 +323,17 @@ class ListenChatAgent(ChatAgent):
|
|||
else:
|
||||
result = raw_result
|
||||
mask_flag = False
|
||||
# Prepare result message with truncation
|
||||
if isinstance(result, str):
|
||||
result_msg = result
|
||||
else:
|
||||
result_str = repr(result)
|
||||
MAX_RESULT_LENGTH = 500
|
||||
if len(result_str) > MAX_RESULT_LENGTH:
|
||||
result_msg = result_str[:MAX_RESULT_LENGTH] + f"... (truncated, total length: {len(result_str)} chars)"
|
||||
else:
|
||||
result_msg = result_str
|
||||
|
||||
asyncio.create_task(
|
||||
task_lock.put_queue(
|
||||
ActionDeactivateToolkitData(
|
||||
|
|
@ -333,7 +342,7 @@ class ListenChatAgent(ChatAgent):
|
|||
"process_task_id": self.process_task_id,
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": func_name,
|
||||
"message": result if isinstance(result, str) else repr(result),
|
||||
"message": result_msg,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -343,9 +352,7 @@ class ListenChatAgent(ChatAgent):
|
|||
error_msg = f"Error executing tool '{func_name}': {e!s}"
|
||||
result = f"Tool execution failed: {error_msg}"
|
||||
mask_flag = False
|
||||
logger.debug(error_msg)
|
||||
traceroot_logger.error(f"Tool execution failed for {func_name}: {e}")
|
||||
traceback.print_exc()
|
||||
traceroot_logger.error(f"Tool execution failed for {func_name}: {e}", exc_info=True)
|
||||
|
||||
return self._record_tool_calling(func_name, args, result, tool_call_id, mask_output=mask_flag)
|
||||
|
||||
|
|
@ -405,9 +412,18 @@ class ListenChatAgent(ChatAgent):
|
|||
# Capture the error message to prevent framework crash
|
||||
error_msg = f"Error executing async tool '{func_name}': {e!s}"
|
||||
result = {"error": error_msg}
|
||||
logger.warning(error_msg)
|
||||
traceroot_logger.error(f"Async tool execution failed for {func_name}: {e}")
|
||||
traceback.print_exc()
|
||||
traceroot_logger.error(f"Async tool execution failed for {func_name}: {e}", exc_info=True)
|
||||
|
||||
# Prepare result message with truncation
|
||||
if isinstance(result, str):
|
||||
result_msg = result
|
||||
else:
|
||||
result_str = repr(result)
|
||||
MAX_RESULT_LENGTH = 500
|
||||
if len(result_str) > MAX_RESULT_LENGTH:
|
||||
result_msg = result_str[:MAX_RESULT_LENGTH] + f"... (truncated, total length: {len(result_str)} chars)"
|
||||
else:
|
||||
result_msg = result_str
|
||||
|
||||
await task_lock.put_queue(
|
||||
ActionDeactivateToolkitData(
|
||||
|
|
@ -416,7 +432,7 @@ class ListenChatAgent(ChatAgent):
|
|||
"process_task_id": self.process_task_id,
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": func_name,
|
||||
"message": result if isinstance(result, str) else repr(result),
|
||||
"message": result_msg,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -429,7 +445,7 @@ class ListenChatAgent(ChatAgent):
|
|||
|
||||
# Clone tools and collect toolkits that need registration
|
||||
cloned_tools, toolkits_to_register = self._clone_tools()
|
||||
|
||||
|
||||
new_agent = ListenChatAgent(
|
||||
api_task_id=self.api_task_id,
|
||||
agent_name=self.agent_name,
|
||||
|
|
@ -445,7 +461,6 @@ class ListenChatAgent(ChatAgent):
|
|||
response_terminators=self.response_terminators,
|
||||
scheduling_strategy=self.model_backend.scheduling_strategy.__name__,
|
||||
max_iteration=self.max_iteration,
|
||||
agent_id=self.agent_id,
|
||||
stop_event=self.stop_event,
|
||||
tool_execution_timeout=self.tool_execution_timeout,
|
||||
mask_tool_output=self.mask_tool_output,
|
||||
|
|
@ -476,9 +491,9 @@ def agent_model(
|
|||
tool_names: list[str] | None = None,
|
||||
toolkits_to_register_agent: list[RegisteredAgentToolkit] | None = None,
|
||||
):
|
||||
task_lock = get_task_lock(options.task_id)
|
||||
task_lock = get_task_lock(options.project_id)
|
||||
agent_id = str(uuid.uuid4())
|
||||
traceroot_logger.info(f"Creating agent: {agent_name} with id: {agent_id} for task: {options.task_id}")
|
||||
traceroot_logger.info(f"Creating agent: {agent_name} with id: {agent_id} for project: {options.project_id}")
|
||||
asyncio.create_task(
|
||||
task_lock.put_queue(
|
||||
ActionCreateAgentData(data={"agent_name": agent_name, "agent_id": agent_id, "tools": tool_names or []})
|
||||
|
|
@ -486,7 +501,7 @@ def agent_model(
|
|||
)
|
||||
|
||||
return ListenChatAgent(
|
||||
options.task_id,
|
||||
options.project_id,
|
||||
agent_name,
|
||||
system_message,
|
||||
model=ModelFactory.create(
|
||||
|
|
@ -495,7 +510,7 @@ def agent_model(
|
|||
api_key=options.api_key,
|
||||
url=options.api_url,
|
||||
model_config_dict={
|
||||
"user": str(options.task_id),
|
||||
"user": str(options.project_id),
|
||||
}
|
||||
if options.is_cloud()
|
||||
else None,
|
||||
|
|
@ -533,24 +548,24 @@ def task_summary_agent(options: Chat):
|
|||
|
||||
@traceroot.trace()
|
||||
async def developer_agent(options: Chat):
|
||||
working_directory = options.file_save_path()
|
||||
traceroot_logger.info(f"Creating developer agent for task: {options.task_id} in directory: {working_directory}")
|
||||
working_directory = get_working_directory(options)
|
||||
traceroot_logger.info(f"Creating developer agent for project: {options.project_id} in directory: {working_directory}")
|
||||
message_integration = ToolkitMessageIntegration(
|
||||
message_handler=HumanToolkit(options.task_id, Agents.developer_agent).send_message_to_user
|
||||
message_handler=HumanToolkit(options.project_id, Agents.developer_agent).send_message_to_user
|
||||
)
|
||||
note_toolkit = NoteTakingToolkit(
|
||||
api_task_id=options.task_id, agent_name=Agents.developer_agent, working_directory=working_directory
|
||||
api_task_id=options.project_id, agent_name=Agents.developer_agent, working_directory=working_directory
|
||||
)
|
||||
note_toolkit = message_integration.register_toolkits(note_toolkit)
|
||||
web_deploy_toolkit = WebDeployToolkit(api_task_id=options.task_id)
|
||||
web_deploy_toolkit = WebDeployToolkit(api_task_id=options.project_id)
|
||||
web_deploy_toolkit = message_integration.register_toolkits(web_deploy_toolkit)
|
||||
screenshot_toolkit = ScreenshotToolkit(options.task_id, working_directory=working_directory)
|
||||
screenshot_toolkit = ScreenshotToolkit(options.project_id, working_directory=working_directory)
|
||||
screenshot_toolkit = message_integration.register_toolkits(screenshot_toolkit)
|
||||
|
||||
terminal_toolkit = TerminalToolkit(options.task_id, Agents.document_agent, safe_mode=True, clone_current_env=False)
|
||||
terminal_toolkit = TerminalToolkit(options.project_id, Agents.document_agent, safe_mode=True, clone_current_env=False)
|
||||
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
|
||||
tools = [
|
||||
*HumanToolkit.get_can_use_tools(options.task_id, Agents.developer_agent),
|
||||
*HumanToolkit.get_can_use_tools(options.project_id, Agents.developer_agent),
|
||||
*note_toolkit.get_tools(),
|
||||
*web_deploy_toolkit.get_tools(),
|
||||
*terminal_toolkit.get_tools(),
|
||||
|
|
@ -704,14 +719,14 @@ these tips to maximize your effectiveness:
|
|||
|
||||
@traceroot.trace()
|
||||
def search_agent(options: Chat):
|
||||
working_directory = options.file_save_path()
|
||||
traceroot_logger.info(f"Creating search agent for task: {options.task_id} in directory: {working_directory}")
|
||||
working_directory = get_working_directory(options)
|
||||
traceroot_logger.info(f"Creating search agent for project: {options.project_id} in directory: {working_directory}")
|
||||
message_integration = ToolkitMessageIntegration(
|
||||
message_handler=HumanToolkit(options.task_id, Agents.search_agent).send_message_to_user
|
||||
message_handler=HumanToolkit(options.project_id, Agents.search_agent).send_message_to_user
|
||||
)
|
||||
|
||||
web_toolkit_custom = HybridBrowserToolkit(
|
||||
options.task_id,
|
||||
options.project_id,
|
||||
headless=False,
|
||||
browser_log_to_file=True,
|
||||
stealth=True,
|
||||
|
|
@ -731,12 +746,14 @@ def search_agent(options: Chat):
|
|||
],
|
||||
)
|
||||
|
||||
# Save reference before registering for toolkits_to_register_agent
|
||||
web_toolkit_for_agent_registration = web_toolkit_custom
|
||||
web_toolkit_custom = message_integration.register_toolkits(web_toolkit_custom)
|
||||
terminal_toolkit = TerminalToolkit(options.task_id, Agents.search_agent, safe_mode=True, clone_current_env=False)
|
||||
terminal_toolkit = TerminalToolkit(options.project_id, Agents.search_agent, safe_mode=True, clone_current_env=False)
|
||||
terminal_toolkit = message_integration.register_functions([terminal_toolkit.shell_exec])
|
||||
note_toolkit = NoteTakingToolkit(options.task_id, Agents.search_agent, working_directory=working_directory)
|
||||
note_toolkit = NoteTakingToolkit(options.project_id, Agents.search_agent, working_directory=working_directory)
|
||||
note_toolkit = message_integration.register_toolkits(note_toolkit)
|
||||
search_tools = SearchToolkit.get_can_use_tools(options.task_id)
|
||||
search_tools = SearchToolkit.get_can_use_tools(options.project_id)
|
||||
# Only register search tools if any are available
|
||||
if search_tools:
|
||||
search_tools = message_integration.register_functions(search_tools)
|
||||
|
|
@ -744,7 +761,7 @@ def search_agent(options: Chat):
|
|||
search_tools = []
|
||||
|
||||
tools = [
|
||||
*HumanToolkit.get_can_use_tools(options.task_id, Agents.search_agent),
|
||||
*HumanToolkit.get_can_use_tools(options.project_id, Agents.search_agent),
|
||||
*web_toolkit_custom.get_tools(),
|
||||
*terminal_toolkit,
|
||||
*note_toolkit.get_tools(),
|
||||
|
|
@ -795,7 +812,7 @@ The current date is {NOW_STR}(Accurate to the hour). For any date-related tasks,
|
|||
- **CRITICAL URL POLICY**: You are STRICTLY FORBIDDEN from inventing,
|
||||
guessing, or constructing URLs yourself. You MUST only use URLs from
|
||||
trusted sources:
|
||||
1. URLs returned by search tools (like `search_google` or `search_exa`)
|
||||
1. URLs returned by search tools (`search_google`)
|
||||
2. URLs found on webpages you have visited through browser tools
|
||||
3. URLs provided by the user in their request
|
||||
Fabricating or guessing URLs is considered a critical error and must
|
||||
|
|
@ -841,8 +858,6 @@ Your approach depends on available search tools:
|
|||
sites using `browser_type` and submit with `browser_enter`
|
||||
- **Extract URLs from results**: Only use URLs that appear in the search
|
||||
results on these websites
|
||||
- **Alternative Search**: If available, use `search_exa` for additional
|
||||
results
|
||||
|
||||
**Common Browser Operations (both scenarios):**
|
||||
- **Navigation and Exploration**: Use `browser_visit_page` to open URLs.
|
||||
|
|
@ -879,41 +894,42 @@ Your approach depends on available search tools:
|
|||
NoteTakingToolkit.toolkit_name(),
|
||||
TerminalToolkit.toolkit_name(),
|
||||
],
|
||||
toolkits_to_register_agent=[web_toolkit_for_agent_registration],
|
||||
)
|
||||
|
||||
|
||||
@traceroot.trace()
|
||||
async def document_agent(options: Chat):
|
||||
working_directory = options.file_save_path()
|
||||
traceroot_logger.info(f"Creating document agent for task: {options.task_id} in directory: {working_directory}")
|
||||
working_directory = get_working_directory(options)
|
||||
traceroot_logger.info(f"Creating document agent for project: {options.project_id} in directory: {working_directory}")
|
||||
message_integration = ToolkitMessageIntegration(
|
||||
message_handler=HumanToolkit(options.task_id, Agents.task_agent).send_message_to_user
|
||||
message_handler=HumanToolkit(options.project_id, Agents.task_agent).send_message_to_user
|
||||
)
|
||||
file_write_toolkit = FileToolkit(options.task_id, working_directory=working_directory)
|
||||
pptx_toolkit = PPTXToolkit(options.task_id, working_directory=working_directory)
|
||||
file_write_toolkit = FileToolkit(options.project_id, working_directory=working_directory)
|
||||
pptx_toolkit = PPTXToolkit(options.project_id, working_directory=working_directory)
|
||||
pptx_toolkit = message_integration.register_toolkits(pptx_toolkit)
|
||||
mark_it_down_toolkit = MarkItDownToolkit(options.task_id)
|
||||
mark_it_down_toolkit = MarkItDownToolkit(options.project_id)
|
||||
mark_it_down_toolkit = message_integration.register_toolkits(mark_it_down_toolkit)
|
||||
excel_toolkit = ExcelToolkit(options.task_id, working_directory=working_directory)
|
||||
excel_toolkit = ExcelToolkit(options.project_id, working_directory=working_directory)
|
||||
excel_toolkit = message_integration.register_toolkits(excel_toolkit)
|
||||
note_toolkit = NoteTakingToolkit(options.task_id, Agents.document_agent, working_directory=working_directory)
|
||||
note_toolkit = NoteTakingToolkit(options.project_id, Agents.document_agent, working_directory=working_directory)
|
||||
note_toolkit = message_integration.register_toolkits(note_toolkit)
|
||||
terminal_toolkit = TerminalToolkit(options.task_id, Agents.document_agent, safe_mode=True, clone_current_env=False)
|
||||
terminal_toolkit = TerminalToolkit(options.project_id, Agents.document_agent, safe_mode=True, clone_current_env=False)
|
||||
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
|
||||
tools = [
|
||||
*file_write_toolkit.get_tools(),
|
||||
*pptx_toolkit.get_tools(),
|
||||
*HumanToolkit.get_can_use_tools(options.task_id, Agents.document_agent),
|
||||
*HumanToolkit.get_can_use_tools(options.project_id, Agents.document_agent),
|
||||
*mark_it_down_toolkit.get_tools(),
|
||||
*excel_toolkit.get_tools(),
|
||||
*note_toolkit.get_tools(),
|
||||
*terminal_toolkit.get_tools(),
|
||||
*await GoogleDriveMCPToolkit.get_can_use_tools(options.task_id, options.get_bun_env()),
|
||||
*await GoogleDriveMCPToolkit.get_can_use_tools(options.project_id, options.get_bun_env()),
|
||||
]
|
||||
if env("EXA_API_KEY") or options.is_cloud():
|
||||
search_toolkit = SearchToolkit(options.task_id, Agents.document_agent).search_exa
|
||||
search_toolkit = message_integration.register_functions([search_toolkit])
|
||||
tools.extend(search_toolkit)
|
||||
# if env("EXA_API_KEY") or options.is_cloud():
|
||||
# search_toolkit = SearchToolkit(options.project_id, Agents.document_agent).search_exa
|
||||
# search_toolkit = message_integration.register_functions([search_toolkit])
|
||||
# tools.extend(search_toolkit)
|
||||
system_message = f"""
|
||||
<role>
|
||||
You are a Documentation Specialist, responsible for creating, modifying, and
|
||||
|
|
@ -1085,32 +1101,32 @@ supported formats including advanced spreadsheet functionality.
|
|||
|
||||
@traceroot.trace()
|
||||
def multi_modal_agent(options: Chat):
|
||||
working_directory = options.file_save_path()
|
||||
traceroot_logger.info(f"Creating multi-modal agent for task: {options.task_id} in directory: {working_directory}")
|
||||
working_directory = get_working_directory(options)
|
||||
traceroot_logger.info(f"Creating multi-modal agent for project: {options.project_id} in directory: {working_directory}")
|
||||
message_integration = ToolkitMessageIntegration(
|
||||
message_handler=HumanToolkit(options.task_id, Agents.multi_modal_agent).send_message_to_user
|
||||
message_handler=HumanToolkit(options.project_id, Agents.multi_modal_agent).send_message_to_user
|
||||
)
|
||||
video_download_toolkit = VideoDownloaderToolkit(options.task_id, working_directory=working_directory)
|
||||
video_download_toolkit = VideoDownloaderToolkit(options.project_id, working_directory=working_directory)
|
||||
video_download_toolkit = message_integration.register_toolkits(video_download_toolkit)
|
||||
image_analysis_toolkit = ImageAnalysisToolkit(options.task_id)
|
||||
image_analysis_toolkit = ImageAnalysisToolkit(options.project_id)
|
||||
image_analysis_toolkit = message_integration.register_toolkits(image_analysis_toolkit)
|
||||
|
||||
terminal_toolkit = TerminalToolkit(
|
||||
options.task_id, agent_name=Agents.multi_modal_agent, safe_mode=True, clone_current_env=False
|
||||
options.project_id, agent_name=Agents.multi_modal_agent, safe_mode=True, clone_current_env=False
|
||||
)
|
||||
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
|
||||
note_toolkit = NoteTakingToolkit(options.task_id, Agents.multi_modal_agent, working_directory=working_directory)
|
||||
note_toolkit = NoteTakingToolkit(options.project_id, Agents.multi_modal_agent, working_directory=working_directory)
|
||||
note_toolkit = message_integration.register_toolkits(note_toolkit)
|
||||
tools = [
|
||||
*video_download_toolkit.get_tools(),
|
||||
*image_analysis_toolkit.get_tools(),
|
||||
*HumanToolkit.get_can_use_tools(options.task_id, Agents.multi_modal_agent),
|
||||
*HumanToolkit.get_can_use_tools(options.project_id, Agents.multi_modal_agent),
|
||||
*terminal_toolkit.get_tools(),
|
||||
*note_toolkit.get_tools(),
|
||||
]
|
||||
if options.is_cloud():
|
||||
open_ai_image_toolkit = OpenAIImageToolkit( # todo check llm has this model
|
||||
options.task_id,
|
||||
options.project_id,
|
||||
model="dall-e-3",
|
||||
response_format="b64_json",
|
||||
size="1024x1024",
|
||||
|
|
@ -1132,7 +1148,7 @@ def multi_modal_agent(options: Chat):
|
|||
|
||||
if model_platform_enum == ModelPlatformType.OPENAI:
|
||||
audio_analysis_toolkit = AudioAnalysisToolkit(
|
||||
options.task_id,
|
||||
options.project_id,
|
||||
working_directory,
|
||||
OpenAIAudioModels(
|
||||
api_key=options.api_key,
|
||||
|
|
@ -1142,10 +1158,10 @@ def multi_modal_agent(options: Chat):
|
|||
audio_analysis_toolkit = message_integration.register_toolkits(audio_analysis_toolkit)
|
||||
tools.extend(audio_analysis_toolkit.get_tools())
|
||||
|
||||
if env("EXA_API_KEY") or options.is_cloud():
|
||||
search_toolkit = SearchToolkit(options.task_id, Agents.multi_modal_agent).search_exa
|
||||
search_toolkit = message_integration.register_functions([search_toolkit])
|
||||
tools.extend(search_toolkit)
|
||||
# if env("EXA_API_KEY") or options.is_cloud():
|
||||
# search_toolkit = SearchToolkit(options.project_id, Agents.multi_modal_agent).search_exa
|
||||
# search_toolkit = message_integration.register_functions([search_toolkit])
|
||||
# tools.extend(search_toolkit)
|
||||
|
||||
system_message = f"""
|
||||
<role>
|
||||
|
|
@ -1255,27 +1271,27 @@ async def social_medium_agent(options: Chat):
|
|||
Agent to handling tasks related to social media:
|
||||
include toolkits: WhatsApp, Twitter, LinkedIn, Reddit, Notion, Slack, Discord and Google Suite.
|
||||
"""
|
||||
working_directory = options.file_save_path()
|
||||
traceroot_logger.info(f"Creating social medium agent for task: {options.task_id} in directory: {working_directory}")
|
||||
working_directory = get_working_directory(options)
|
||||
traceroot_logger.info(f"Creating social medium agent for project: {options.project_id} in directory: {working_directory}")
|
||||
tools = [
|
||||
*WhatsAppToolkit.get_can_use_tools(options.task_id),
|
||||
*TwitterToolkit.get_can_use_tools(options.task_id),
|
||||
*LinkedInToolkit.get_can_use_tools(options.task_id),
|
||||
*RedditToolkit.get_can_use_tools(options.task_id),
|
||||
*await NotionMCPToolkit.get_can_use_tools(options.task_id),
|
||||
# *SlackToolkit.get_can_use_tools(options.task_id),
|
||||
*await GoogleGmailMCPToolkit.get_can_use_tools(options.task_id, options.get_bun_env()),
|
||||
*GoogleCalendarToolkit.get_can_use_tools(options.task_id),
|
||||
*HumanToolkit.get_can_use_tools(options.task_id, Agents.social_medium_agent),
|
||||
*TerminalToolkit(options.task_id, agent_name=Agents.social_medium_agent, clone_current_env=False).get_tools(),
|
||||
*WhatsAppToolkit.get_can_use_tools(options.project_id),
|
||||
*TwitterToolkit.get_can_use_tools(options.project_id),
|
||||
*LinkedInToolkit.get_can_use_tools(options.project_id),
|
||||
*RedditToolkit.get_can_use_tools(options.project_id),
|
||||
*await NotionMCPToolkit.get_can_use_tools(options.project_id),
|
||||
# *SlackToolkit.get_can_use_tools(options.project_id),
|
||||
*await GoogleGmailMCPToolkit.get_can_use_tools(options.project_id, options.get_bun_env()),
|
||||
*GoogleCalendarToolkit.get_can_use_tools(options.project_id),
|
||||
*HumanToolkit.get_can_use_tools(options.project_id, Agents.social_medium_agent),
|
||||
*TerminalToolkit(options.project_id, agent_name=Agents.social_medium_agent, clone_current_env=False).get_tools(),
|
||||
*NoteTakingToolkit(
|
||||
options.task_id, Agents.social_medium_agent, working_directory=working_directory
|
||||
options.project_id, Agents.social_medium_agent, working_directory=working_directory
|
||||
).get_tools(),
|
||||
# *DiscordToolkit(options.task_id).get_tools(), # Not supported temporarily
|
||||
# *GoogleSuiteToolkit(options.task_id).get_tools(), # Not supported temporarily
|
||||
# *DiscordToolkit(options.project_id).get_tools(), # Not supported temporarily
|
||||
# *GoogleSuiteToolkit(options.project_id).get_tools(), # Not supported temporarily
|
||||
]
|
||||
if env("EXA_API_KEY") or options.is_cloud():
|
||||
tools.append(FunctionTool(SearchToolkit(options.task_id, Agents.social_medium_agent).search_exa))
|
||||
# if env("EXA_API_KEY") or options.is_cloud():
|
||||
# tools.append(FunctionTool(SearchToolkit(options.project_id, Agents.social_medium_agent).search_exa))
|
||||
return agent_model(
|
||||
Agents.social_medium_agent,
|
||||
BaseMessage.make_assistant_message(
|
||||
|
|
@ -1371,16 +1387,16 @@ operations.
|
|||
@traceroot.trace()
|
||||
async def mcp_agent(options: Chat):
|
||||
traceroot_logger.info(
|
||||
f"Creating MCP agent for task: {options.task_id} with {len(options.installed_mcp['mcpServers'])} MCP servers"
|
||||
f"Creating MCP agent for project: {options.project_id} with {len(options.installed_mcp['mcpServers'])} MCP servers"
|
||||
)
|
||||
tools = [
|
||||
# *HumanToolkit.get_can_use_tools(options.task_id, Agents.mcp_agent),
|
||||
*McpSearchToolkit(options.task_id).get_tools(),
|
||||
# *HumanToolkit.get_can_use_tools(options.project_id, Agents.mcp_agent),
|
||||
*McpSearchToolkit(options.project_id).get_tools(),
|
||||
]
|
||||
if len(options.installed_mcp["mcpServers"]) > 0:
|
||||
try:
|
||||
mcp_tools = await get_mcp_tools(options.installed_mcp)
|
||||
traceroot_logger.info(f"Retrieved {len(mcp_tools)} MCP tools for task {options.task_id}")
|
||||
traceroot_logger.info(f"Retrieved {len(mcp_tools)} MCP tools for task {options.project_id}")
|
||||
if mcp_tools:
|
||||
tool_names = [tool.get_function_name() if hasattr(tool, 'get_function_name') else str(tool) for tool in mcp_tools]
|
||||
traceroot_logger.debug(f"MCP tools: {tool_names}")
|
||||
|
|
@ -1388,9 +1404,9 @@ async def mcp_agent(options: Chat):
|
|||
except Exception as e:
|
||||
traceroot_logger.debug(repr(e))
|
||||
|
||||
task_lock = get_task_lock(options.task_id)
|
||||
task_lock = get_task_lock(options.project_id)
|
||||
agent_id = str(uuid.uuid4())
|
||||
traceroot_logger.info(f"Creating MCP agent: {Agents.mcp_agent} with id: {agent_id} for task: {options.task_id}")
|
||||
traceroot_logger.info(f"Creating MCP agent: {Agents.mcp_agent} with id: {agent_id} for task: {options.project_id}")
|
||||
asyncio.create_task(
|
||||
task_lock.put_queue(
|
||||
ActionCreateAgentData(
|
||||
|
|
@ -1403,7 +1419,7 @@ async def mcp_agent(options: Chat):
|
|||
)
|
||||
)
|
||||
return ListenChatAgent(
|
||||
options.task_id,
|
||||
options.project_id,
|
||||
Agents.mcp_agent,
|
||||
system_message="You are a helpful assistant that can help users search mcp servers. The found mcp services will be returned to the user, and you will ask the user via ask_human_via_gui whether they want to install these mcp services.",
|
||||
model=ModelFactory.create(
|
||||
|
|
@ -1412,7 +1428,7 @@ async def mcp_agent(options: Chat):
|
|||
api_key=options.api_key,
|
||||
url=options.api_url,
|
||||
model_config_dict={
|
||||
"user": str(options.task_id),
|
||||
"user": str(options.project_id),
|
||||
}
|
||||
if options.is_cloud()
|
||||
else None,
|
||||
|
|
|
|||
20
backend/app/utils/file_utils.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""File system utilities."""
|
||||
|
||||
from app.component.environment import env
|
||||
from app.model.chat import Chat
|
||||
|
||||
|
||||
def get_working_directory(options: Chat, task_lock=None) -> str:
|
||||
"""
|
||||
Get the correct working directory for file operations.
|
||||
First checks if there's an updated path from improve API call,
|
||||
then falls back to environment variable or default path.
|
||||
"""
|
||||
if not task_lock:
|
||||
from app.service.task import get_task_lock_if_exists
|
||||
task_lock = get_task_lock_if_exists(options.project_id)
|
||||
|
||||
if task_lock and hasattr(task_lock, 'new_folder_path') and task_lock.new_folder_path:
|
||||
return str(task_lock.new_folder_path)
|
||||
else:
|
||||
return env("file_save_path", options.file_save_path())
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import asyncio
|
||||
from functools import wraps
|
||||
from inspect import iscoroutinefunction
|
||||
from inspect import iscoroutinefunction, getmembers, ismethod, signature
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Type, TypeVar
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from loguru import logger
|
||||
from app.service.task import (
|
||||
ActionActivateToolkitData,
|
||||
ActionDeactivateToolkitData,
|
||||
|
|
@ -12,6 +13,41 @@ from app.service.task import (
|
|||
)
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from app.service.task import process_task
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("toolkit_listen")
|
||||
|
||||
|
||||
def _safe_put_queue(task_lock, data):
|
||||
"""Safely put data to the queue, handling both sync and async contexts"""
|
||||
try:
|
||||
# Try to get current event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're in an async context, create a task
|
||||
task = asyncio.create_task(task_lock.put_queue(data))
|
||||
if hasattr(task_lock, "add_background_task"):
|
||||
task_lock.add_background_task(task)
|
||||
except RuntimeError:
|
||||
# No running event loop, we need to handle this differently
|
||||
try:
|
||||
# Create a new event loop in a separate thread to avoid conflicts
|
||||
def run_in_thread():
|
||||
try:
|
||||
# Create a new event loop for this thread
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
new_loop.run_until_complete(task_lock.put_queue(data))
|
||||
finally:
|
||||
new_loop.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[listen_toolkit] Failed to send data in thread: {e}")
|
||||
|
||||
# Run in a separate thread to avoid blocking
|
||||
thread = threading.Thread(target=run_in_thread, daemon=True)
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"[listen_toolkit] Failed to send data to queue: {e}")
|
||||
|
||||
|
||||
def listen_toolkit(
|
||||
|
|
@ -27,6 +63,11 @@ def listen_toolkit(
|
|||
@wraps(wrap)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
toolkit: AbstractToolkit = args[0]
|
||||
# Check if api_task_id exists
|
||||
if not hasattr(toolkit, 'api_task_id'):
|
||||
logger.warning(f"[listen_toolkit] {toolkit.__class__.__name__} missing api_task_id, calling method directly")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
task_lock = get_task_lock(toolkit.api_task_id)
|
||||
|
||||
if inputs is not None:
|
||||
|
|
@ -40,19 +81,23 @@ def listen_toolkit(
|
|||
kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
|
||||
args_str = f"{args_str}, {kwargs_str}" if args_str else kwargs_str
|
||||
|
||||
# Truncate args_str if too long
|
||||
MAX_ARGS_LENGTH = 500
|
||||
if len(args_str) > MAX_ARGS_LENGTH:
|
||||
args_str = args_str[:MAX_ARGS_LENGTH] + f"... (truncated, total length: {len(args_str)} chars)"
|
||||
|
||||
toolkit_name = toolkit.toolkit_name()
|
||||
method_name = func.__name__.replace("_", " ")
|
||||
await task_lock.put_queue(
|
||||
ActionActivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": args_str,
|
||||
},
|
||||
)
|
||||
activate_data = ActionActivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": args_str,
|
||||
},
|
||||
)
|
||||
await task_lock.put_queue(activate_data)
|
||||
error = None
|
||||
res = None
|
||||
try:
|
||||
|
|
@ -70,21 +115,26 @@ def listen_toolkit(
|
|||
res_msg = json.dumps(res, ensure_ascii=False)
|
||||
except TypeError:
|
||||
# Handle cases where res contains non-serializable objects (like coroutines)
|
||||
res_msg = str(res)
|
||||
res_str = str(res)
|
||||
# Truncate very long outputs to avoid flooding logs
|
||||
MAX_LENGTH = 500
|
||||
if len(res_str) > MAX_LENGTH:
|
||||
res_msg = res_str[:MAX_LENGTH] + f"... (truncated, total length: {len(res_str)} chars)"
|
||||
else:
|
||||
res_msg = res_str
|
||||
else:
|
||||
res_msg = str(error)
|
||||
|
||||
await task_lock.put_queue(
|
||||
ActionDeactivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": res_msg,
|
||||
},
|
||||
)
|
||||
deactivate_data = ActionDeactivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": res_msg,
|
||||
},
|
||||
)
|
||||
await task_lock.put_queue(deactivate_data)
|
||||
if error is not None:
|
||||
raise error
|
||||
return res
|
||||
|
|
@ -96,6 +146,11 @@ def listen_toolkit(
|
|||
@wraps(wrap)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
toolkit: AbstractToolkit = args[0]
|
||||
# Check if api_task_id exists
|
||||
if not hasattr(toolkit, 'api_task_id'):
|
||||
logger.warning(f"[listen_toolkit] {toolkit.__class__.__name__} missing api_task_id, calling method directly")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
task_lock = get_task_lock(toolkit.api_task_id)
|
||||
|
||||
if inputs is not None:
|
||||
|
|
@ -109,34 +164,34 @@ def listen_toolkit(
|
|||
kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
|
||||
args_str = f"{args_str}, {kwargs_str}" if args_str else kwargs_str
|
||||
|
||||
# Truncate args_str if too long
|
||||
MAX_ARGS_LENGTH = 500
|
||||
if len(args_str) > MAX_ARGS_LENGTH:
|
||||
args_str = args_str[:MAX_ARGS_LENGTH] + f"... (truncated, total length: {len(args_str)} chars)"
|
||||
|
||||
toolkit_name = toolkit.toolkit_name()
|
||||
method_name = func.__name__.replace("_", " ")
|
||||
task = asyncio.create_task(
|
||||
task_lock.put_queue(
|
||||
ActionActivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": args_str,
|
||||
},
|
||||
)
|
||||
)
|
||||
activate_data = ActionActivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": args_str,
|
||||
},
|
||||
)
|
||||
if hasattr(task_lock, "add_background_task"):
|
||||
task_lock.add_background_task(task)
|
||||
_safe_put_queue(task_lock, activate_data)
|
||||
error = None
|
||||
res = None
|
||||
try:
|
||||
logger.debug(f"Executing toolkit method: {toolkit_name}.{method_name} for agent '{toolkit.agent_name}'")
|
||||
res = func(*args, **kwargs)
|
||||
# Safety check: if the result is a coroutine, we need to await it
|
||||
# Safety check: if the result is a coroutine, this is a programming error
|
||||
if asyncio.iscoroutine(res):
|
||||
import warnings
|
||||
|
||||
warnings.warn(f"Async function {func.__name__} was incorrectly called synchronously")
|
||||
res = asyncio.run(res)
|
||||
error_msg = f"Async function {func.__name__} was incorrectly called in sync context. This is a bug - the function should be marked as async or should not return a coroutine."
|
||||
logger.error(f"[listen_toolkit] {error_msg}")
|
||||
# Cannot safely await in sync context - close the coroutine to prevent warnings
|
||||
res.close()
|
||||
raise TypeError(error_msg)
|
||||
except Exception as e:
|
||||
error = e
|
||||
|
||||
|
|
@ -150,25 +205,26 @@ def listen_toolkit(
|
|||
res_msg = json.dumps(res, ensure_ascii=False)
|
||||
except TypeError:
|
||||
# Handle cases where res contains non-serializable objects (like coroutines)
|
||||
res_msg = str(res)
|
||||
res_str = str(res)
|
||||
# Truncate very long outputs to avoid flooding logs
|
||||
MAX_LENGTH = 500
|
||||
if len(res_str) > MAX_LENGTH:
|
||||
res_msg = res_str[:MAX_LENGTH] + f"... (truncated, total length: {len(res_str)} chars)"
|
||||
else:
|
||||
res_msg = res_str
|
||||
else:
|
||||
res_msg = str(error)
|
||||
|
||||
task = asyncio.create_task(
|
||||
task_lock.put_queue(
|
||||
ActionDeactivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": res_msg,
|
||||
},
|
||||
)
|
||||
)
|
||||
deactivate_data = ActionDeactivateToolkitData(
|
||||
data={
|
||||
"agent_name": toolkit.agent_name,
|
||||
"process_task_id": process_task.get(""),
|
||||
"toolkit_name": toolkit_name,
|
||||
"method_name": method_name,
|
||||
"message": res_msg,
|
||||
},
|
||||
)
|
||||
if hasattr(task_lock, "add_background_task"):
|
||||
task_lock.add_background_task(task)
|
||||
_safe_put_queue(task_lock, deactivate_data)
|
||||
if error is not None:
|
||||
raise error
|
||||
return res
|
||||
|
|
@ -176,3 +232,81 @@ def listen_toolkit(
|
|||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
# Methods that should not be wrapped by auto_listen_toolkit
|
||||
# These are utility/helper methods that don't perform actual tool operations
|
||||
EXCLUDED_METHODS = {
|
||||
'get_tools', # Tool enumeration
|
||||
'get_can_use_tools', # Tool filtering
|
||||
'toolkit_name', # Metadata getter
|
||||
'run_mcp_server', # MCP server initialization
|
||||
'model_dump', # Pydantic model serialization
|
||||
'model_dump_json', # Pydantic model serialization
|
||||
'dict', # Pydantic legacy dict method
|
||||
'json', # Pydantic legacy json method
|
||||
'copy', # Object copying
|
||||
'update', # Object update
|
||||
}
|
||||
|
||||
|
||||
def auto_listen_toolkit(base_toolkit_class: Type[T]) -> Callable[[Type[T]], Type[T]]:
|
||||
"""
|
||||
Class decorator that automatically wraps all public methods from the base toolkit
|
||||
with the @listen_toolkit decorator.
|
||||
|
||||
Excluded methods (not wrapped):
|
||||
- get_tools, get_can_use_tools: Tool enumeration/filtering
|
||||
- toolkit_name: Metadata getter
|
||||
- run_mcp_server: MCP server initialization
|
||||
- Pydantic serialization methods: model_dump, model_dump_json, dict, json
|
||||
- Object utility methods: copy, update
|
||||
|
||||
These methods are typically called during initialization or for metadata,
|
||||
and should not trigger activate/deactivate events.
|
||||
|
||||
Usage:
|
||||
@auto_listen_toolkit(BaseNoteTakingToolkit)
|
||||
class NoteTakingToolkit(BaseNoteTakingToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
"""
|
||||
def class_decorator(cls: Type[T]) -> Type[T]:
|
||||
|
||||
base_methods = {}
|
||||
for name in dir(base_toolkit_class):
|
||||
# Skip private methods and excluded helper methods
|
||||
if not name.startswith('_') and name not in EXCLUDED_METHODS:
|
||||
attr = getattr(base_toolkit_class, name)
|
||||
if callable(attr):
|
||||
base_methods[name] = attr
|
||||
|
||||
for method_name, base_method in base_methods.items():
|
||||
if method_name in cls.__dict__:
|
||||
continue
|
||||
|
||||
sig = signature(base_method)
|
||||
|
||||
def create_wrapper(method_name: str, base_method: Callable) -> Callable:
|
||||
if iscoroutinefunction(base_method):
|
||||
async def async_method_wrapper(self, *args, **kwargs):
|
||||
return await getattr(super(cls, self), method_name)(*args, **kwargs)
|
||||
async_method_wrapper.__name__ = method_name
|
||||
async_method_wrapper.__signature__ = sig
|
||||
return async_method_wrapper
|
||||
else:
|
||||
def sync_method_wrapper(self, *args, **kwargs):
|
||||
return getattr(super(cls, self), method_name)(*args, **kwargs)
|
||||
sync_method_wrapper.__name__ = method_name
|
||||
sync_method_wrapper.__signature__ = sig
|
||||
return sync_method_wrapper
|
||||
|
||||
wrapper = create_wrapper(method_name, base_method)
|
||||
decorated_method = listen_toolkit(base_method)(wrapper)
|
||||
|
||||
setattr(cls, method_name, decorated_method)
|
||||
|
||||
return cls
|
||||
|
||||
return class_decorator
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import httpx
|
|||
import asyncio
|
||||
import os
|
||||
import json
|
||||
from loguru import logger
|
||||
from app.service.chat_service import Chat
|
||||
from app.component.environment import env
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("sync_step")
|
||||
|
||||
|
||||
def sync_step(func):
|
||||
|
|
@ -28,7 +30,9 @@ def sync_step(func):
|
|||
send_to_api(
|
||||
sync_url,
|
||||
{
|
||||
"task_id": chat.task_id,
|
||||
# TODO: revert to task_id to support multi-task project replay
|
||||
# "task_id": chat.task_id,
|
||||
"task_id": chat.project_id,
|
||||
"step": json_data["step"],
|
||||
"data": json_data["data"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ import datetime
|
|||
from camel.agents.chat_agent import AsyncStreamingChatAgentResponse
|
||||
from camel.societies.workforce.single_agent_worker import SingleAgentWorker as BaseSingleAgentWorker
|
||||
from camel.tasks.task import Task, TaskState, is_task_result_insufficient
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
from app.utils.agent import ListenChatAgent
|
||||
from camel.societies.workforce.prompts import PROCESS_TASK_PROMPT
|
||||
from colorama import Fore
|
||||
from camel.societies.workforce.utils import TaskResult
|
||||
from camel.utils.context_utils import ContextUtility
|
||||
|
||||
logger = traceroot.get_logger("single_agent_worker")
|
||||
|
||||
|
||||
class SingleAgentWorker(BaseSingleAgentWorker):
|
||||
|
|
@ -19,6 +23,8 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
pool_max_size: int = 10,
|
||||
auto_scale_pool: bool = True,
|
||||
use_structured_output_handler: bool = True,
|
||||
context_utility: ContextUtility | None = None,
|
||||
enable_workflow_memory: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
description=description,
|
||||
|
|
@ -28,6 +34,8 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
pool_max_size=pool_max_size,
|
||||
auto_scale_pool=auto_scale_pool,
|
||||
use_structured_output_handler=use_structured_output_handler,
|
||||
context_utility=context_utility,
|
||||
enable_workflow_memory=enable_workflow_memory,
|
||||
)
|
||||
self.worker = worker # change type hint
|
||||
|
||||
|
|
@ -54,6 +62,7 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
worker_agent.process_task_id = task.id # type: ignore rewrite line
|
||||
|
||||
response_content = ""
|
||||
final_response = None
|
||||
try:
|
||||
dependency_tasks_info = self._get_dep_tasks_info(dependencies)
|
||||
prompt = PROCESS_TASK_PROMPT.format(
|
||||
|
|
@ -130,8 +139,28 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
usage_info = response.info.get("usage") or response.info.get("token_usage")
|
||||
total_tokens = usage_info.get("total_tokens", 0) if usage_info else 0
|
||||
|
||||
# collect conversation from working agent to
|
||||
# accumulator for workflow memory
|
||||
# Only transfer memory if workflow memory is enabled
|
||||
if self.enable_workflow_memory:
|
||||
accumulator = self._get_conversation_accumulator()
|
||||
|
||||
# transfer all memory records from working agent to accumulator
|
||||
try:
|
||||
# retrieve all context records from the working agent
|
||||
work_records = worker_agent.memory.retrieve()
|
||||
|
||||
# write these records to the accumulator's memory
|
||||
memory_records = [record.memory_record for record in work_records]
|
||||
accumulator.memory.write_records(memory_records)
|
||||
|
||||
logger.debug(f"Transferred {len(memory_records)} memory records to accumulator")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to transfer conversation to accumulator: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}Error processing task {task.id}: {type(e).__name__}: {e}{Fore.RESET}")
|
||||
logger.error(f"Error processing task {task.id}: {type(e).__name__}: {e}")
|
||||
# Store error information in task result
|
||||
task.result = f"{type(e).__name__}: {e!s}"
|
||||
return TaskState.FAILED
|
||||
|
|
@ -144,6 +173,8 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
task.additional_info = {}
|
||||
|
||||
# Create worker attempt details with descriptive keys
|
||||
# Use final_response if available (streaming), otherwise use response
|
||||
response_for_info = final_response if final_response is not None else response
|
||||
worker_attempt_details = {
|
||||
"agent_id": getattr(worker_agent, "agent_id", worker_agent.role_name),
|
||||
"original_worker_id": getattr(self.worker, "agent_id", self.worker.role_name),
|
||||
|
|
@ -154,11 +185,7 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
f"{getattr(self.worker, 'agent_id', self.worker.role_name)}) "
|
||||
f"to process task: {task.content}",
|
||||
"response_content": response_content[:50],
|
||||
"tool_calls": str(
|
||||
final_response.info.get("tool_calls")
|
||||
if isinstance(response, AsyncStreamingChatAgentResponse)
|
||||
else response.info.get("tool_calls")
|
||||
)[:50],
|
||||
"tool_calls": str(response_for_info.info.get("tool_calls", []) if response_for_info and hasattr(response_for_info, 'info') else [])[:50],
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
|
||||
|
|
@ -172,9 +199,12 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
|
||||
print(f"======\n{Fore.GREEN}Response from {self}:{Fore.RESET}")
|
||||
|
||||
logger.info(f"Response from {self}:")
|
||||
|
||||
if not self.use_structured_output_handler:
|
||||
# Handle native structured output parsing
|
||||
if task_result is None:
|
||||
logger.error("Error in worker step execution: Invalid task result")
|
||||
print(f"{Fore.RED}Error in worker step execution: Invalid task result{Fore.RESET}")
|
||||
task_result = TaskResult(
|
||||
content="Failed to generate valid task result.",
|
||||
|
|
@ -186,12 +216,17 @@ class SingleAgentWorker(BaseSingleAgentWorker):
|
|||
f"\n{color}{task_result.content}{Fore.RESET}\n======", # type: ignore[union-attr]
|
||||
)
|
||||
|
||||
if task_result.failed: # type: ignore[union-attr]
|
||||
logger.error(f"{task_result.content}") # type: ignore[union-attr]
|
||||
else:
|
||||
logger.info(f"{task_result.content}") # type: ignore[union-attr]
|
||||
|
||||
task.result = task_result.content # type: ignore[union-attr]
|
||||
|
||||
if task_result.failed: # type: ignore[union-attr]
|
||||
return TaskState.FAILED
|
||||
|
||||
if is_task_result_insufficient(task):
|
||||
print(f"{Fore.RED}Task {task.id}: Content validation failed - task marked as failed{Fore.RESET}")
|
||||
logger.warning(f"Task {task.id}: Content validation failed - task marked as failed")
|
||||
return TaskState.FAILED
|
||||
return TaskState.DONE
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ from camel.toolkits import AudioAnalysisToolkit as BaseAudioAnalysisToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseAudioAnalysisToolkit)
|
||||
class AudioAnalysisToolkit(BaseAudioAnalysisToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.multi_modal_agent
|
||||
|
||||
|
|
@ -23,14 +24,3 @@ class AudioAnalysisToolkit(BaseAudioAnalysisToolkit, AbstractToolkit):
|
|||
cache_dir = env("file_save_path", os.path.expanduser("~/.eigent/tmp/"))
|
||||
super().__init__(cache_dir, transcribe_model, audio_reasoning_model, timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseAudioAnalysisToolkit.audio2text,
|
||||
lambda _, audio_path, question: f"transcribe audio from {audio_path} and ask question: {question}",
|
||||
)
|
||||
def ask_question_about_audio(self, audio_path: str, question: str) -> str:
|
||||
return super().ask_question_about_audio(audio_path, question)
|
||||
|
||||
@listen_toolkit(BaseAudioAnalysisToolkit.audio2text)
|
||||
def audio2text(self, audio_path: str) -> str:
|
||||
return super().audio2text(audio_path)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from typing import List, Literal
|
||||
from camel.toolkits import CodeExecutionToolkit as BaseCodeExecutionToolkit, FunctionTool
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseCodeExecutionToolkit)
|
||||
class CodeExecutionToolkit(BaseCodeExecutionToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.developer_agent
|
||||
|
||||
|
|
@ -21,18 +22,6 @@ class CodeExecutionToolkit(BaseCodeExecutionToolkit, AbstractToolkit):
|
|||
self.api_task_id = api_task_id
|
||||
super().__init__(sandbox, verbose, unsafe_mode, import_white_list, require_confirm, timeout)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseCodeExecutionToolkit.execute_code,
|
||||
)
|
||||
def execute_code(self, code: str, code_type: str = "python") -> str:
|
||||
return super().execute_code(code, code_type)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseCodeExecutionToolkit.execute_command,
|
||||
)
|
||||
def execute_command(self, command: str) -> str | tuple[str, str]:
|
||||
return super().execute_command(command)
|
||||
|
||||
def get_tools(self) -> List[FunctionTool]:
|
||||
return [
|
||||
FunctionTool(self.execute_code),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
from camel.toolkits import Crawl4AIToolkit as BaseCrawl4AIToolkit
|
||||
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseCrawl4AIToolkit)
|
||||
class Crawl4AIToolkit(BaseCrawl4AIToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.search_agent
|
||||
|
||||
|
|
@ -12,18 +13,5 @@ class Crawl4AIToolkit(BaseCrawl4AIToolkit, AbstractToolkit):
|
|||
self.api_task_id = api_task_id
|
||||
super().__init__(timeout)
|
||||
|
||||
# async def _get_client(self):
|
||||
# r"""Get or create the AsyncWebCrawler client."""
|
||||
# if self._client is None:
|
||||
# from crawl4ai import AsyncWebCrawler
|
||||
|
||||
# self._client = AsyncWebCrawler(use_managed_browser=True)
|
||||
# await self._client.__aenter__()
|
||||
# return self._client
|
||||
|
||||
@listen_toolkit(BaseCrawl4AIToolkit.scrape)
|
||||
async def scrape(self, url: str) -> str:
|
||||
return await super().scrape(url)
|
||||
|
||||
def toolkit_name(self) -> str:
|
||||
return "Crawl Toolkit"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from camel.toolkits import ExcelToolkit as BaseExcelToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseExcelToolkit)
|
||||
class ExcelToolkit(BaseExcelToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
|
||||
|
|
@ -20,7 +21,3 @@ class ExcelToolkit(BaseExcelToolkit, AbstractToolkit):
|
|||
if working_directory is None:
|
||||
working_directory = env("file_save_path", os.path.expanduser("~/Downloads"))
|
||||
super().__init__(timeout=timeout, working_directory=working_directory)
|
||||
|
||||
@listen_toolkit(BaseExcelToolkit.extract_excel_content)
|
||||
def extract_excel_content(self, document_path: str) -> str:
|
||||
return super().extract_excel_content(document_path)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from camel.toolkits import FileToolkit as BaseFileToolkit
|
|||
from app.component.environment import env
|
||||
from app.service.task import process_task
|
||||
from app.service.task import ActionWriteFileData, Agents, get_task_lock
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseFileToolkit)
|
||||
class FileToolkit(BaseFileToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
|
||||
|
|
@ -54,15 +55,3 @@ class FileToolkit(BaseFileToolkit, AbstractToolkit):
|
|||
)
|
||||
)
|
||||
return res
|
||||
|
||||
@listen_toolkit(
|
||||
BaseFileToolkit.read_file,
|
||||
)
|
||||
def read_file(self, file_paths: str | list[str]) -> str | dict[str, str]:
|
||||
return super().read_file(file_paths)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseFileToolkit.edit_file,
|
||||
)
|
||||
def edit_file(self, file_path: str, old_content: str, new_content: str) -> str:
|
||||
return super().edit_file(file_path, old_content, new_content)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from camel.toolkits import GithubToolkit as BaseGithubToolkit
|
|||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseGithubToolkit)
|
||||
class GithubToolkit(BaseGithubToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.developer_agent
|
||||
|
||||
|
|
@ -19,86 +20,6 @@ class GithubToolkit(BaseGithubToolkit, AbstractToolkit):
|
|||
super().__init__(access_token, timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.create_pull_request,
|
||||
lambda _,
|
||||
repo_name,
|
||||
file_path,
|
||||
new_content,
|
||||
pr_title,
|
||||
body,
|
||||
branch_name: f"Create PR in {repo_name} for {file_path} with title '{pr_title}', branch '{branch_name}', content '{new_content}'",
|
||||
)
|
||||
def create_pull_request(
|
||||
self,
|
||||
repo_name: str,
|
||||
file_path: str,
|
||||
new_content: str,
|
||||
pr_title: str,
|
||||
body: str,
|
||||
branch_name: str,
|
||||
) -> str:
|
||||
return super().create_pull_request(repo_name, file_path, new_content, pr_title, body, branch_name)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.get_issue_list,
|
||||
lambda _, repo_name, state="all": f"Get issue list from {repo_name} with state '{state}'",
|
||||
lambda issues: f"Retrieved {len(issues)} issues",
|
||||
)
|
||||
def get_issue_list(
|
||||
self, repo_name: str, state: Literal["open", "closed", "all"] = "all"
|
||||
) -> list[dict[str, object]]:
|
||||
return super().get_issue_list(repo_name, state)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.get_issue_content,
|
||||
lambda _, repo_name, issue_number: f"Get content of issue {issue_number} from {repo_name}",
|
||||
)
|
||||
def get_issue_content(self, repo_name: str, issue_number: int) -> str:
|
||||
return super().get_issue_content(repo_name, issue_number)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.get_pull_request_list,
|
||||
lambda _, repo_name, state="all": f"Get pull request list from {repo_name} with state '{state}'",
|
||||
lambda prs: f"Retrieved {len(prs)} pull requests",
|
||||
)
|
||||
def get_pull_request_list(
|
||||
self, repo_name: str, state: Literal["open", "closed", "all"] = "all"
|
||||
) -> list[dict[str, object]]:
|
||||
return super().get_pull_request_list(repo_name, state)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.get_pull_request_code,
|
||||
lambda _, repo_name, pr_number: f"Get code for pull request {pr_number} in {repo_name}",
|
||||
lambda code: f"Retrieved {len(code)} code files",
|
||||
)
|
||||
def get_pull_request_code(self, repo_name: str, pr_number: int) -> list[dict[str, str]]:
|
||||
return super().get_pull_request_code(repo_name, pr_number)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.get_pull_request_comments,
|
||||
lambda _, repo_name, pr_number: f"Get comments for pull request {pr_number} in {repo_name}",
|
||||
lambda comments: f"Retrieved {len(comments)} comments",
|
||||
)
|
||||
def get_pull_request_comments(self, repo_name: str, pr_number: int) -> list[dict[str, str]]:
|
||||
return super().get_pull_request_comments(repo_name, pr_number)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.get_all_file_paths,
|
||||
lambda _, repo_name, path="": f"Get all file paths from {repo_name}, path '{path}'",
|
||||
lambda paths: f"Retrieved {len(paths)} file paths",
|
||||
)
|
||||
def get_all_file_paths(self, repo_name: str, path: str = "") -> list[str]:
|
||||
return super().get_all_file_paths(repo_name, path)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseGithubToolkit.retrieve_file_content,
|
||||
lambda _, repo_name, file_path: f"Retrieve content of file {file_path} from {repo_name}",
|
||||
lambda content: f"Retrieved content of length {len(content)}",
|
||||
)
|
||||
def retrieve_file_content(self, repo_name: str, file_path: str) -> str:
|
||||
return super().retrieve_file_content(repo_name, file_path)
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
if env("GITHUB_ACCESS_TOKEN"):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import threading
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from app.utils.oauth_state_manager import oauth_state_manager
|
||||
from camel.toolkits import GoogleCalendarToolkit as BaseGoogleCalendarToolkit
|
||||
|
|
@ -12,6 +12,7 @@ from loguru import logger
|
|||
|
||||
SCOPES = ['https://www.googleapis.com/auth/calendar']
|
||||
|
||||
@auto_listen_toolkit(BaseGoogleCalendarToolkit)
|
||||
class GoogleCalendarToolkit(BaseGoogleCalendarToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
|
|
@ -29,50 +30,6 @@ class GoogleCalendarToolkit(BaseGoogleCalendarToolkit, AbstractToolkit):
|
|||
)
|
||||
super().__init__(timeout)
|
||||
|
||||
@listen_toolkit(BaseGoogleCalendarToolkit.create_event)
|
||||
def create_event(
|
||||
self,
|
||||
event_title: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
attendees_email: List[str] | None = None,
|
||||
timezone: str = "UTC",
|
||||
) -> Dict[str, Any]:
|
||||
return super().create_event(
|
||||
event_title, start_time, end_time, description, location, attendees_email, timezone
|
||||
)
|
||||
|
||||
@listen_toolkit(BaseGoogleCalendarToolkit.get_events)
|
||||
def get_events(
|
||||
self, max_results: int = 10, time_min: str | None = None
|
||||
) -> List[Dict[str, Any]] | Dict[str, Any]:
|
||||
return super().get_events(max_results, time_min)
|
||||
|
||||
@listen_toolkit(BaseGoogleCalendarToolkit.update_event)
|
||||
def update_event(
|
||||
self,
|
||||
event_id: str,
|
||||
event_title: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
attendees_email: List[str] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
return super().update_event(
|
||||
event_id, event_title, start_time, end_time, description, location, attendees_email
|
||||
)
|
||||
|
||||
@listen_toolkit(BaseGoogleCalendarToolkit.delete_event)
|
||||
def delete_event(self, event_id: str) -> str:
|
||||
return super().delete_event(event_id)
|
||||
|
||||
@listen_toolkit(BaseGoogleCalendarToolkit.get_calendar_details)
|
||||
def get_calendar_details(self) -> Dict[str, Any]:
|
||||
return super().get_calendar_details()
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str):
|
||||
if env("GOOGLE_CLIENT_ID") and env("GOOGLE_CLIENT_SECRET"):
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import asyncio
|
||||
from camel.toolkits.base import BaseToolkit
|
||||
from loguru import logger
|
||||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.service.task import Action, ActionAskData, ActionNoticeData, get_task_lock
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from app.service.task import process_task
|
||||
# Rewrite HumanToolkit because the system's user interaction was using console, but in electron we cannot use console. Changed to use SSE response to let frontend show dialog for user interaction
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("human_toolkit")
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseToolkit)
|
||||
class HumanToolkit(BaseToolkit, AbstractToolkit):
|
||||
r"""A class representing a toolkit for human interaction.
|
||||
Note:
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ from camel.toolkits.hybrid_browser_toolkit_py.actions import ActionExecutor
|
|||
from camel.toolkits.hybrid_browser_toolkit_py.snapshot import PageSnapshot
|
||||
from camel.toolkits.hybrid_browser_toolkit_py.agent import PlaywrightLLMAgent
|
||||
from camel.toolkits.function_tool import FunctionTool
|
||||
from loguru import logger
|
||||
from app.component.environment import env
|
||||
from app.exception.exception import ProgramException
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("hybrid_browser_python_toolkit")
|
||||
|
||||
|
||||
class BrowserSession(BaseHybridBrowserSession):
|
||||
|
|
@ -124,6 +126,7 @@ class BrowserSession(BaseHybridBrowserSession):
|
|||
break
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseHybridBrowserToolkit)
|
||||
class HybridBrowserPythonToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.search_agent
|
||||
|
||||
|
|
@ -224,14 +227,6 @@ class HybridBrowserPythonToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
|||
self._agent: PlaywrightLLMAgent | None = None
|
||||
self._unified_script = self._load_unified_analyzer()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_open)
|
||||
async def browser_open(self) -> Dict[str, str]:
|
||||
return await super().browser_open()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_close)
|
||||
async def browser_close(self) -> str:
|
||||
return await super().browser_close()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_visit_page, lambda _, url: url)
|
||||
async def browser_visit_page(self, url: str) -> Dict[str, Any]:
|
||||
r"""Navigates to a URL.
|
||||
|
|
@ -282,66 +277,6 @@ class HybridBrowserPythonToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
|||
|
||||
return {"result": nav_result, "snapshot": snapshot, **tab_info}
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_back)
|
||||
async def browser_back(self) -> Dict[str, Any]:
|
||||
return await super().browser_back()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_forward)
|
||||
async def browser_forward(self) -> Dict[str, Any]:
|
||||
return await super().browser_forward()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_click)
|
||||
async def browser_click(self, *, ref: str) -> Dict[str, Any]:
|
||||
return await super().browser_click(ref=ref)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_type)
|
||||
async def browser_type(self, *, ref: str, text: str) -> Dict[str, Any]:
|
||||
return await super().browser_type(ref=ref, text=text)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_switch_tab)
|
||||
async def browser_switch_tab(self, *, tab_id: str) -> Dict[str, Any]:
|
||||
return await super().browser_switch_tab(tab_id=tab_id)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_select)
|
||||
async def browser_select(self, *, ref: str, value: str) -> Dict[str, str]:
|
||||
return await super().browser_select(ref=ref, value=value)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_scroll)
|
||||
async def browser_scroll(self, *, direction: str, amount: int) -> Dict[str, str]:
|
||||
return await super().browser_scroll(direction=direction, amount=amount)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_wait_user)
|
||||
async def browser_wait_user(self, timeout_sec: float | None = None) -> Dict[str, str]:
|
||||
return await super().browser_wait_user(timeout_sec)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_enter)
|
||||
async def browser_enter(self) -> Dict[str, str]:
|
||||
return await super().browser_enter()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_solve_task)
|
||||
async def browser_solve_task(self, task_prompt: str, start_url: str, max_steps: int = 15) -> str:
|
||||
return await super().browser_solve_task(task_prompt, start_url, max_steps)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_page_snapshot)
|
||||
async def browser_get_page_snapshot(self) -> str:
|
||||
return await super().browser_get_page_snapshot()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_som_screenshot)
|
||||
async def browser_get_som_screenshot(self):
|
||||
return await super().browser_get_som_screenshot()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_page_links)
|
||||
async def browser_get_page_links(self, *, ref: List[str]) -> Dict[str, Any]:
|
||||
return await super().browser_get_page_links(ref=ref)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_close_tab)
|
||||
async def browser_close_tab(self, *, tab_id: str) -> Dict[str, Any]:
|
||||
return await super().browser_close_tab(tab_id=tab_id)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_tab_info)
|
||||
async def browser_get_tab_info(self) -> Dict[str, Any]:
|
||||
return await super().browser_get_tab_info()
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
browser = HybridBrowserPythonToolkit(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import time
|
|||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from loguru import logger
|
||||
import websockets
|
||||
import websockets.exceptions
|
||||
|
||||
|
|
@ -16,8 +15,11 @@ from camel.toolkits.hybrid_browser_toolkit.ws_wrapper import WebSocketBrowserWra
|
|||
from app.component.command import bun, uv
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("hybrid_browser_toolkit")
|
||||
|
||||
|
||||
class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
|
||||
|
|
@ -45,8 +47,13 @@ class WebSocketBrowserWrapper(BaseWebSocketBrowserWrapper):
|
|||
future.set_result(response)
|
||||
logger.debug(f"Processed response for message {message_id}")
|
||||
else:
|
||||
# Log unexpected messages
|
||||
logger.warning(f"Received unexpected message: {response}")
|
||||
message_summary = {
|
||||
"id": response.get("id"),
|
||||
"success": response.get("success"),
|
||||
"has_result": "result" in response,
|
||||
"result_type": type(response.get("result")).__name__ if "result" in response else None
|
||||
}
|
||||
logger.debug(f"Received unexpected message: {message_summary}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
disconnect_reason = "Receive loop cancelled"
|
||||
|
|
@ -210,6 +217,7 @@ class WebSocketConnectionPool:
|
|||
websocket_connection_pool = WebSocketConnectionPool()
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseHybridBrowserToolkit)
|
||||
class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.search_agent
|
||||
|
||||
|
|
@ -240,7 +248,10 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
|||
cdp_keep_current_page: bool = False,
|
||||
full_visual_mode: bool = False,
|
||||
) -> None:
|
||||
logger.info(f"[HybridBrowserToolkit] Initializing with api_task_id: {api_task_id}")
|
||||
self.api_task_id = api_task_id
|
||||
logger.debug(f"[HybridBrowserToolkit] api_task_id set to: {self.api_task_id}")
|
||||
logger.debug(f"[HybridBrowserToolkit] Calling super().__init__ with session_id: {session_id}")
|
||||
super().__init__(
|
||||
headless=headless,
|
||||
user_data_dir=user_data_dir,
|
||||
|
|
@ -264,16 +275,20 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
|||
cdp_keep_current_page=cdp_keep_current_page,
|
||||
full_visual_mode=full_visual_mode,
|
||||
)
|
||||
logger.info(f"[HybridBrowserToolkit] Initialization complete for api_task_id: {self.api_task_id}")
|
||||
|
||||
async def _ensure_ws_wrapper(self):
|
||||
"""Ensure WebSocket wrapper is initialized using connection pool."""
|
||||
logger.debug(f"[HybridBrowserToolkit] _ensure_ws_wrapper called for api_task_id: {getattr(self, 'api_task_id', 'NOT SET')}")
|
||||
global websocket_connection_pool
|
||||
|
||||
# Get session ID from config or use default
|
||||
session_id = self._ws_config.get("session_id", "default")
|
||||
logger.debug(f"[HybridBrowserToolkit] Using session_id: {session_id}")
|
||||
|
||||
# Get or create connection from pool
|
||||
self._ws_wrapper = await websocket_connection_pool.get_connection(session_id, self._ws_config)
|
||||
logger.info(f"[HybridBrowserToolkit] WebSocket wrapper initialized for session: {session_id}")
|
||||
|
||||
# Additional health check
|
||||
if self._ws_wrapper.websocket is None:
|
||||
|
|
@ -336,74 +351,3 @@ class HybridBrowserToolkit(BaseHybridBrowserToolkit, AbstractToolkit):
|
|||
if hasattr(self, "_ws_wrapper") and self._ws_wrapper:
|
||||
session_id = self._ws_config.get("session_id", "default")
|
||||
logger.debug(f"HybridBrowserToolkit for session {session_id} is being garbage collected")
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_open)
|
||||
async def browser_open(self) -> Dict[str, Any]:
|
||||
return await super().browser_open()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_close)
|
||||
async def browser_close(self) -> str:
|
||||
return await super().browser_close()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_visit_page)
|
||||
async def browser_visit_page(self, url: str) -> Dict[str, Any]:
|
||||
logger.debug(f"browser_visit_page called with URL: {url}")
|
||||
try:
|
||||
result = await super().browser_visit_page(url)
|
||||
logger.debug(f"browser_visit_page succeeded for URL: {url}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"browser_visit_page failed for URL {url}: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_back)
|
||||
async def browser_back(self) -> Dict[str, Any]:
|
||||
return await super().browser_back()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_forward)
|
||||
async def browser_forward(self) -> Dict[str, Any]:
|
||||
return await super().browser_forward()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_page_snapshot)
|
||||
async def browser_get_page_snapshot(self) -> str:
|
||||
return await super().browser_get_page_snapshot()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_som_screenshot)
|
||||
async def browser_get_som_screenshot(self, read_image: bool = False, instruction: str | None = None) -> str:
|
||||
return await super().browser_get_som_screenshot(read_image, instruction)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_click)
|
||||
async def browser_click(self, *, ref: str) -> Dict[str, Any]:
|
||||
return await super().browser_click(ref=ref)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_type)
|
||||
async def browser_type(self, *, ref: str, text: str) -> Dict[str, Any]:
|
||||
return await super().browser_type(ref=ref, text=text)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_select)
|
||||
async def browser_select(self, *, ref: str, value: str) -> Dict[str, Any]:
|
||||
return await super().browser_select(ref=ref, value=value)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_scroll)
|
||||
async def browser_scroll(self, *, direction: str, amount: int = 500) -> Dict[str, Any]:
|
||||
return await super().browser_scroll(direction=direction, amount=amount)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_enter)
|
||||
async def browser_enter(self) -> Dict[str, Any]:
|
||||
return await super().browser_enter()
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_wait_user)
|
||||
async def browser_wait_user(self, timeout_sec: float | None = None) -> Dict[str, Any]:
|
||||
return await super().browser_wait_user(timeout_sec)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_switch_tab)
|
||||
async def browser_switch_tab(self, *, tab_id: str) -> Dict[str, Any]:
|
||||
return await super().browser_switch_tab(tab_id=tab_id)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_close_tab)
|
||||
async def browser_close_tab(self, *, tab_id: str) -> Dict[str, Any]:
|
||||
return await super().browser_close_tab(tab_id=tab_id)
|
||||
|
||||
@listen_toolkit(BaseHybridBrowserToolkit.browser_get_tab_info)
|
||||
async def browser_get_tab_info(self) -> Dict[str, Any]:
|
||||
return await super().browser_get_tab_info()
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ from camel.models import BaseModelBackend
|
|||
from camel.toolkits import ImageAnalysisToolkit as BaseImageAnalysisToolkit
|
||||
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseImageAnalysisToolkit)
|
||||
class ImageAnalysisToolkit(BaseImageAnalysisToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.multi_modal_agent
|
||||
|
||||
|
|
@ -17,24 +18,3 @@ class ImageAnalysisToolkit(BaseImageAnalysisToolkit, AbstractToolkit):
|
|||
):
|
||||
super().__init__(model, timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseImageAnalysisToolkit.image_to_text,
|
||||
lambda _,
|
||||
image_path,
|
||||
sys_prompt: f"transcribe image from {image_path} and ask sys_prompt: {sys_prompt}",
|
||||
)
|
||||
def image_to_text(self, image_path: str, sys_prompt: str | None = None) -> str:
|
||||
return super().image_to_text(image_path, sys_prompt)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseImageAnalysisToolkit.ask_question_about_image,
|
||||
lambda _,
|
||||
image_path,
|
||||
question,
|
||||
sys_prompt: f"transcribe image from {image_path} and ask question: {question} with sys_prompt: {sys_prompt}",
|
||||
)
|
||||
def ask_question_about_image(
|
||||
self, image_path: str, question: str, sys_prompt: str | None = None
|
||||
) -> str:
|
||||
return super().ask_question_about_image(image_path, question, sys_prompt)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ from camel.toolkits import LinkedInToolkit as BaseLinkedInToolkit
|
|||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseLinkedInToolkit)
|
||||
class LinkedInToolkit(BaseLinkedInToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
|
|
@ -13,27 +14,6 @@ class LinkedInToolkit(BaseLinkedInToolkit, AbstractToolkit):
|
|||
super().__init__(timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseLinkedInToolkit.create_post,
|
||||
lambda _, text: f"create a LinkedIn post with text: {text}",
|
||||
)
|
||||
def create_post(self, text: str) -> dict:
|
||||
return super().create_post(text)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseLinkedInToolkit.delete_post,
|
||||
lambda _, post_id: f"delete LinkedIn post with id: {post_id}",
|
||||
)
|
||||
def delete_post(self, post_id: str) -> str:
|
||||
return super().delete_post(post_id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseLinkedInToolkit.get_profile,
|
||||
lambda _, include_id: f"get LinkedIn profile with include_id: {include_id}",
|
||||
)
|
||||
def get_profile(self, include_id: bool = False) -> dict:
|
||||
return super().get_profile(include_id)
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
if env("LINKEDIN_ACCESS_TOKEN"):
|
||||
|
|
|
|||
|
|
@ -2,17 +2,14 @@ from typing import Dict, List
|
|||
from camel.toolkits import MarkItDownToolkit as BaseMarkItDownToolkit
|
||||
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseMarkItDownToolkit)
|
||||
class MarkItDownToolkit(BaseMarkItDownToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
|
||||
def __init__(self, api_task_id: str, timeout: float | None = None):
|
||||
self.api_task_id = api_task_id
|
||||
super().__init__(timeout)
|
||||
|
||||
@listen_toolkit(BaseMarkItDownToolkit.read_files)
|
||||
def read_files(self, file_paths: List[str]) -> Dict[str, str]:
|
||||
return super().read_files(file_paths)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from typing import Optional
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseNoteTakingToolkit)
|
||||
class NoteTakingToolkit(BaseNoteTakingToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
|
||||
|
|
@ -25,19 +26,3 @@ class NoteTakingToolkit(BaseNoteTakingToolkit, AbstractToolkit):
|
|||
if working_directory is None:
|
||||
working_directory = env("file_save_path", os.path.expanduser("~/.eigent/notes")) + "/note.md"
|
||||
super().__init__(working_directory=working_directory, timeout=timeout)
|
||||
|
||||
@listen_toolkit(BaseNoteTakingToolkit.append_note)
|
||||
def append_note(self, note_name: str, content: str) -> str:
|
||||
return super().append_note(note_name=note_name, content=content)
|
||||
|
||||
@listen_toolkit(BaseNoteTakingToolkit.read_note)
|
||||
def read_note(self, note_name: Optional[str] = "all_notes") -> str:
|
||||
return super().read_note(note_name=note_name)
|
||||
|
||||
@listen_toolkit(BaseNoteTakingToolkit.create_note)
|
||||
def create_note(self, note_name: str, content: str, overwrite: bool = False) -> str:
|
||||
return super().create_note(note_name=note_name, content=content, overwrite=overwrite)
|
||||
|
||||
@listen_toolkit(BaseNoteTakingToolkit.list_note)
|
||||
def list_note(self) -> str:
|
||||
return super().list_note()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,32 @@
|
|||
import os
|
||||
from typing import Any, Dict, List
|
||||
from loguru import logger
|
||||
from camel.toolkits import FunctionTool
|
||||
from app.component.environment import env
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from camel.toolkits.mcp_toolkit import MCPToolkit
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("notion_mcp_toolkit")
|
||||
|
||||
def _customize_function_parameters(schema: Dict[str, Any]) -> None:
|
||||
r"""Customize function parameters for specific functions.
|
||||
|
||||
This method allows modifying parameter descriptions or other schema
|
||||
attributes for specific functions.
|
||||
"""
|
||||
function_info = schema.get("function", {})
|
||||
function_name = function_info.get("name", "")
|
||||
parameters = function_info.get("parameters", {})
|
||||
properties = parameters.get("properties", {})
|
||||
required = parameters.get("required", [])
|
||||
|
||||
# Modify the notion-create-pages function to make parent optional
|
||||
if function_name == "notion-create-pages":
|
||||
required.remove("parent")
|
||||
parameters["required"] = required
|
||||
if "parent" in properties:
|
||||
# Update the parent parameter description
|
||||
properties["parent"]["description"] = "Optional. " + properties["parent"]["description"]
|
||||
|
||||
class NotionMCPToolkit(MCPToolkit, AbstractToolkit):
|
||||
|
||||
|
|
@ -33,68 +54,7 @@ class NotionMCPToolkit(MCPToolkit, AbstractToolkit):
|
|||
}
|
||||
}
|
||||
}
|
||||
super().__init__(config_dict=config_dict, timeout=timeout)
|
||||
|
||||
def get_tools(self) -> List[FunctionTool]:
|
||||
r"""Returns a list of tools provided by the NotionMCPToolkit.
|
||||
|
||||
Returns:
|
||||
List[FunctionTool]: List of available tools.
|
||||
"""
|
||||
all_tools = []
|
||||
for client in self.clients:
|
||||
try:
|
||||
original_build_schema = client._build_tool_schema
|
||||
|
||||
def create_wrapper(orig_func):
|
||||
def wrapper(mcp_tool):
|
||||
return self._build_custom_tool_schema(
|
||||
mcp_tool, orig_func
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
client._build_tool_schema = create_wrapper( # type: ignore[method-assign]
|
||||
original_build_schema
|
||||
)
|
||||
|
||||
client_tools = client.get_tools()
|
||||
all_tools.extend(client_tools)
|
||||
|
||||
client._build_tool_schema = original_build_schema # type: ignore[method-assign]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get tools from client: {e}")
|
||||
return all_tools
|
||||
|
||||
def _build_custom_tool_schema(self, mcp_tool, original_build_schema):
|
||||
r"""Build tool schema with custom modifications."""
|
||||
schema = original_build_schema(mcp_tool)
|
||||
self._customize_function_parameters(schema)
|
||||
return schema
|
||||
|
||||
def _customize_function_parameters(self, schema: Dict[str, Any]) -> None:
|
||||
r"""Customize function parameters for specific functions.
|
||||
|
||||
This method allows modifying parameter descriptions or other schema
|
||||
attributes for specific functions.
|
||||
"""
|
||||
function_info = schema.get("function", {})
|
||||
function_name = function_info.get("name", "")
|
||||
parameters = function_info.get("parameters", {})
|
||||
properties = parameters.get("properties", {})
|
||||
|
||||
# Modify the notion-create-pages function to make parent optional
|
||||
if function_name == "notion-create-pages":
|
||||
if "parent" in properties:
|
||||
# Update the parent parameter description
|
||||
properties["parent"]["description"] = (
|
||||
"Optional. The parent under which the new pages will be created. "
|
||||
"This can be a page (page_id), a database page (database_id), or "
|
||||
"a data source/collection under a database (data_source_id). "
|
||||
"If omitted, the new pages will be created as private pages at the workspace level. "
|
||||
"Use data_source_id when you have a collection:// URL from the fetch tool."
|
||||
)
|
||||
super().__init__(config_dict=config_dict, timeout=timeout)
|
||||
|
||||
@classmethod
|
||||
async def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
|
|
@ -104,6 +64,12 @@ class NotionMCPToolkit(MCPToolkit, AbstractToolkit):
|
|||
await toolkit.connect()
|
||||
# Use subclass implementation that inlines upstream processing
|
||||
all_tools = toolkit.get_tools()
|
||||
tool_schema = [
|
||||
item.get_openai_tool_schema() for item in all_tools
|
||||
]
|
||||
#adjust tool schema
|
||||
for item in tool_schema:
|
||||
_customize_function_parameters(item)
|
||||
for item in all_tools:
|
||||
setattr(item, "_toolkit_name", cls.__name__)
|
||||
tools.append(item)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from camel.toolkits import NotionToolkit as BaseNotionToolkit
|
|||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseNotionToolkit)
|
||||
class NotionToolkit(BaseNotionToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
|
||||
|
|
@ -19,29 +20,6 @@ class NotionToolkit(BaseNotionToolkit, AbstractToolkit):
|
|||
super().__init__(notion_token, timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseNotionToolkit.list_all_pages,
|
||||
lambda _: "list all pages in Notion workspace",
|
||||
lambda result: f"{len(result)} pages found",
|
||||
)
|
||||
def list_all_pages(self) -> List[dict]:
|
||||
return super().list_all_pages()
|
||||
|
||||
@listen_toolkit(
|
||||
BaseNotionToolkit.list_all_users,
|
||||
lambda _: "list all users in Notion workspace",
|
||||
lambda result: f"{len(result)} users found",
|
||||
)
|
||||
def list_all_users(self) -> List[dict]:
|
||||
return super().list_all_users()
|
||||
|
||||
@listen_toolkit(
|
||||
BaseNotionToolkit.get_notion_block_text_content,
|
||||
lambda _, page_id: f"get text content of page with id: {page_id}",
|
||||
)
|
||||
def get_notion_block_text_content(self, block_id: str) -> str:
|
||||
return super().get_notion_block_text_content(block_id)
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> List[FunctionTool]:
|
||||
if env("NOTION_TOKEN"):
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ from camel.toolkits import OpenAIImageToolkit as BaseOpenAIImageToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from typing import Literal, Optional, Union, List
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseOpenAIImageToolkit)
|
||||
class OpenAIImageToolkit(BaseOpenAIImageToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.multi_modal_agent
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ from camel.toolkits import PPTXToolkit as BasePPTXToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import ActionWriteFileData, Agents, get_task_lock
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from app.service.task import process_task
|
||||
|
||||
|
||||
@auto_listen_toolkit(BasePPTXToolkit)
|
||||
class PPTXToolkit(BasePPTXToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.document_agent
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ from camel.toolkits import PyAutoGUIToolkit as BasePyAutoGUIToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BasePyAutoGUIToolkit)
|
||||
class PyAutoGUIToolkit(BasePyAutoGUIToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.search_agent
|
||||
|
||||
|
|
@ -21,69 +22,3 @@ class PyAutoGUIToolkit(BasePyAutoGUIToolkit, AbstractToolkit):
|
|||
screenshots_dir = env("file_save_path", os.path.expanduser("~/Downloads"))
|
||||
super().__init__(timeout, screenshots_dir)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(BasePyAutoGUIToolkit.mouse_move, lambda _, x, y: f"mouse move to {x}, {y}")
|
||||
def mouse_move(self, x: int, y: int) -> str:
|
||||
return super().mouse_move(x, y)
|
||||
|
||||
@listen_toolkit(
|
||||
BasePyAutoGUIToolkit.mouse_click,
|
||||
lambda _, button="left", clicks=1, x=None, y=None: f"mouse click {button} {clicks} times at {x}, {y}",
|
||||
)
|
||||
def mouse_click(
|
||||
self,
|
||||
button: Literal["left", "middle", "right"] = "left",
|
||||
clicks: int = 1,
|
||||
x: int | None = None,
|
||||
y: int | None = None,
|
||||
) -> str:
|
||||
return super().mouse_click(button, clicks, x, y)
|
||||
|
||||
@listen_toolkit(
|
||||
BasePyAutoGUIToolkit.keyboard_type,
|
||||
lambda _, text, interval=0: f"keyboard type {text}, interval {interval}",
|
||||
)
|
||||
def keyboard_type(self, text: str, interval: float = 0) -> str:
|
||||
return super().keyboard_type(text, interval)
|
||||
|
||||
@listen_toolkit(BasePyAutoGUIToolkit.take_screenshot)
|
||||
def take_screenshot(self) -> str:
|
||||
return super().take_screenshot()
|
||||
|
||||
@listen_toolkit(BasePyAutoGUIToolkit.get_mouse_position)
|
||||
def get_mouse_position(self) -> str:
|
||||
return super().get_mouse_position()
|
||||
|
||||
@listen_toolkit(BasePyAutoGUIToolkit.press_key, lambda _, key: f"press key {key}")
|
||||
def press_key(self, key: str | list[str]) -> str:
|
||||
return super().press_key(key)
|
||||
|
||||
@listen_toolkit(BasePyAutoGUIToolkit.hotkey, lambda _, keys: f"hotkey {keys}")
|
||||
def hotkey(self, keys: List[str]) -> str:
|
||||
return super().hotkey(keys)
|
||||
|
||||
@listen_toolkit(
|
||||
BasePyAutoGUIToolkit.mouse_drag,
|
||||
lambda _,
|
||||
start_x,
|
||||
start_y,
|
||||
end_x,
|
||||
end_y,
|
||||
button="left": f"mouse drag from {start_x}, {start_y} to {end_x}, {end_y} with {button} button",
|
||||
)
|
||||
def mouse_drag(
|
||||
self,
|
||||
start_x: int,
|
||||
start_y: int,
|
||||
end_x: int,
|
||||
end_y: int,
|
||||
button: Literal["left", "middle", "right"] = "left",
|
||||
) -> str:
|
||||
return super().mouse_drag(start_x, start_y, end_x, end_y, button)
|
||||
|
||||
@listen_toolkit(
|
||||
BasePyAutoGUIToolkit.scroll,
|
||||
lambda _, scroll_amount, x=None, y=None: f"scroll {scroll_amount} at {x}, {y}",
|
||||
)
|
||||
def scroll(self, scroll_amount: int, x: int | None = None, y: int | None = None) -> str:
|
||||
return super().scroll(scroll_amount, x, y)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from camel.toolkits import RedditToolkit as BaseRedditToolkit
|
|||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseRedditToolkit)
|
||||
class RedditToolkit(BaseRedditToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
|
|
@ -20,47 +21,6 @@ class RedditToolkit(BaseRedditToolkit, AbstractToolkit):
|
|||
super().__init__(retries, delay, timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseRedditToolkit.collect_top_posts,
|
||||
lambda _,
|
||||
subreddit_name,
|
||||
post_limit=5,
|
||||
comment_limit=5: f"collect top posts from subreddit: {subreddit_name} with post limit: {post_limit} and comment limit: {comment_limit}",
|
||||
lambda result: f"top posts collected: {result}",
|
||||
)
|
||||
def collect_top_posts(
|
||||
self, subreddit_name: str, post_limit: int = 5, comment_limit: int = 5
|
||||
) -> List[Dict[str, Any]] | str:
|
||||
return super().collect_top_posts(subreddit_name, post_limit, comment_limit)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseRedditToolkit.perform_sentiment_analysis,
|
||||
lambda _, data: f"perform sentiment analysis on data number: {len(data)}",
|
||||
lambda result: f"perform analysis result: {result}",
|
||||
)
|
||||
def perform_sentiment_analysis(self, data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return super().perform_sentiment_analysis(data)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseRedditToolkit.track_keyword_discussions,
|
||||
lambda _,
|
||||
subreddits,
|
||||
keywords,
|
||||
post_limit=10,
|
||||
comment_limit=10,
|
||||
sentiment_analysis=False: f"track keyword discussions for subreddits: {subreddits}, keywords: {keywords}",
|
||||
lambda result: f"track keyword discussions result: {result}",
|
||||
)
|
||||
def track_keyword_discussions(
|
||||
self,
|
||||
subreddits: List[str],
|
||||
keywords: List[str],
|
||||
post_limit: int = 10,
|
||||
comment_limit: int = 10,
|
||||
sentiment_analysis: bool = False,
|
||||
) -> List[Dict[str, Any]] | str:
|
||||
return super().track_keyword_discussions(subreddits, keywords, post_limit, comment_limit, sentiment_analysis)
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
if env("REDDIT_CLIENT_ID") and env("REDDIT_CLIENT_SECRET") and env("REDDIT_USER_AGENT"):
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from camel.toolkits import ScreenshotToolkit as BaseScreenshotToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseScreenshotToolkit)
|
||||
class ScreenshotToolkit(BaseScreenshotToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.developer_agent
|
||||
|
||||
|
|
@ -15,13 +16,3 @@ class ScreenshotToolkit(BaseScreenshotToolkit, AbstractToolkit):
|
|||
if working_directory is None:
|
||||
working_directory = env("file_save_path", os.path.expanduser("~/Downloads"))
|
||||
super().__init__(working_directory, timeout)
|
||||
|
||||
@listen_toolkit(BaseScreenshotToolkit.take_screenshot_and_read_image)
|
||||
def take_screenshot_and_read_image(
|
||||
self, filename: str, save_to_file: bool = True, read_image: bool = True, instruction: str | None = None
|
||||
) -> str:
|
||||
return super().take_screenshot_and_read_image(filename, save_to_file, read_image, instruction)
|
||||
|
||||
@listen_toolkit(BaseScreenshotToolkit.read_image)
|
||||
def read_image(self, image_path: str, instruction: str = "") -> str:
|
||||
return super().read_image(image_path, instruction)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ from typing import Any, Dict, List, Literal
|
|||
from camel.toolkits import SearchToolkit as BaseSearchToolkit
|
||||
from camel.toolkits.function_tool import FunctionTool
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from app.component.environment import env, env_not_empty
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("search_toolkit")
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseSearchToolkit)
|
||||
class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.search_agent
|
||||
|
||||
|
|
@ -50,19 +53,36 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
|
|||
|
||||
@listen_toolkit(
|
||||
BaseSearchToolkit.search_google,
|
||||
lambda _, query, search_type="web": f"with query '{query}' and {search_type} result pages",
|
||||
lambda _, query, search_type="web", number_of_result_pages=10, start_page=1: f"with query '{query}', {search_type} type, {number_of_result_pages} result pages starting from page {start_page}",
|
||||
)
|
||||
def search_google(self, query: str, search_type: str = "web") -> list[dict[str, Any]]:
|
||||
def search_google(
|
||||
self,
|
||||
query: str,
|
||||
search_type: str = "web",
|
||||
number_of_result_pages: int = 10,
|
||||
start_page: int = 1
|
||||
) -> list[dict[str, Any]]:
|
||||
if env("GOOGLE_API_KEY") and env("SEARCH_ENGINE_ID"):
|
||||
return super().search_google(query, search_type)
|
||||
return super().search_google(query, search_type, number_of_result_pages, start_page)
|
||||
else:
|
||||
return self.cloud_search_google(query, search_type)
|
||||
return self.cloud_search_google(query, search_type, number_of_result_pages, start_page)
|
||||
|
||||
def cloud_search_google(self, query: str, search_type):
|
||||
def cloud_search_google(
|
||||
self,
|
||||
query: str,
|
||||
search_type: str = "web",
|
||||
number_of_result_pages: int = 10,
|
||||
start_page: int = 1
|
||||
):
|
||||
url = env_not_empty("SERVER_URL")
|
||||
res = httpx.get(
|
||||
url + "/proxy/google",
|
||||
params={"query": query, "search_type": search_type},
|
||||
params={
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"number_of_result_pages": number_of_result_pages,
|
||||
"start_page": start_page
|
||||
},
|
||||
headers={"api-key": env_not_empty("cloud_api_key")},
|
||||
)
|
||||
return res.json()
|
||||
|
|
@ -163,73 +183,73 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
|
|||
# def search_bing(self, query: str) -> dict[str, Any]:
|
||||
# return super().search_bing(query)
|
||||
|
||||
@listen_toolkit(BaseSearchToolkit.search_exa, lambda _, query, *args, **kwargs: f"{query}, {args}, {kwargs}")
|
||||
def search_exa(
|
||||
self,
|
||||
query: str,
|
||||
search_type: Literal["auto", "neural", "keyword"] = "auto",
|
||||
category: None
|
||||
| Literal[
|
||||
"company",
|
||||
"research paper",
|
||||
"news",
|
||||
"pdf",
|
||||
"github",
|
||||
"tweet",
|
||||
"personal site",
|
||||
"linkedin profile",
|
||||
"financial report",
|
||||
] = None,
|
||||
include_text: List[str] | None = None,
|
||||
exclude_text: List[str] | None = None,
|
||||
use_autoprompt: bool = True,
|
||||
text: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
if env("EXA_API_KEY"):
|
||||
res = super().search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
|
||||
return res
|
||||
else:
|
||||
return self.cloud_search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
|
||||
|
||||
def cloud_search_exa(
|
||||
self,
|
||||
query: str,
|
||||
search_type: Literal["auto", "neural", "keyword"] = "auto",
|
||||
category: None
|
||||
| Literal[
|
||||
"company",
|
||||
"research paper",
|
||||
"news",
|
||||
"pdf",
|
||||
"github",
|
||||
"tweet",
|
||||
"personal site",
|
||||
"linkedin profile",
|
||||
"financial report",
|
||||
] = None,
|
||||
include_text: List[str] | None = None,
|
||||
exclude_text: List[str] | None = None,
|
||||
use_autoprompt: bool = True,
|
||||
text: bool = False,
|
||||
):
|
||||
url = env_not_empty("SERVER_URL")
|
||||
logger.debug(f">>>>>>>>>>>>>>>>{url}<<<<")
|
||||
res = httpx.post(
|
||||
url + "/proxy/exa",
|
||||
json={
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"category": category,
|
||||
"include_text": include_text,
|
||||
"exclude_text": exclude_text,
|
||||
"use_autoprompt": use_autoprompt,
|
||||
"text": text,
|
||||
},
|
||||
headers={"api-key": env_not_empty("cloud_api_key")},
|
||||
)
|
||||
logger.debug(">>>>>>>>>>>>>>>>>")
|
||||
logger.debug(res)
|
||||
return res.json()
|
||||
# @listen_toolkit(BaseSearchToolkit.search_exa, lambda _, query, *args, **kwargs: f"{query}, {args}, {kwargs}")
|
||||
# def search_exa(
|
||||
# self,
|
||||
# query: str,
|
||||
# search_type: Literal["auto", "neural", "keyword"] = "auto",
|
||||
# category: None
|
||||
# | Literal[
|
||||
# "company",
|
||||
# "research paper",
|
||||
# "news",
|
||||
# "pdf",
|
||||
# "github",
|
||||
# "tweet",
|
||||
# "personal site",
|
||||
# "linkedin profile",
|
||||
# "financial report",
|
||||
# ] = None,
|
||||
# include_text: List[str] | None = None,
|
||||
# exclude_text: List[str] | None = None,
|
||||
# use_autoprompt: bool = True,
|
||||
# text: bool = False,
|
||||
# ) -> Dict[str, Any]:
|
||||
# if env("EXA_API_KEY"):
|
||||
# res = super().search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
|
||||
# return res
|
||||
# else:
|
||||
# return self.cloud_search_exa(query, search_type, category, include_text, exclude_text, use_autoprompt, text)
|
||||
#
|
||||
# def cloud_search_exa(
|
||||
# self,
|
||||
# query: str,
|
||||
# search_type: Literal["auto", "neural", "keyword"] = "auto",
|
||||
# category: None
|
||||
# | Literal[
|
||||
# "company",
|
||||
# "research paper",
|
||||
# "news",
|
||||
# "pdf",
|
||||
# "github",
|
||||
# "tweet",
|
||||
# "personal site",
|
||||
# "linkedin profile",
|
||||
# "financial report",
|
||||
# ] = None,
|
||||
# include_text: List[str] | None = None,
|
||||
# exclude_text: List[str] | None = None,
|
||||
# use_autoprompt: bool = True,
|
||||
# text: bool = False,
|
||||
# ):
|
||||
# url = env_not_empty("SERVER_URL")
|
||||
# logger.debug(f">>>>>>>>>>>>>>>>{url}<<<<")
|
||||
# res = httpx.post(
|
||||
# url + "/proxy/exa",
|
||||
# json={
|
||||
# "query": query,
|
||||
# "search_type": search_type,
|
||||
# "category": category,
|
||||
# "include_text": include_text,
|
||||
# "exclude_text": exclude_text,
|
||||
# "use_autoprompt": use_autoprompt,
|
||||
# "text": text,
|
||||
# },
|
||||
# headers={"api-key": env_not_empty("cloud_api_key")},
|
||||
# )
|
||||
# logger.debug(">>>>>>>>>>>>>>>>>")
|
||||
# logger.debug(res)
|
||||
# return res.json()
|
||||
|
||||
# @listen_toolkit(
|
||||
# BaseSearchToolkit.search_alibaba_tongxiao,
|
||||
|
|
@ -289,12 +309,12 @@ class SearchToolkit(BaseSearchToolkit, AbstractToolkit):
|
|||
# if env("BOCHA_API_KEY"):
|
||||
# tools.append(FunctionTool(search_toolkit.search_bocha))
|
||||
|
||||
if env("EXA_API_KEY") or env("cloud_api_key"):
|
||||
tools.append(FunctionTool(search_toolkit.search_exa))
|
||||
# if env("EXA_API_KEY") or env("cloud_api_key"):
|
||||
# tools.append(FunctionTool(search_toolkit.search_exa))
|
||||
|
||||
# if env("TONGXIAO_API_KEY"):
|
||||
# tools.append(FunctionTool(search_toolkit.search_alibaba_tongxiao))
|
||||
return tools
|
||||
|
||||
def get_tools(self) -> List[FunctionTool]:
|
||||
return [FunctionTool(self.search_exa)]
|
||||
# def get_tools(self) -> List[FunctionTool]:
|
||||
# return [FunctionTool(self.search_exa)]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from camel.toolkits import SlackToolkit as BaseSlackToolkit
|
||||
from camel.toolkits.function_tool import FunctionTool
|
||||
from loguru import logger
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("slack_toolkit")
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseSlackToolkit)
|
||||
class SlackToolkit(BaseSlackToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
|
|
@ -14,71 +17,6 @@ class SlackToolkit(BaseSlackToolkit, AbstractToolkit):
|
|||
super().__init__(timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.create_slack_channel,
|
||||
lambda _, name, is_private=True: f"create a Slack channel with name: {name} and is_private: {is_private}",
|
||||
)
|
||||
def create_slack_channel(self, name: str, is_private: bool | None = True) -> str:
|
||||
return super().create_slack_channel(name, is_private)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.join_slack_channel,
|
||||
lambda _, channel_id: f"join Slack channel with id: {channel_id}",
|
||||
)
|
||||
def join_slack_channel(self, channel_id: str) -> str:
|
||||
return super().join_slack_channel(channel_id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.leave_slack_channel,
|
||||
lambda _, channel_id: f"leave Slack channel with id: {channel_id}",
|
||||
)
|
||||
def leave_slack_channel(self, channel_id: str) -> str:
|
||||
return super().leave_slack_channel(channel_id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.get_slack_channel_information,
|
||||
lambda _: "get Slack channel information",
|
||||
)
|
||||
def get_slack_channel_information(self) -> str:
|
||||
return super().get_slack_channel_information()
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.get_slack_channel_message,
|
||||
lambda _, channel_id: f"get Slack channel message for channel id: {channel_id}",
|
||||
)
|
||||
def get_slack_channel_message(self, channel_id: str) -> str:
|
||||
return super().get_slack_channel_message(channel_id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.send_slack_message,
|
||||
lambda _, message, channel_id, file_path=None, user=None: f"send Slack message: {message} to channel id: {channel_id}, file: {file_path}, user: {user}",
|
||||
)
|
||||
def send_slack_message(self, message: str, channel_id: str, file_path: str | None = None, user: str | None = None) -> str:
|
||||
return super().send_slack_message(message, channel_id, file_path, user)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.delete_slack_message,
|
||||
lambda _,
|
||||
time_stamp,
|
||||
channel_id: f"delete Slack message with timestamp: {time_stamp} in channel id: {channel_id}",
|
||||
)
|
||||
def delete_slack_message(self, time_stamp: str, channel_id: str) -> str:
|
||||
return super().delete_slack_message(time_stamp, channel_id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.get_slack_user_list,
|
||||
lambda _: "get Slack user list",
|
||||
)
|
||||
def get_slack_user_list(self) -> str:
|
||||
return super().get_slack_user_list()
|
||||
|
||||
@listen_toolkit(
|
||||
BaseSlackToolkit.get_slack_user_info,
|
||||
lambda _, user_id: f"get Slack user info with user id: {user_id}",
|
||||
)
|
||||
def get_slack_user_info(self, user_id: str) -> str:
|
||||
return super().get_slack_user_info(user_id)
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
logger.debug(f"slack===={env('SLACK_BOT_TOKEN')}")
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ from camel.toolkits.terminal_toolkit import TerminalToolkit as BaseTerminalToolk
|
|||
from camel.toolkits.terminal_toolkit.terminal_toolkit import _to_plain
|
||||
from app.component.environment import env
|
||||
from app.service.task import Action, ActionTerminalData, Agents, get_task_lock
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
from app.service.task import process_task
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseTerminalToolkit)
|
||||
class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.developer_agent
|
||||
|
||||
|
|
@ -67,45 +68,3 @@ class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
|
|||
)
|
||||
if hasattr(task_lock, "add_background_task"):
|
||||
task_lock.add_background_task(task)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseTerminalToolkit.shell_exec,
|
||||
lambda _, id, command, block=True: f"id: {id}, command: {command}, block: {block}",
|
||||
)
|
||||
def shell_exec(self, id: str, command: str, block: bool = True) -> str:
|
||||
return super().shell_exec(id=id, command=command, block=block)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseTerminalToolkit.shell_view,
|
||||
lambda _, id: f"id: {id}",
|
||||
)
|
||||
def shell_view(self, id: str) -> str:
|
||||
return super().shell_view(id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseTerminalToolkit.shell_wait,
|
||||
lambda _, id, wait_seconds=None: f"id: {id}, wait_seconds: {wait_seconds}",
|
||||
)
|
||||
def shell_wait(self, id: str, wait_seconds: float = 5.0) -> str:
|
||||
return super().shell_wait(id=id, wait_seconds=wait_seconds)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseTerminalToolkit.shell_write_to_process,
|
||||
lambda _, id, command: f"id: {id}, command: {command}",
|
||||
)
|
||||
def shell_write_to_process(self, id: str, command: str) -> str:
|
||||
return super().shell_write_to_process(id=id, command=command)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseTerminalToolkit.shell_kill_process,
|
||||
lambda _, id: f"id: {id}",
|
||||
)
|
||||
def shell_kill_process(self, id: str) -> str:
|
||||
return super().shell_kill_process(id=id)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseTerminalToolkit.shell_ask_user_for_help,
|
||||
lambda _, id, prompt: f"id: {id}, prompt: {prompt}",
|
||||
)
|
||||
def shell_ask_user_for_help(self, id: str, prompt: str) -> str:
|
||||
return super().shell_ask_user_for_help(id=id, prompt=prompt)
|
||||
|
|
|
|||
|
|
@ -1,40 +1,13 @@
|
|||
from camel.toolkits import ThinkingToolkit as BaseThinkingToolkit
|
||||
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseThinkingToolkit)
|
||||
class ThinkingToolkit(BaseThinkingToolkit, AbstractToolkit):
|
||||
|
||||
def __init__(self, api_task_id: str, agent_name: str, timeout: float | None = None):
|
||||
super().__init__(timeout)
|
||||
self.api_task_id = api_task_id
|
||||
self.agent_name = agent_name
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.plan)
|
||||
def plan(self, plan: str) -> str:
|
||||
return super().plan(plan)
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.hypothesize)
|
||||
def hypothesize(self, hypothesis: str) -> str:
|
||||
return super().hypothesize(hypothesis)
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.think)
|
||||
def think(self, thought: str) -> str:
|
||||
return super().think(thought)
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.contemplate)
|
||||
def contemplate(self, contemplation: str) -> str:
|
||||
return super().contemplate(contemplation)
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.critique)
|
||||
def critique(self, critique: str) -> str:
|
||||
return super().critique(critique)
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.synthesize)
|
||||
def synthesize(self, synthesis: str) -> str:
|
||||
return super().synthesize(synthesis)
|
||||
|
||||
@listen_toolkit(BaseThinkingToolkit.reflect)
|
||||
def reflect(self, reflection: str) -> str:
|
||||
return super().reflect(reflection)
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ from camel.toolkits.twitter_toolkit import (
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseTwitterToolkit)
|
||||
class TwitterToolkit(BaseTwitterToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ from camel.toolkits import VideoAnalysisToolkit as BaseVideoAnalysisToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseVideoAnalysisToolkit)
|
||||
class VideoAnalysisToolkit(BaseVideoAnalysisToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.multi_modal_agent
|
||||
|
||||
|
|
@ -36,10 +37,3 @@ class VideoAnalysisToolkit(BaseVideoAnalysisToolkit, AbstractToolkit):
|
|||
cookies_path,
|
||||
timeout,
|
||||
)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseVideoAnalysisToolkit.ask_question_about_video,
|
||||
lambda _, video_path, question: f"transcribe video from {video_path} and ask question: {question}",
|
||||
)
|
||||
def ask_question_about_video(self, video_path: str, question: str) -> str:
|
||||
return super().ask_question_about_video(video_path, question)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from camel.toolkits import VideoDownloaderToolkit as BaseVideoDownloaderToolkit
|
|||
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseVideoDownloaderToolkit)
|
||||
class VideoDownloaderToolkit(BaseVideoDownloaderToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.multi_modal_agent
|
||||
|
||||
|
|
@ -23,23 +24,3 @@ class VideoDownloaderToolkit(BaseVideoDownloaderToolkit, AbstractToolkit):
|
|||
working_directory = env("file_save_path", os.path.expanduser("~/Downloads"))
|
||||
super().__init__(working_directory, cookies_path, timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(BaseVideoDownloaderToolkit.download_video)
|
||||
def download_video(self, url: str) -> str:
|
||||
return super().download_video(url)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseVideoDownloaderToolkit.get_video_bytes,
|
||||
lambda _, video_path: f"get video bytes from {video_path}",
|
||||
lambda _: "get video bytes",
|
||||
)
|
||||
def get_video_bytes(self, video_path: str) -> bytes:
|
||||
return super().get_video_bytes(video_path)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseVideoDownloaderToolkit.get_video_screenshots,
|
||||
lambda _, video_path, amount: f"get video screenshots from {video_path}, amount: {amount}",
|
||||
lambda results: f"get video screenshots {len(results)}",
|
||||
)
|
||||
def get_video_screenshots(self, video_path: str, amount: int) -> List[Image]:
|
||||
return super().get_video_screenshots(video_path, amount)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from typing import Any, Dict
|
|||
from camel.toolkits import WebDeployToolkit as BaseWebDeployToolkit
|
||||
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit, listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseWebDeployToolkit)
|
||||
class WebDeployToolkit(BaseWebDeployToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.developer_agent
|
||||
|
||||
|
|
@ -43,11 +44,3 @@ class WebDeployToolkit(BaseWebDeployToolkit, AbstractToolkit):
|
|||
) -> Dict[str, Any]:
|
||||
subdirectory = str(uuid.uuid4())
|
||||
return super().deploy_folder(folder_path, port, domain, subdirectory)
|
||||
|
||||
@listen_toolkit(BaseWebDeployToolkit.stop_server)
|
||||
def stop_server(self, port: int) -> Dict[str, Any]:
|
||||
return super().stop_server(port)
|
||||
|
||||
@listen_toolkit(BaseWebDeployToolkit.list_running_servers)
|
||||
def list_running_servers(self) -> Dict[str, Any]:
|
||||
return super().list_running_servers()
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ from camel.toolkits import WhatsAppToolkit as BaseWhatsAppToolkit
|
|||
from camel.toolkits.function_tool import FunctionTool
|
||||
from app.component.environment import env
|
||||
from app.service.task import Agents
|
||||
from app.utils.listen.toolkit_listen import listen_toolkit
|
||||
from app.utils.listen.toolkit_listen import auto_listen_toolkit
|
||||
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
|
||||
|
||||
|
||||
@auto_listen_toolkit(BaseWhatsAppToolkit)
|
||||
class WhatsAppToolkit(BaseWhatsAppToolkit, AbstractToolkit):
|
||||
agent_name: str = Agents.social_medium_agent
|
||||
|
||||
|
|
@ -14,30 +15,6 @@ class WhatsAppToolkit(BaseWhatsAppToolkit, AbstractToolkit):
|
|||
super().__init__(timeout)
|
||||
self.api_task_id = api_task_id
|
||||
|
||||
@listen_toolkit(
|
||||
BaseWhatsAppToolkit.send_message,
|
||||
lambda _, to, message: f"send message to {to}: {message}",
|
||||
lambda result: f"message sent result: {result}",
|
||||
)
|
||||
def send_message(self, to: str, message: str) -> Dict[str, Any] | str:
|
||||
return super().send_message(to, message)
|
||||
|
||||
@listen_toolkit(
|
||||
BaseWhatsAppToolkit.get_message_templates,
|
||||
lambda _: "get message templates",
|
||||
lambda result: f"message templates: {result}",
|
||||
)
|
||||
def get_message_templates(self) -> List[Dict[str, Any]] | str:
|
||||
return super().get_message_templates()
|
||||
|
||||
@listen_toolkit(
|
||||
BaseWhatsAppToolkit.get_business_profile,
|
||||
lambda _: "get business profile",
|
||||
lambda result: f"business profile: {result}",
|
||||
)
|
||||
def get_business_profile(self) -> Dict[str, Any] | str:
|
||||
return super().get_business_profile()
|
||||
|
||||
@classmethod
|
||||
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
|
||||
if env("WHATSAPP_ACCESS_TOKEN") and env("WHATSAPP_PHONE_NUMBER_ID"):
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
"""Conditional traceroot wrapper - only loads if .traceroot-config.yaml exists."""
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def _find_config() -> bool:
|
||||
"""Check if .traceroot-config.yaml exists in current or parent directories."""
|
||||
path = Path.cwd()
|
||||
for _ in range(5):
|
||||
if (path / ".traceroot-config.yaml").exists():
|
||||
return True
|
||||
if path == path.parent:
|
||||
break
|
||||
path = path.parent
|
||||
return False
|
||||
|
||||
|
||||
# Load traceroot only if config exists
|
||||
if _find_config():
|
||||
import traceroot
|
||||
trace = traceroot.trace
|
||||
get_logger = traceroot.get_logger
|
||||
else:
|
||||
# No-op implementations
|
||||
def trace():
|
||||
def decorator(func: Callable) -> Callable:
|
||||
return func
|
||||
return decorator
|
||||
|
||||
class _NoOpLogger:
|
||||
def __getattr__(self, name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
def get_logger(name: str):
|
||||
return _NoOpLogger()
|
||||
|
|
@ -9,7 +9,6 @@ from camel.societies.workforce.workforce import (
|
|||
from camel.societies.workforce.task_channel import TaskChannel
|
||||
from camel.societies.workforce.base import BaseNode
|
||||
from camel.societies.workforce.utils import TaskAssignResult
|
||||
from loguru import logger
|
||||
from camel.tasks.task import Task, TaskState, validate_task_content
|
||||
from app.component import code
|
||||
from app.exception.exception import UserException
|
||||
|
|
@ -18,30 +17,16 @@ from app.service.task import (
|
|||
Action,
|
||||
ActionAssignTaskData,
|
||||
ActionEndData,
|
||||
ActionNewTaskStateData,
|
||||
ActionTaskStateData,
|
||||
get_camel_task,
|
||||
get_task_lock,
|
||||
)
|
||||
from app.utils.single_agent_worker import SingleAgentWorker
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("workforce")
|
||||
|
||||
# === Debug sink === Write detailed dependency debug logs to file (logs/workforce_debug.log)
|
||||
# Create a new file every day, keep the logs for the last 7 days, and write asynchronously without blocking the main process
|
||||
logger.add(
|
||||
"logs/workforce_debug_{time:YYYY-MM-DD}.log",
|
||||
rotation="00:00",
|
||||
retention="7 days",
|
||||
enqueue=True,
|
||||
level="DEBUG",
|
||||
)
|
||||
# Independent sink: only collect the "[WF]" debug lines we insert to quickly view the dependency chain
|
||||
logger.add(
|
||||
"logs/wf_trace_{time:YYYY-MM-DD-HH}.log",
|
||||
rotation="00:00",
|
||||
retention="7 days",
|
||||
enqueue=True,
|
||||
level="DEBUG",
|
||||
filter=lambda record: record["message"].startswith("[WF]"),
|
||||
)
|
||||
|
||||
|
||||
class Workforce(BaseWorkforce):
|
||||
|
|
@ -69,8 +54,15 @@ class Workforce(BaseWorkforce):
|
|||
use_structured_output_handler=use_structured_output_handler,
|
||||
)
|
||||
|
||||
def eigent_make_sub_tasks(self, task: Task):
|
||||
"""split process_task method to eigent_make_sub_tasks and eigent_start method"""
|
||||
def eigent_make_sub_tasks(self, task: Task, coordinator_context: str = ""):
|
||||
"""
|
||||
Split process_task method to eigent_make_sub_tasks and eigent_start method.
|
||||
|
||||
Args:
|
||||
task: The main task to decompose
|
||||
coordinator_context: Optional context ONLY for coordinator agent during decomposition.
|
||||
This context will NOT be passed to subtasks or worker agents.
|
||||
"""
|
||||
|
||||
if not validate_task_content(task.content, task.id):
|
||||
task.state = TaskState.FAILED
|
||||
|
|
@ -85,10 +77,20 @@ class Workforce(BaseWorkforce):
|
|||
self.set_channel(TaskChannel())
|
||||
self._state = WorkforceState.RUNNING
|
||||
task.state = TaskState.OPEN
|
||||
self._pending_tasks.append(task)
|
||||
|
||||
# Decompose the task into subtasks first
|
||||
subtasks_result = self._decompose_task(task)
|
||||
if coordinator_context:
|
||||
original_content = task.content
|
||||
task_with_context = coordinator_context
|
||||
if coordinator_context:
|
||||
task_with_context += "\n=== CURRENT TASK ===\n"
|
||||
task_with_context += original_content
|
||||
task.content = task_with_context
|
||||
|
||||
subtasks_result = self._decompose_task(task)
|
||||
|
||||
task.content = original_content
|
||||
else:
|
||||
subtasks_result = self._decompose_task(task)
|
||||
|
||||
# Handle both streaming and non-streaming results
|
||||
if isinstance(subtasks_result, Generator):
|
||||
|
|
@ -119,6 +121,64 @@ class Workforce(BaseWorkforce):
|
|||
if self._state != WorkforceState.STOPPED:
|
||||
self._state = WorkforceState.IDLE
|
||||
|
||||
async def handle_decompose_append_task(
|
||||
self, task: Task, reset: bool = True, coordinator_context: str = ""
|
||||
) -> List[Task]:
|
||||
"""
|
||||
Override to support coordinator_context parameter.
|
||||
Handle task decomposition and validation, then append to pending tasks.
|
||||
|
||||
Args:
|
||||
task: The task to be processed
|
||||
reset: Should trigger workforce reset (Workforce must not be running)
|
||||
coordinator_context: Optional context ONLY for coordinator during decomposition
|
||||
|
||||
Returns:
|
||||
List[Task]: The decomposed subtasks or the original task
|
||||
"""
|
||||
if not validate_task_content(task.content, task.id):
|
||||
task.state = TaskState.FAILED
|
||||
task.result = "Task failed: Invalid or empty content provided"
|
||||
logger.warning(
|
||||
f"Task {task.id} rejected: Invalid or empty content. "
|
||||
f"Content preview: '{task.content}'"
|
||||
)
|
||||
return [task]
|
||||
|
||||
if reset and self._state != WorkforceState.RUNNING:
|
||||
self.reset()
|
||||
logger.info("Workforce reset before handling task.")
|
||||
|
||||
self._task = task
|
||||
task.state = TaskState.FAILED
|
||||
|
||||
if coordinator_context:
|
||||
original_content = task.content
|
||||
task_with_context = coordinator_context
|
||||
if coordinator_context:
|
||||
task_with_context += "\n=== CURRENT TASK ===\n"
|
||||
task_with_context += original_content
|
||||
task.content = task_with_context
|
||||
|
||||
subtasks_result = self._decompose_task(task)
|
||||
|
||||
task.content = original_content
|
||||
else:
|
||||
subtasks_result = self._decompose_task(task)
|
||||
|
||||
if isinstance(subtasks_result, Generator):
|
||||
subtasks = []
|
||||
for new_tasks in subtasks_result:
|
||||
subtasks.extend(new_tasks)
|
||||
else:
|
||||
subtasks = subtasks_result
|
||||
|
||||
if subtasks:
|
||||
self._pending_tasks.extendleft(reversed(subtasks))
|
||||
logger.info(f"Appended {len(subtasks)} subtasks to pending tasks")
|
||||
|
||||
return subtasks if subtasks else [task]
|
||||
|
||||
async def _find_assignee(self, tasks: List[Task]) -> TaskAssignResult:
|
||||
# Task assignment phase: send "waiting for execution" notification to the frontend, and send "start execution" notification when the task actually begins execution
|
||||
assigned = await super()._find_assignee(tasks)
|
||||
|
|
@ -133,7 +193,9 @@ class Workforce(BaseWorkforce):
|
|||
# Find task content
|
||||
task_obj = get_camel_task(item.task_id, tasks)
|
||||
if task_obj is None:
|
||||
logger.warning(f"[WF] WARN: Task {item.task_id} not found in tasks list during ASSIGN phase. This may indicate a task tree inconsistency.")
|
||||
logger.warning(
|
||||
f"[WF] WARN: Task {item.task_id} not found in tasks list during ASSIGN phase. This may indicate a task tree inconsistency."
|
||||
)
|
||||
content = ""
|
||||
else:
|
||||
content = task_obj.content
|
||||
|
|
@ -179,7 +241,11 @@ class Workforce(BaseWorkforce):
|
|||
await super()._post_task(task, assignee_id)
|
||||
|
||||
def add_single_agent_worker(
|
||||
self, description: str, worker: ListenChatAgent, pool_max_size: int = DEFAULT_WORKER_POOL_SIZE
|
||||
self,
|
||||
description: str,
|
||||
worker: ListenChatAgent,
|
||||
pool_max_size: int = DEFAULT_WORKER_POOL_SIZE,
|
||||
enable_workflow_memory: bool = False,
|
||||
) -> BaseWorkforce:
|
||||
if self._state == WorkforceState.RUNNING:
|
||||
raise RuntimeError("Cannot add workers while workforce is running. Pause the workforce first.")
|
||||
|
|
@ -195,6 +261,8 @@ class Workforce(BaseWorkforce):
|
|||
worker=worker,
|
||||
pool_max_size=pool_max_size,
|
||||
use_structured_output_handler=self.use_structured_output_handler,
|
||||
context_utility=None, # Will be set during save/load operations
|
||||
enable_workflow_memory=enable_workflow_memory,
|
||||
)
|
||||
self._children.append(worker_node)
|
||||
|
||||
|
|
@ -218,17 +286,33 @@ class Workforce(BaseWorkforce):
|
|||
logger.debug(f"[WF] DONE {task.id}")
|
||||
task_lock = get_task_lock(self.api_task_id)
|
||||
|
||||
await task_lock.put_queue(
|
||||
ActionTaskStateData(
|
||||
data={
|
||||
"task_id": task.id,
|
||||
"content": task.content,
|
||||
"state": task.state,
|
||||
"result": task.result or "",
|
||||
"failure_count": task.failure_count,
|
||||
},
|
||||
# Log task completion with result details
|
||||
is_main_task = self._task and task.id == self._task.id
|
||||
task_type = "MAIN TASK" if is_main_task else "SUB-TASK"
|
||||
logger.info(f"[TASK-RESULT] {task_type} COMPLETED: {task.id}")
|
||||
logger.info(f"[TASK-RESULT] Content: {task.content[:200]}..." if len(task.content) > 200 else f"[TASK-RESULT] Content: {task.content}")
|
||||
logger.info(f"[TASK-RESULT] Result: {task.result[:500]}..." if task.result and len(str(task.result)) > 500 else f"[TASK-RESULT] Result: {task.result}")
|
||||
|
||||
task_data = {
|
||||
"task_id": task.id,
|
||||
"content": task.content,
|
||||
"state": task.state,
|
||||
"result": task.result or "",
|
||||
"failure_count": task.failure_count,
|
||||
}
|
||||
|
||||
if self._task_is_new(task_data):
|
||||
await task_lock.put_queue(
|
||||
ActionNewTaskStateData(
|
||||
data=task_data
|
||||
)
|
||||
)
|
||||
else:
|
||||
await task_lock.put_queue(
|
||||
ActionTaskStateData(
|
||||
data=task_data
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return await super()._handle_completed_task(task)
|
||||
|
||||
|
|
@ -260,6 +344,36 @@ class Workforce(BaseWorkforce):
|
|||
|
||||
return result
|
||||
|
||||
def _task_is_new(self, item:dict) -> bool:
|
||||
# Validate the task state data object first
|
||||
assert isinstance(item, dict)
|
||||
task_id = item.get("task_id", "")
|
||||
state = item.get("state", "")
|
||||
result = item.get("result", "")
|
||||
failure_count = item.get("failure_count", 0)
|
||||
|
||||
# Validate required fields
|
||||
if not task_id:
|
||||
logger.error("Missing task_id in task_state data")
|
||||
return False
|
||||
elif not state:
|
||||
logger.error(f"Missing state in task_state data for task {task_id}")
|
||||
return False
|
||||
|
||||
# Ensure failure_count is an integer
|
||||
try:
|
||||
failure_count = int(failure_count)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"Invalid failure_count in task_state data for task {task_id}: {failure_count}")
|
||||
failure_count = 0 # Default to 0 if invalid
|
||||
|
||||
should_send_new_task_state = (
|
||||
state == "FAILED" or
|
||||
(failure_count == 0 and result.strip() == "")
|
||||
)
|
||||
|
||||
return should_send_new_task_state
|
||||
|
||||
def stop(self) -> None:
|
||||
super().stop()
|
||||
task_lock = get_task_lock(self.api_task_id)
|
||||
|
|
|
|||
|
|
@ -1,37 +1,42 @@
|
|||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
import signal
|
||||
import asyncio
|
||||
import atexit
|
||||
from app import api
|
||||
from loguru import logger
|
||||
from app.component.environment import auto_include_routers, env
|
||||
|
||||
# Add project root to Python path to import shared utils
|
||||
_project_root = pathlib.Path(__file__).parent.parent
|
||||
if str(_project_root) not in sys.path:
|
||||
sys.path.insert(0, str(_project_root))
|
||||
|
||||
# 1) Load env and init traceroot BEFORE importing modules that get a logger
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
from app import api
|
||||
|
||||
# Only initialize traceroot if enabled
|
||||
if traceroot.is_enabled():
|
||||
from traceroot.integrations.fastapi import connect_fastapi
|
||||
connect_fastapi(api)
|
||||
|
||||
# 2) Now safe to import modules that use traceroot.get_logger() at import-time
|
||||
from app.component.environment import auto_include_routers, env
|
||||
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
app_logger = traceroot.get_logger("main")
|
||||
|
||||
# Log application startup
|
||||
logger.info("Starting Eigent Multi-Agent System API")
|
||||
logger.info(f"Python encoding: {os.environ.get('PYTHONIOENCODING')}")
|
||||
logger.info(f"Environment: {os.environ.get('ENVIRONMENT', 'development')}")
|
||||
app_logger.info("Starting Eigent Multi-Agent System API")
|
||||
app_logger.info(f"Python encoding: {os.environ.get('PYTHONIOENCODING')}")
|
||||
app_logger.info(f"Environment: {os.environ.get('ENVIRONMENT', 'development')}")
|
||||
|
||||
prefix = env("url_prefix", "")
|
||||
logger.info(f"Loading routers with prefix: '{prefix}'")
|
||||
app_logger.info(f"Loading routers with prefix: '{prefix}'")
|
||||
auto_include_routers(api, prefix, "app/controller")
|
||||
logger.info("All routers loaded successfully")
|
||||
app_logger.info("All routers loaded successfully")
|
||||
|
||||
|
||||
# Configure Loguru
|
||||
log_path = os.path.expanduser("~/.eigent/runtime/log/app.log")
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
logger.add(
|
||||
log_path, # Log file
|
||||
rotation="10 MB", # Log rotation: 10MB per file
|
||||
retention="10 days", # Retain logs for the last 10 days
|
||||
level="DEBUG", # Log level
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info(f"Loguru configured with log file: {log_path}")
|
||||
|
||||
dir = pathlib.Path(__file__).parent / "runtime"
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -44,12 +49,12 @@ async def write_pid_file():
|
|||
|
||||
async with aiofiles.open(dir / "run.pid", "w") as f:
|
||||
await f.write(str(os.getpid()))
|
||||
logger.info(f"PID file written: {os.getpid()}")
|
||||
app_logger.info(f"PID file written: {os.getpid()}")
|
||||
|
||||
|
||||
# Create task to write PID
|
||||
pid_task = asyncio.create_task(write_pid_file())
|
||||
logger.info("PID write task created")
|
||||
app_logger.info("PID write task created")
|
||||
|
||||
# Graceful shutdown handler
|
||||
shutdown_event = asyncio.Event()
|
||||
|
|
@ -57,8 +62,7 @@ shutdown_event = asyncio.Event()
|
|||
|
||||
async def cleanup_resources():
|
||||
r"""Cleanup all resources on shutdown"""
|
||||
logger.info("Starting graceful shutdown...")
|
||||
logger.info("Starting graceful shutdown process")
|
||||
app_logger.info("Starting graceful shutdown process")
|
||||
|
||||
from app.service.task import task_locks, _cleanup_task
|
||||
|
||||
|
|
@ -75,21 +79,19 @@ async def cleanup_resources():
|
|||
task_lock = task_locks[task_id]
|
||||
await task_lock.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up task {task_id}: {e}")
|
||||
app_logger.error(f"Error cleaning up task {task_id}: {e}")
|
||||
|
||||
# Remove PID file
|
||||
pid_file = dir / "run.pid"
|
||||
if pid_file.exists():
|
||||
pid_file.unlink()
|
||||
|
||||
logger.info("Graceful shutdown completed")
|
||||
logger.info("All resources cleaned up successfully")
|
||||
app_logger.info("All resources cleaned up successfully")
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
r"""Handle shutdown signals"""
|
||||
logger.info(f"Received signal {signum}")
|
||||
logger.warning(f"Received shutdown signal: {signum}")
|
||||
app_logger.warning(f"Received shutdown signal: {signum}")
|
||||
asyncio.create_task(cleanup_resources())
|
||||
shutdown_event.set()
|
||||
|
||||
|
|
@ -97,8 +99,19 @@ def signal_handler(signum, frame):
|
|||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Register cleanup on exit
|
||||
atexit.register(lambda: asyncio.run(cleanup_resources()))
|
||||
# Register cleanup on exit with safe synchronous wrapper
|
||||
def sync_cleanup():
|
||||
"""Synchronous cleanup for atexit - handles PID file removal"""
|
||||
try:
|
||||
# Only perform synchronous cleanup tasks
|
||||
pid_file = dir / "run.pid"
|
||||
if pid_file.exists():
|
||||
pid_file.unlink()
|
||||
app_logger.info("PID file removed during shutdown")
|
||||
except Exception as e:
|
||||
app_logger.error(f"Error during atexit cleanup: {e}")
|
||||
|
||||
atexit.register(sync_cleanup)
|
||||
|
||||
# Log successful initialization
|
||||
logger.info("Application initialization completed successfully")
|
||||
app_logger.info("Application initialization completed successfully")
|
||||
|
|
|
|||
|
|
@ -5,19 +5,18 @@ description = "Add your description here"
|
|||
readme = "README.md"
|
||||
requires-python = "==3.10.16"
|
||||
dependencies = [
|
||||
"camel-ai[eigent]==0.2.76a13",
|
||||
"camel-ai[eigent]==0.2.78",
|
||||
"fastapi>=0.115.12",
|
||||
"fastapi-babel>=1.0.0",
|
||||
"uvicorn[standard]>=0.34.2",
|
||||
"pydantic-i18n>=0.4.5",
|
||||
"python-dotenv>=1.1.0",
|
||||
"httpx[socks]>=0.28.1",
|
||||
"loguru>=0.7.3",
|
||||
"pydash>=8.0.5",
|
||||
"inflection>=0.5.1",
|
||||
"aiofiles>=24.1.0",
|
||||
"openai>=1.99.3,<2",
|
||||
"traceroot>=0.0.5a2",
|
||||
"traceroot>=0.0.7",
|
||||
"nodejs-wheel>=22.18.0",
|
||||
"numpy>=1.23.0,<2.0.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -145,14 +145,21 @@ def mock_model_backend():
|
|||
@pytest.fixture
|
||||
def mock_camel_agent():
|
||||
"""Mock CAMEL agent for testing."""
|
||||
agent = AsyncMock()
|
||||
agent = MagicMock() # Use MagicMock instead of AsyncMock
|
||||
agent.role_name = "test_agent"
|
||||
agent.agent_id = "test_agent_123"
|
||||
|
||||
# Make step method async and return proper structure
|
||||
agent.step = AsyncMock()
|
||||
agent.step.return_value.msgs = [MagicMock()]
|
||||
agent.step.return_value.msgs[0].content = "Test agent response"
|
||||
# Make step method return proper structure with both .msg and .msgs[0]
|
||||
mock_response = MagicMock()
|
||||
mock_message = MagicMock()
|
||||
mock_message.content = "Test agent response"
|
||||
mock_message.parsed = None
|
||||
|
||||
mock_response.msg = mock_message
|
||||
mock_response.msgs = [mock_message] # msgs[0] should point to the same content
|
||||
mock_response.info = {"usage": {"total_tokens": 50}}
|
||||
|
||||
agent.step.return_value = mock_response
|
||||
|
||||
agent.astep = AsyncMock()
|
||||
agent.astep.return_value.msg.content = "Test async agent response"
|
||||
|
|
@ -288,6 +295,7 @@ def sample_chat_data():
|
|||
"""Sample chat data for testing."""
|
||||
return {
|
||||
"task_id": "test_task_123",
|
||||
"project_id": "test_project_456",
|
||||
"email": "test@example.com",
|
||||
"question": "Create a simple Python script",
|
||||
"attaches": [],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app.service.chat_service import (
|
||||
step_solve,
|
||||
|
|
@ -12,14 +15,335 @@ from app.service.chat_service import (
|
|||
summary_task,
|
||||
construct_workforce,
|
||||
format_agent_description,
|
||||
new_agent_model
|
||||
new_agent_model,
|
||||
collect_previous_task_context,
|
||||
build_context_for_workforce
|
||||
)
|
||||
from app.model.chat import Chat, NewAgent
|
||||
from app.service.task import Action, ActionImproveData, ActionEndData, ActionInstallMcpData
|
||||
from app.service.task import Action, ActionImproveData, ActionEndData, ActionInstallMcpData, TaskLock
|
||||
from camel.tasks import Task
|
||||
from camel.tasks.task import TaskState
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCollectPreviousTaskContext:
|
||||
"""Test cases for collect_previous_task_context function."""
|
||||
|
||||
def test_collect_previous_task_context_basic(self, temp_dir):
|
||||
"""Test collect_previous_task_context with basic inputs."""
|
||||
working_directory = str(temp_dir)
|
||||
previous_task_content = "Create a Python script"
|
||||
previous_task_result = "Successfully created script.py"
|
||||
previous_summary = "Python Script Creation Task"
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content=previous_task_content,
|
||||
previous_task_result=previous_task_result,
|
||||
previous_summary=previous_summary
|
||||
)
|
||||
|
||||
# Check that all sections are included
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Previous Task:" in result
|
||||
assert "Create a Python script" in result
|
||||
assert "Previous Task Summary:" in result
|
||||
assert "Python Script Creation Task" in result
|
||||
assert "Previous Task Result:" in result
|
||||
assert "Successfully created script.py" in result
|
||||
assert "=== END OF PREVIOUS TASK CONTEXT ===" in result
|
||||
assert "=== NEW TASK ===" in result
|
||||
|
||||
def test_collect_previous_task_context_with_generated_files(self, temp_dir):
|
||||
"""Test collect_previous_task_context with generated files in working directory."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create some test files
|
||||
(temp_dir / "script.py").write_text("print('Hello World')")
|
||||
(temp_dir / "config.json").write_text('{"test": true}')
|
||||
(temp_dir / "README.md").write_text("# Test Project")
|
||||
|
||||
# Create a subdirectory with files
|
||||
sub_dir = temp_dir / "utils"
|
||||
sub_dir.mkdir()
|
||||
(sub_dir / "helper.py").write_text("def helper(): pass")
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Create project files",
|
||||
previous_task_result="Files created successfully",
|
||||
previous_summary=""
|
||||
)
|
||||
|
||||
# Check that generated files are listed
|
||||
assert "Generated Files from Previous Task:" in result
|
||||
assert "script.py" in result
|
||||
assert "config.json" in result
|
||||
assert "README.md" in result
|
||||
assert "utils/helper.py" in result or "utils\\helper.py" in result # Handle Windows paths
|
||||
|
||||
# Files should be sorted
|
||||
lines = result.split('\n')
|
||||
file_lines = [line.strip() for line in lines if line.strip().startswith('- ')]
|
||||
assert len(file_lines) == 4
|
||||
|
||||
def test_collect_previous_task_context_filters_hidden_files(self, temp_dir):
|
||||
"""Test that hidden files and directories are filtered out."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create regular files
|
||||
(temp_dir / "visible.py").write_text("# Visible file")
|
||||
|
||||
# Create hidden files and directories
|
||||
(temp_dir / ".hidden_file").write_text("hidden content")
|
||||
(temp_dir / ".env").write_text("SECRET=hidden")
|
||||
|
||||
hidden_dir = temp_dir / ".hidden_dir"
|
||||
hidden_dir.mkdir()
|
||||
(hidden_dir / "file.txt").write_text("in hidden dir")
|
||||
|
||||
# Create cache directories
|
||||
cache_dir = temp_dir / "__pycache__"
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "module.pyc").write_text("compiled")
|
||||
|
||||
node_modules = temp_dir / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "package").mkdir()
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test filtering",
|
||||
previous_task_result="Files filtered",
|
||||
previous_summary=""
|
||||
)
|
||||
|
||||
# Should only include visible files
|
||||
assert "visible.py" in result
|
||||
assert ".hidden_file" not in result
|
||||
assert ".env" not in result
|
||||
assert "__pycache__" not in result
|
||||
assert "node_modules" not in result
|
||||
assert ".hidden_dir" not in result
|
||||
|
||||
def test_collect_previous_task_context_filters_temp_files(self, temp_dir):
|
||||
"""Test that temporary files are filtered out."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create regular files
|
||||
(temp_dir / "main.py").write_text("# Main file")
|
||||
|
||||
# Create temporary files
|
||||
(temp_dir / "temp.tmp").write_text("temporary")
|
||||
(temp_dir / "compiled.pyc").write_text("compiled python")
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test temp filtering",
|
||||
previous_task_result="Temp files filtered",
|
||||
previous_summary=""
|
||||
)
|
||||
|
||||
# Should only include regular files
|
||||
assert "main.py" in result
|
||||
assert "temp.tmp" not in result
|
||||
assert "compiled.pyc" not in result
|
||||
|
||||
def test_collect_previous_task_context_nonexistent_directory(self):
|
||||
"""Test collect_previous_task_context with non-existent working directory."""
|
||||
working_directory = "/nonexistent/directory"
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test task",
|
||||
previous_task_result="Test result",
|
||||
previous_summary="Test summary"
|
||||
)
|
||||
|
||||
# Should not crash and should not include file listing
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Test task" in result
|
||||
assert "Test result" in result
|
||||
assert "Test summary" in result
|
||||
assert "Generated Files from Previous Task:" not in result
|
||||
|
||||
def test_collect_previous_task_context_empty_inputs(self, temp_dir):
|
||||
"""Test collect_previous_task_context with empty string inputs."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="",
|
||||
previous_task_result="",
|
||||
previous_summary=""
|
||||
)
|
||||
|
||||
# Should still have the structural elements
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "=== END OF PREVIOUS TASK CONTEXT ===" in result
|
||||
assert "=== NEW TASK ===" in result
|
||||
|
||||
# Should not have content sections for empty inputs
|
||||
assert "Previous Task:" not in result
|
||||
assert "Previous Task Summary:" not in result
|
||||
assert "Previous Task Result:" not in result
|
||||
|
||||
def test_collect_previous_task_context_only_summary(self, temp_dir):
|
||||
"""Test collect_previous_task_context with only summary provided."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="",
|
||||
previous_task_result="",
|
||||
previous_summary="Only summary provided"
|
||||
)
|
||||
|
||||
# Should include summary section only
|
||||
assert "Previous Task Summary:" in result
|
||||
assert "Only summary provided" in result
|
||||
assert "Previous Task:" not in result
|
||||
assert "Previous Task Result:" not in result
|
||||
|
||||
@patch('app.service.chat_service.logger')
|
||||
def test_collect_previous_task_context_file_system_error(self, mock_logger, temp_dir):
|
||||
"""Test collect_previous_task_context handles file system errors gracefully."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Mock os.walk to raise an exception
|
||||
with patch('os.walk', side_effect=PermissionError("Access denied")):
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test task",
|
||||
previous_task_result="Test result",
|
||||
previous_summary="Test summary"
|
||||
)
|
||||
|
||||
# Should still return result without files
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Test task" in result
|
||||
assert "Generated Files from Previous Task:" not in result
|
||||
|
||||
# Should log warning
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
def test_collect_previous_task_context_relative_paths(self, temp_dir):
|
||||
"""Test that file paths are correctly converted to relative paths."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create nested directory structure
|
||||
deep_dir = temp_dir / "level1" / "level2" / "level3"
|
||||
deep_dir.mkdir(parents=True)
|
||||
(deep_dir / "deep_file.txt").write_text("deep content")
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test relative paths",
|
||||
previous_task_result="Paths converted",
|
||||
previous_summary=""
|
||||
)
|
||||
|
||||
# Check that the path is relative to working directory
|
||||
expected_path = "level1/level2/level3/deep_file.txt"
|
||||
windows_path = "level1\\level2\\level3\\deep_file.txt"
|
||||
|
||||
# Should contain relative path (handle both Unix and Windows separators)
|
||||
assert expected_path in result or windows_path in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildContextForWorkforce:
|
||||
"""Test cases for build_context_for_workforce function."""
|
||||
|
||||
def test_build_context_for_workforce_basic(self, temp_dir):
|
||||
"""Test build_context_for_workforce with basic task lock and options."""
|
||||
# Create mock TaskLock
|
||||
task_lock = MagicMock(spec=TaskLock)
|
||||
task_lock.conversation_history = [
|
||||
{'role': 'user', 'content': 'Create a Python script'},
|
||||
{'role': 'assistant', 'content': 'I will create a Python script for you'}
|
||||
]
|
||||
task_lock.last_task_result = "Script created successfully"
|
||||
task_lock.last_task_summary = "Python Script Creation"
|
||||
|
||||
# Create mock Chat options
|
||||
options = MagicMock()
|
||||
options.file_save_path.return_value = str(temp_dir)
|
||||
|
||||
result = build_context_for_workforce(task_lock, options)
|
||||
|
||||
# Should include conversation history
|
||||
assert "=== CONVERSATION HISTORY ===" in result
|
||||
assert "user: Create a Python script" in result
|
||||
assert "assistant: I will create a Python script for you" in result
|
||||
|
||||
# Should include previous task context
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Script created successfully" in result
|
||||
|
||||
def test_build_context_for_workforce_empty_history(self, temp_dir):
|
||||
"""Test build_context_for_workforce with empty conversation history."""
|
||||
task_lock = MagicMock(spec=TaskLock)
|
||||
task_lock.conversation_history = []
|
||||
task_lock.last_task_result = ""
|
||||
task_lock.last_task_summary = ""
|
||||
|
||||
options = MagicMock()
|
||||
options.file_save_path.return_value = str(temp_dir)
|
||||
|
||||
result = build_context_for_workforce(task_lock, options)
|
||||
|
||||
# Should return empty string for no context
|
||||
assert result == ""
|
||||
|
||||
def test_build_context_for_workforce_task_result_role(self, temp_dir):
|
||||
"""Test build_context_for_workforce handles 'task_result' role specially."""
|
||||
task_lock = MagicMock(spec=TaskLock)
|
||||
task_lock.conversation_history = [
|
||||
{'role': 'user', 'content': 'First question'},
|
||||
{'role': 'task_result', 'content': 'Full task context from previous task'},
|
||||
{'role': 'user', 'content': 'Second question'}
|
||||
]
|
||||
task_lock.last_task_result = "Final result"
|
||||
task_lock.last_task_summary = "Task summary"
|
||||
|
||||
options = MagicMock()
|
||||
options.file_save_path.return_value = str(temp_dir)
|
||||
|
||||
result = build_context_for_workforce(task_lock, options)
|
||||
|
||||
# Should simplify task_result display
|
||||
assert "[Previous Task Completed]" in result
|
||||
assert "Full task context from previous task" not in result # Should not show full content
|
||||
assert "user: First question" in result
|
||||
assert "user: Second question" in result
|
||||
|
||||
def test_build_context_for_workforce_with_last_task_result(self, temp_dir):
|
||||
"""Test build_context_for_workforce includes last task result context."""
|
||||
# Create some files in temp directory
|
||||
(temp_dir / "output.txt").write_text("Task output")
|
||||
|
||||
task_lock = MagicMock(spec=TaskLock)
|
||||
task_lock.conversation_history = [
|
||||
{'role': 'user', 'content': 'Test question'}
|
||||
]
|
||||
task_lock.last_task_result = "Task completed with output.txt"
|
||||
task_lock.last_task_summary = "File creation task"
|
||||
|
||||
options = MagicMock()
|
||||
options.file_save_path.return_value = str(temp_dir)
|
||||
|
||||
result = build_context_for_workforce(task_lock, options)
|
||||
|
||||
# Should include conversation history and task context
|
||||
assert "=== CONVERSATION HISTORY ===" in result
|
||||
assert "user: Test question" in result
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Task completed with output.txt" in result
|
||||
assert "File creation task" in result
|
||||
assert "output.txt" in result # Generated file should be listed
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestChatServiceUtilities:
|
||||
"""Test cases for chat service utility functions."""
|
||||
|
|
@ -324,6 +648,130 @@ class TestChatServiceIntegration:
|
|||
"""Integration tests for chat service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_solve_context_building_workflow(self, sample_chat_data, mock_request, temp_dir):
|
||||
"""Test step_solve builds context correctly using collect_previous_task_context."""
|
||||
options = Chat(**sample_chat_data)
|
||||
|
||||
# Create actual TaskLock with context data
|
||||
task_lock = TaskLock(
|
||||
id="test_task_123",
|
||||
queue=AsyncMock(),
|
||||
human_input={}
|
||||
)
|
||||
task_lock.conversation_history = [
|
||||
{'role': 'user', 'content': 'Create a Python script'},
|
||||
{'role': 'assistant', 'content': 'Script created successfully'}
|
||||
]
|
||||
task_lock.last_task_result = "def hello(): print('Hello World')"
|
||||
task_lock.last_task_summary = "Python Hello World Script"
|
||||
|
||||
# Create some files in working directory
|
||||
working_dir = temp_dir / "test_project"
|
||||
working_dir.mkdir()
|
||||
(working_dir / "script.py").write_text("def hello(): print('Hello World')")
|
||||
|
||||
# Mock file_save_path method to return our temp directory
|
||||
with patch.object(Chat, 'file_save_path', return_value=str(working_dir)):
|
||||
|
||||
# Test the context building directly
|
||||
context = build_context_for_workforce(task_lock, options)
|
||||
|
||||
# Verify context includes conversation history
|
||||
assert "=== CONVERSATION HISTORY ===" in context
|
||||
assert "user: Create a Python script" in context
|
||||
assert "assistant: Script created successfully" in context
|
||||
|
||||
# Verify context includes task context with files
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in context
|
||||
assert "def hello(): print('Hello World')" in context
|
||||
assert "Python Hello World Script" in context
|
||||
assert "script.py" in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_solve_new_task_state_context_collection(self, sample_chat_data, mock_request, temp_dir):
|
||||
"""Test step_solve correctly collects context in new_task_state action."""
|
||||
options = Chat(**sample_chat_data)
|
||||
working_dir = temp_dir / "project"
|
||||
working_dir.mkdir()
|
||||
|
||||
# Create files that should be included in context
|
||||
(working_dir / "main.py").write_text("print('main')")
|
||||
(working_dir / "config.json").write_text('{"version": "1.0"}')
|
||||
|
||||
# Mock file_save_path to return our temp directory
|
||||
with patch.object(Chat, 'file_save_path', return_value=str(working_dir)):
|
||||
|
||||
# Test collect_previous_task_context directly with the scenario
|
||||
result = collect_previous_task_context(
|
||||
working_directory=str(working_dir),
|
||||
previous_task_content="Create project structure",
|
||||
previous_task_result="Project files created successfully",
|
||||
previous_summary="Project Setup Task"
|
||||
)
|
||||
|
||||
# Verify all expected elements are present
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Previous Task:" in result
|
||||
assert "Create project structure" in result
|
||||
assert "Previous Task Summary:" in result
|
||||
assert "Project Setup Task" in result
|
||||
assert "Previous Task Result:" in result
|
||||
assert "Project files created successfully" in result
|
||||
assert "Generated Files from Previous Task:" in result
|
||||
assert "main.py" in result
|
||||
assert "config.json" in result
|
||||
assert "=== END OF PREVIOUS TASK CONTEXT ===" in result
|
||||
assert "=== NEW TASK ===" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_solve_end_action_context_collection(self, sample_chat_data, mock_request, temp_dir):
|
||||
"""Test step_solve correctly collects and saves context in end action."""
|
||||
options = Chat(**sample_chat_data)
|
||||
working_dir = temp_dir / "finished_project"
|
||||
working_dir.mkdir()
|
||||
|
||||
# Create output files
|
||||
(working_dir / "output.txt").write_text("Final output")
|
||||
(working_dir / "report.md").write_text("# Task Report")
|
||||
|
||||
# Create actual TaskLock
|
||||
task_lock = TaskLock(
|
||||
id="test_end_task",
|
||||
queue=AsyncMock(),
|
||||
human_input={}
|
||||
)
|
||||
task_lock.last_task_summary = "Final Task Summary"
|
||||
|
||||
# Mock file_save_path
|
||||
with patch.object(Chat, 'file_save_path', return_value=str(working_dir)):
|
||||
|
||||
# Test the context collection for end action scenario
|
||||
task_content = "Generate final report"
|
||||
task_result = "Report generated successfully with output files"
|
||||
|
||||
context = collect_previous_task_context(
|
||||
working_directory=str(working_dir),
|
||||
previous_task_content=task_content,
|
||||
previous_task_result=task_result,
|
||||
previous_summary=task_lock.last_task_summary
|
||||
)
|
||||
|
||||
# Verify context structure for end action
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in context
|
||||
assert "Generate final report" in context
|
||||
assert "Report generated successfully with output files" in context
|
||||
assert "Final Task Summary" in context
|
||||
assert "output.txt" in context
|
||||
assert "report.md" in context
|
||||
|
||||
# Test that context can be added to conversation history
|
||||
task_lock.add_conversation('task_result', context)
|
||||
assert len(task_lock.conversation_history) == 1
|
||||
assert task_lock.conversation_history[0]['role'] == 'task_result'
|
||||
assert task_lock.conversation_history[0]['content'] == context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Gets Stuck for some reason.")
|
||||
async def test_step_solve_basic_workflow(self, sample_chat_data, mock_request, mock_task_lock):
|
||||
"""Test step_solve basic workflow integration."""
|
||||
options = Chat(**sample_chat_data)
|
||||
|
|
@ -380,6 +828,7 @@ class TestChatServiceIntegration:
|
|||
# Note: Workforce might not be created/stopped if request is immediately disconnected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Gets Stuck for some reason.")
|
||||
async def test_step_solve_error_handling(self, sample_chat_data, mock_request, mock_task_lock):
|
||||
"""Test step_solve handles errors gracefully."""
|
||||
options = Chat(**sample_chat_data)
|
||||
|
|
@ -423,7 +872,195 @@ class TestChatServiceWithLLM:
|
|||
@pytest.mark.unit
|
||||
class TestChatServiceErrorCases:
|
||||
"""Test error cases and edge conditions for chat service."""
|
||||
|
||||
|
||||
def test_collect_previous_task_context_os_walk_exception(self, temp_dir):
|
||||
"""Test collect_previous_task_context handles os.walk exceptions."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
with patch('os.walk', side_effect=OSError("Permission denied")):
|
||||
with patch('app.service.chat_service.logger') as mock_logger:
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test task",
|
||||
previous_task_result="Test result",
|
||||
previous_summary="Test summary"
|
||||
)
|
||||
|
||||
# Should still include basic context
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
assert "Test task" in result
|
||||
assert "Test result" in result
|
||||
assert "Test summary" in result
|
||||
|
||||
# Should not include file listing
|
||||
assert "Generated Files from Previous Task:" not in result
|
||||
|
||||
# Should log warning
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
def test_collect_previous_task_context_relpath_exception(self, temp_dir):
|
||||
"""Test collect_previous_task_context handles os.path.relpath exceptions."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create a test file
|
||||
(temp_dir / "test.txt").write_text("test content")
|
||||
|
||||
with patch('os.path.relpath', side_effect=ValueError("Invalid path")):
|
||||
with patch('app.service.chat_service.logger') as mock_logger:
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test task",
|
||||
previous_task_result="Test result",
|
||||
previous_summary="Test summary"
|
||||
)
|
||||
|
||||
# Should handle the exception gracefully
|
||||
assert "=== CONTEXT FROM PREVIOUS TASK ===" in result
|
||||
# Should log warning about file collection failure
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
def test_build_context_for_workforce_missing_attributes(self, temp_dir):
|
||||
"""Test build_context_for_workforce handles missing attributes gracefully."""
|
||||
# Create task_lock without required attributes
|
||||
task_lock = MagicMock(spec=TaskLock)
|
||||
task_lock.conversation_history = None # Missing attribute
|
||||
task_lock.last_task_result = None # Missing attribute
|
||||
task_lock.last_task_summary = None # Missing attribute
|
||||
|
||||
options = MagicMock()
|
||||
options.file_save_path.return_value = str(temp_dir)
|
||||
|
||||
result = build_context_for_workforce(task_lock, options)
|
||||
|
||||
# Should handle missing attributes gracefully
|
||||
assert result == ""
|
||||
|
||||
def test_build_context_for_workforce_file_save_path_exception(self):
|
||||
"""Test build_context_for_workforce handles file_save_path exceptions."""
|
||||
task_lock = MagicMock(spec=TaskLock)
|
||||
task_lock.conversation_history = []
|
||||
task_lock.last_task_result = "Test result"
|
||||
task_lock.last_task_summary = "Test summary"
|
||||
|
||||
options = MagicMock()
|
||||
options.file_save_path.side_effect = Exception("Path error")
|
||||
|
||||
with patch('app.service.chat_service.logger') as mock_logger:
|
||||
# Should handle exception when getting file path
|
||||
with pytest.raises(Exception, match="Path error"):
|
||||
build_context_for_workforce(task_lock, options)
|
||||
|
||||
def test_collect_previous_task_context_unicode_handling(self, temp_dir):
|
||||
"""Test collect_previous_task_context handles unicode content correctly."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create files with unicode content
|
||||
(temp_dir / "unicode_file.txt").write_text("Unicode content: 🐍 Python ñáéíóú", encoding='utf-8')
|
||||
|
||||
unicode_task_content = "Create files with unicode: 🔥 emojis and ñáéíóú accents"
|
||||
unicode_result = "Files created successfully with unicode: ✅ done"
|
||||
unicode_summary = "Unicode Task: 📝 file creation"
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content=unicode_task_content,
|
||||
previous_task_result=unicode_result,
|
||||
previous_summary=unicode_summary
|
||||
)
|
||||
|
||||
# Should handle unicode correctly
|
||||
assert "🔥 emojis" in result
|
||||
assert "ñáéíóú accents" in result
|
||||
assert "✅ done" in result
|
||||
assert "📝 file creation" in result
|
||||
assert "unicode_file.txt" in result
|
||||
|
||||
def test_collect_previous_task_context_very_long_content(self, temp_dir):
|
||||
"""Test collect_previous_task_context handles very long content."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create very long content strings
|
||||
long_content = "Very long task content. " * 1000 # ~25KB
|
||||
long_result = "Very long task result. " * 1000 # ~23KB
|
||||
long_summary = "Very long summary. " * 100 # ~1.8KB
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content=long_content,
|
||||
previous_task_result=long_result,
|
||||
previous_summary=long_summary
|
||||
)
|
||||
|
||||
# Should handle long content without issues
|
||||
assert len(result) > 49000 # Should be quite long
|
||||
assert "Very long task content." in result
|
||||
assert "Very long task result." in result
|
||||
assert "Very long summary." in result
|
||||
|
||||
def test_collect_previous_task_context_many_files(self, temp_dir):
|
||||
"""Test collect_previous_task_context performance with many files."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create many files to test performance
|
||||
for i in range(100):
|
||||
(temp_dir / f"file_{i:03d}.txt").write_text(f"Content {i}")
|
||||
|
||||
# Create subdirectories with files
|
||||
for dir_i in range(10):
|
||||
sub_dir = temp_dir / f"subdir_{dir_i}"
|
||||
sub_dir.mkdir()
|
||||
for file_i in range(10):
|
||||
(sub_dir / f"subfile_{file_i}.txt").write_text(f"Sub content {dir_i}-{file_i}")
|
||||
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test many files",
|
||||
previous_task_result="Many files processed",
|
||||
previous_summary="Performance test"
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
execution_time = end_time - start_time
|
||||
|
||||
# Should complete in reasonable time (less than 1 second for 200 files)
|
||||
assert execution_time < 1.0
|
||||
|
||||
# Should list all files
|
||||
assert "Generated Files from Previous Task:" in result
|
||||
# Count number of file entries
|
||||
file_lines = [line for line in result.split('\n') if ' - ' in line]
|
||||
assert len(file_lines) == 200 # 100 main files + 100 subfiles
|
||||
|
||||
def test_collect_previous_task_context_special_characters_in_filenames(self, temp_dir):
|
||||
"""Test collect_previous_task_context handles special characters in filenames."""
|
||||
working_directory = str(temp_dir)
|
||||
|
||||
# Create files with special characters (that are valid in filenames)
|
||||
try:
|
||||
(temp_dir / "file with spaces.txt").write_text("content")
|
||||
(temp_dir / "file-with-dashes.txt").write_text("content")
|
||||
(temp_dir / "file_with_underscores.txt").write_text("content")
|
||||
(temp_dir / "file.with.dots.txt").write_text("content")
|
||||
except OSError:
|
||||
# Skip if filesystem doesn't support these characters
|
||||
pytest.skip("Filesystem doesn't support special characters in filenames")
|
||||
|
||||
result = collect_previous_task_context(
|
||||
working_directory=working_directory,
|
||||
previous_task_content="Test special chars",
|
||||
previous_task_result="Files created",
|
||||
previous_summary=""
|
||||
)
|
||||
|
||||
# Should list files with special characters
|
||||
assert "file with spaces.txt" in result
|
||||
assert "file-with-dashes.txt" in result
|
||||
assert "file_with_underscores.txt" in result
|
||||
assert "file.with.dots.txt" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_question_confirm_agent_error(self, mock_camel_agent):
|
||||
"""Test question_confirm when agent raises error."""
|
||||
|
|
|
|||
|
|
@ -370,13 +370,13 @@ class TestWorkforce:
|
|||
)
|
||||
|
||||
with patch('app.service.task.delete_task_lock', side_effect=Exception("Delete failed")), \
|
||||
patch('loguru.logger.error') as mock_log_error:
|
||||
patch('traceroot.get_logger') as mock_get_logger:
|
||||
|
||||
# Should not raise exception
|
||||
await workforce.cleanup()
|
||||
|
||||
|
||||
# Should log the error
|
||||
mock_log_error.assert_called_once()
|
||||
mock_get_logger.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
|
|
@ -623,13 +623,13 @@ class TestWorkforceErrorCases:
|
|||
)
|
||||
|
||||
with patch('app.service.task.delete_task_lock', side_effect=Exception("Task lock not found")), \
|
||||
patch('loguru.logger.error') as mock_log_error:
|
||||
patch('traceroot.get_logger') as mock_get_logger:
|
||||
|
||||
# Should handle missing task lock gracefully
|
||||
await workforce.cleanup()
|
||||
|
||||
|
||||
# Should log the error
|
||||
mock_log_error.assert_called_once()
|
||||
mock_get_logger.assert_called_once()
|
||||
|
||||
def test_workforce_inheritance(self):
|
||||
"""Test that Workforce properly inherits from BaseWorkforce."""
|
||||
|
|
|
|||
1
backend/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Utils package
|
||||
52
backend/uv.lock
generated
|
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = "==3.10.16"
|
||||
|
||||
[[package]]
|
||||
|
|
@ -122,6 +122,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "astor"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/21/75b771132fee241dfe601d39ade629548a9626d1d39f333fde31bc46febe/astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e", size = 35090, upload-time = "2019-12-10T01:50:35.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/88/97eef84f48fa04fbd6750e62dcceafba6c63c81b7ac1420856c8dcc0a3f9/astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5", size = 27488, upload-time = "2019-12-10T01:50:33.628Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "5.0.1"
|
||||
|
|
@ -219,7 +228,6 @@ dependencies = [
|
|||
{ name = "fastapi-babel" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "inflection" },
|
||||
{ name = "loguru" },
|
||||
{ name = "nodejs-wheel" },
|
||||
{ name = "numpy" },
|
||||
{ name = "openai" },
|
||||
|
|
@ -240,19 +248,18 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "camel-ai", extras = ["eigent"], specifier = "==0.2.76a13" },
|
||||
{ name = "camel-ai", extras = ["eigent"], specifier = "==0.2.78" },
|
||||
{ name = "fastapi", specifier = ">=0.115.12" },
|
||||
{ name = "fastapi-babel", specifier = ">=1.0.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
||||
{ name = "inflection", specifier = ">=0.5.1" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "nodejs-wheel", specifier = ">=22.18.0" },
|
||||
{ name = "numpy", specifier = ">=1.23.0,<2.0.0" },
|
||||
{ name = "openai", specifier = ">=1.99.3,<2" },
|
||||
{ name = "pydantic-i18n", specifier = ">=0.4.5" },
|
||||
{ name = "pydash", specifier = ">=8.0.5" },
|
||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||
{ name = "traceroot", specifier = ">=0.0.5a2" },
|
||||
{ name = "traceroot", specifier = ">=0.0.7" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.2" },
|
||||
]
|
||||
|
||||
|
|
@ -324,9 +331,10 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "camel-ai"
|
||||
version = "0.2.76a13"
|
||||
version = "0.2.78"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astor" },
|
||||
{ name = "colorama" },
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "httpx" },
|
||||
|
|
@ -339,9 +347,9 @@ dependencies = [
|
|||
{ name = "tiktoken" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/7c/0145edf0307e360557917de28691eb0c41b36b017a28c6b67e58a729a6da/camel_ai-0.2.76a13.tar.gz", hash = "sha256:487570c36a39a333ae8000783babd5a82350a829aaa8aa2ae712470b596cafe1", size = 950278, upload-time = "2025-10-06T06:09:46.064Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/2b/cd5181bfd0ebcf567a088ee5c1e3768b132ba4b1489ee19d5fb0bd679586/camel_ai-0.2.78.tar.gz", hash = "sha256:24745da225da7da96dcd85f72d143c6104569c17f14280c369d7e82b86851284", size = 964632, upload-time = "2025-10-15T17:20:54.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/46/9886106669491737631178830bce79bd7bf63391db4d2200f645089dd9df/camel_ai-0.2.76a13-py3-none-any.whl", hash = "sha256:b860412e4a5b5fc31b0cc3d4b1eeefcd02382d9a5aced252856a1eff0285a97b", size = 1400549, upload-time = "2025-10-06T06:09:43.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/81/0cfb1c0d9da589665e2eb4471887967e70bba428638c37fb4f6a78baf300/camel_ai-0.2.78-py3-none-any.whl", hash = "sha256:356624da13dfe0c55ef43dc509c18ce029f67fe3997966495a4ce9be931078d5", size = 1415578, upload-time = "2025-10-15T17:20:51.727Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1153,19 +1161,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
version = "0.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.2"
|
||||
|
|
@ -2528,7 +2523,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "traceroot"
|
||||
version = "0.0.5"
|
||||
version = "0.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
|
|
@ -2549,9 +2544,9 @@ dependencies = [
|
|||
{ name = "pyyaml" },
|
||||
{ name = "watchtower" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/5e/8ade61cadecf69b4fa49205640a7424880bc25b5e9615159ba8cf4aff2bf/traceroot-0.0.5.tar.gz", hash = "sha256:0924d9b524a9e59d64c4eec4c812018f2d7583558de17001294ace96874381c0", size = 28066, upload-time = "2025-08-24T03:29:04.966Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/c0/9f047cc761a9f98a2e7a9a8fef4c01ea1eeb7b2383fe1f3ad82d24ac98b3/traceroot-0.0.7.tar.gz", hash = "sha256:7792def0bb466977318f0126756c02e8950a1c208bcec7a8efed1e05e02b189d", size = 25710, upload-time = "2025-10-16T06:17:39.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2777d7c3d0e36b3b2d9f903151991f3e1c21f190788c0e5f537fd762ae34/traceroot-0.0.5-py3-none-any.whl", hash = "sha256:ec27afb4ac33df3109c4c436f3bdfc47e30e2f1ce5eb90ba2215cfebe19e6b2e", size = 24324, upload-time = "2025-08-24T03:29:03.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/59/8593afb3615fb0c2e0cf6888dc49d9ae05d365c76ee053f43a36519f889c/traceroot-0.0.7-py3-none-any.whl", hash = "sha256:2a20a8e2dfa6b10e1f96bc98d5b84dc40c14c01d47098f86068393ece99a2862", size = 24026, upload-time = "2025-10-16T06:17:38.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2734,15 +2729,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/35/25e68fbc99e672127cc6fbb14b8ec1ba3dfef035bf1e4c90f78f24a80b7d/wikipedia-1.4.0.tar.gz", hash = "sha256:db0fad1829fdd441b1852306e9856398204dc0786d2996dd2e0c8bb8e26133b2", size = 27748, upload-time = "2014-11-15T15:59:49.808Z" }
|
||||
|
||||
[[package]]
|
||||
name = "win32-setctime"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "1.17.3"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
|
|
@ -17,5 +18,7 @@
|
|||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"registries": {
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
"from": "backend",
|
||||
"to": "backend",
|
||||
"filter": ["**/*", "!.venv/**/*"]
|
||||
},
|
||||
{
|
||||
"from": "utils",
|
||||
"to": "utils"
|
||||
}
|
||||
],
|
||||
"protocols": [
|
||||
|
|
|
|||
|
|
@ -541,12 +541,54 @@ export class FileReader {
|
|||
}
|
||||
}
|
||||
|
||||
public getFileList(email: string, taskId: string): FileInfo[] {
|
||||
private findTaskInProjects(userDir: string, taskId: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(userDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(userDir);
|
||||
|
||||
// Look for project directories
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('project_')) {
|
||||
const projectDir = path.join(userDir, entry);
|
||||
const taskDir = path.join(projectDir, `task_${taskId}`);
|
||||
|
||||
if (fs.existsSync(taskDir)) {
|
||||
return taskDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Error finding task in projects:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public getFileList(email: string, taskId: string, projectId?: string): FileInfo[] {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
|
||||
const userHome = app.getPath('home');
|
||||
const dirPath = path.join(userHome, "eigent", safeEmail, `task_${taskId}`);
|
||||
|
||||
let dirPath: string;
|
||||
|
||||
// Check if projectId is provided for new project-based structure
|
||||
if (projectId) {
|
||||
dirPath = path.join(userHome, "eigent", safeEmail, `project_${projectId}`, `task_${taskId}`);
|
||||
} else {
|
||||
// First try project-based structure (scan for existing projects)
|
||||
const userDir = path.join(userHome, "eigent", safeEmail);
|
||||
const projectBasedPath = this.findTaskInProjects(userDir, taskId);
|
||||
|
||||
if (projectBasedPath) {
|
||||
dirPath = projectBasedPath;
|
||||
} else {
|
||||
// Fallback to legacy direct task structure
|
||||
dirPath = path.join(userHome, "eigent", safeEmail, `task_${taskId}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
|
|
@ -559,10 +601,62 @@ export class FileReader {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
public getLogFolder(email: string): string {
|
||||
|
||||
public deleteTaskFiles(email: string, taskId: string, projectId?: string): {
|
||||
success: boolean;
|
||||
path: { dirPath: string; logPath: string }
|
||||
}
|
||||
{
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
|
||||
let dirPath: string;
|
||||
let logPath: string;
|
||||
|
||||
// Check if projectId is provided for new project-based structure
|
||||
if (projectId) {
|
||||
dirPath = path.join(userHome, "eigent", safeEmail, `project_${projectId}`, `task_${taskId}`);
|
||||
logPath = path.join(userHome, ".eigent", safeEmail, `project_${projectId}`, `task_${taskId}`);
|
||||
} else {
|
||||
// First try project-based structure
|
||||
const userDir = path.join(userHome, "eigent", safeEmail);
|
||||
const projectBasedPath = this.findTaskInProjects(userDir, taskId);
|
||||
|
||||
if (projectBasedPath) {
|
||||
dirPath = projectBasedPath;
|
||||
// Extract project from path to construct log path
|
||||
const projectMatch = projectBasedPath.match(/project_([^\\\/]+)/);
|
||||
if (projectMatch) {
|
||||
logPath = path.join(userHome, ".eigent", safeEmail, projectMatch[0], `task_${taskId}`);
|
||||
} else {
|
||||
logPath = path.join(userHome, ".eigent", safeEmail, `task_${taskId}`);
|
||||
}
|
||||
} else {
|
||||
// Fallback to legacy direct task structure
|
||||
dirPath = path.join(userHome, "eigent", safeEmail, `task_${taskId}`);
|
||||
logPath = path.join(userHome, ".eigent", safeEmail, `task_${taskId}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let success = false;
|
||||
if (fs.existsSync(dirPath)) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
success = true;
|
||||
}
|
||||
if (fs.existsSync(logPath)) {
|
||||
fs.rmSync(logPath, { recursive: true, force: true });
|
||||
success = true;
|
||||
}
|
||||
return { success, path: { dirPath, logPath } };
|
||||
} catch (err) {
|
||||
console.error("Delete task files failed:", dirPath, err);
|
||||
return { success: false, path: { dirPath, logPath } };
|
||||
}
|
||||
}
|
||||
|
||||
public getLogFolder(email: string): string {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
const dirPath = path.join(userHome, "eigent", safeEmail);
|
||||
|
||||
|
|
@ -577,5 +671,205 @@ export class FileReader {
|
|||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public createProjectStructure(email: string, projectId: string): { success: boolean; path: string } {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
const projectPath = path.join(userHome, "eigent", safeEmail, `project_${projectId}`);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
}
|
||||
return { success: true, path: projectPath };
|
||||
} catch (err) {
|
||||
console.error("Create project structure failed:", err);
|
||||
return { success: false, path: projectPath };
|
||||
}
|
||||
}
|
||||
|
||||
public getProjectList(email: string): Array<{ id: string; name: string; path: string; taskCount: number; createdAt: Date }> {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
const userDir = path.join(userHome, "eigent", safeEmail);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(userDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(userDir);
|
||||
const projects: Array<{ id: string; name: string; path: string; taskCount: number; createdAt: Date }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('project_')) {
|
||||
const projectPath = path.join(userDir, entry);
|
||||
const stats = fs.statSync(projectPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const projectId = entry.replace('project_', '');
|
||||
|
||||
// Count tasks in this project
|
||||
const taskCount = this.countTasksInProject(projectPath);
|
||||
|
||||
projects.push({
|
||||
id: projectId,
|
||||
name: `Project ${projectId}`,
|
||||
path: projectPath,
|
||||
taskCount,
|
||||
createdAt: stats.birthtime
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return projects.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (err) {
|
||||
console.error("Get project list failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public getTasksInProject(email: string, projectId: string): Array<{ id: string; name: string; path: string; createdAt: Date }> {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
const projectPath = path.join(userHome, "eigent", safeEmail, `project_${projectId}`);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(projectPath);
|
||||
const tasks: Array<{ id: string; name: string; path: string; createdAt: Date }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('task_')) {
|
||||
const taskPath = path.join(projectPath, entry);
|
||||
const stats = fs.statSync(taskPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const taskId = entry.replace('task_', '');
|
||||
|
||||
tasks.push({
|
||||
id: taskId,
|
||||
name: `Task ${taskId}`,
|
||||
path: taskPath,
|
||||
createdAt: stats.birthtime
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
} catch (err) {
|
||||
console.error("Get tasks in project failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public moveTaskToProject(email: string, taskId: string, projectId: string): { success: boolean; message: string } {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
|
||||
// Source path (legacy structure)
|
||||
const sourcePath = path.join(userHome, "eigent", safeEmail, `task_${taskId}`);
|
||||
const sourceLogPath = path.join(userHome, ".eigent", safeEmail, `task_${taskId}`);
|
||||
|
||||
// Destination paths (project structure)
|
||||
const projectPath = path.join(userHome, "eigent", safeEmail, `project_${projectId}`);
|
||||
const destPath = path.join(projectPath, `task_${taskId}`);
|
||||
const destLogPath = path.join(userHome, ".eigent", safeEmail, `project_${projectId}`, `task_${taskId}`);
|
||||
|
||||
try {
|
||||
// Create project structure if it doesn't exist
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Create destination log directory
|
||||
const destLogDir = path.dirname(destLogPath);
|
||||
if (!fs.existsSync(destLogDir)) {
|
||||
fs.mkdirSync(destLogDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move task files
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.renameSync(sourcePath, destPath);
|
||||
}
|
||||
|
||||
// Move log files
|
||||
if (fs.existsSync(sourceLogPath)) {
|
||||
fs.renameSync(sourceLogPath, destLogPath);
|
||||
}
|
||||
|
||||
return { success: true, message: `Task ${taskId} moved to project ${projectId}` };
|
||||
} catch (err) {
|
||||
console.error("Move task to project failed:", err);
|
||||
return { success: false, message: `Failed to move task: ${err}` };
|
||||
}
|
||||
}
|
||||
|
||||
public getProjectFileList(email: string, projectId: string): FileInfo[] {
|
||||
const safeEmail = email.split('@')[0].replace(/[\\/*?:"<>|\s]/g, "_").replace(/^\.+|\.+$/g, "");
|
||||
const userHome = app.getPath('home');
|
||||
const projectPath = path.join(userHome, "eigent", safeEmail, `project_${projectId}`);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allFiles: FileInfo[] = [];
|
||||
const taskDirs = fs.readdirSync(projectPath);
|
||||
|
||||
for (const taskDir of taskDirs) {
|
||||
if (!taskDir.startsWith('task_')) continue;
|
||||
|
||||
const taskPath = path.join(projectPath, taskDir);
|
||||
const stats = fs.statSync(taskPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const taskId = taskDir.replace('task_', '');
|
||||
const taskFiles = this.getFilesRecursive(taskPath, taskPath);
|
||||
|
||||
const enrichedFiles = taskFiles.map(file => {
|
||||
const fileDir = path.dirname(file.path);
|
||||
const relativeParentPath = path.relative(projectPath, fileDir);
|
||||
|
||||
return {
|
||||
...file,
|
||||
task_id: taskId,
|
||||
project_id: projectId,
|
||||
relativePath: relativeParentPath === '.' ? '' : relativeParentPath
|
||||
};
|
||||
});
|
||||
|
||||
allFiles.push(...enrichedFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles.sort((a, b) => {
|
||||
// Sort by task_id first, then by file path
|
||||
if (a.task_id !== b.task_id) {
|
||||
return a.task_id!.localeCompare(b.task_id!);
|
||||
}
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Get project file list failed:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private countTasksInProject(projectPath: string): number {
|
||||
try {
|
||||
const entries = fs.readdirSync(projectPath);
|
||||
return entries.filter(entry => entry.startsWith('task_')).length;
|
||||
} catch (err) {
|
||||
console.error("Count tasks in project failed:", err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ let python_process: ChildProcessWithoutNullStreams | null = null;
|
|||
let backendPort: number = 5001;
|
||||
let browser_port = 9222;
|
||||
|
||||
// Protocol URL queue for handling URLs before window is ready
|
||||
let protocolUrlQueue: string[] = [];
|
||||
let isWindowReady = false;
|
||||
|
||||
// ==================== path config ====================
|
||||
const preload = path.join(__dirname, '../preload/index.mjs');
|
||||
const indexHtml = path.join(RENDERER_DIST, 'index.html');
|
||||
|
|
@ -97,6 +101,19 @@ const setupProtocolHandlers = () => {
|
|||
// ==================== protocol url handle ====================
|
||||
function handleProtocolUrl(url: string) {
|
||||
log.info('enter handleProtocolUrl', url);
|
||||
|
||||
// If window is not ready, queue the URL
|
||||
if (!isWindowReady || !win || win.isDestroyed()) {
|
||||
log.info('Window not ready, queuing protocol URL:', url);
|
||||
protocolUrlQueue.push(url);
|
||||
return;
|
||||
}
|
||||
|
||||
processProtocolUrl(url);
|
||||
}
|
||||
|
||||
// Process a single protocol URL
|
||||
function processProtocolUrl(url: string) {
|
||||
const urlObj = new URL(url);
|
||||
const code = urlObj.searchParams.get('code');
|
||||
const share_token = urlObj.searchParams.get('share_token');
|
||||
|
|
@ -130,6 +147,26 @@ function handleProtocolUrl(url: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Process all queued protocol URLs
|
||||
function processQueuedProtocolUrls() {
|
||||
if (protocolUrlQueue.length > 0) {
|
||||
log.info('Processing queued protocol URLs:', protocolUrlQueue.length);
|
||||
|
||||
// Verify window is ready before processing
|
||||
if (!win || win.isDestroyed() || !isWindowReady) {
|
||||
log.warn('Window not ready for processing queued URLs, keeping URLs in queue');
|
||||
return;
|
||||
}
|
||||
|
||||
const urls = [...protocolUrlQueue];
|
||||
protocolUrlQueue = [];
|
||||
|
||||
urls.forEach(url => {
|
||||
processProtocolUrl(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== single instance lock ====================
|
||||
const setupSingleInstanceLock = () => {
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
|
|
@ -207,7 +244,7 @@ const checkManagerInstance = (manager: any, name: string) => {
|
|||
function registerIpcHandlers() {
|
||||
// ==================== basic info handler ====================
|
||||
ipcMain.handle('get-browser-port', () => {
|
||||
log.info('Starting new task')
|
||||
log.info('Getting browser port')
|
||||
return browser_port
|
||||
});
|
||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||
|
|
@ -609,6 +646,13 @@ function registerIpcHandlers() {
|
|||
return { success: false, error: 'File does not exist' };
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
const stats = await fsp.stat(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
log.error('Path is a directory, not a file:', filePath);
|
||||
return { success: false, error: 'Path is a directory, not a file' };
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const fileContent = await fsp.readFile(filePath);
|
||||
log.info('File read successfully:', filePath);
|
||||
|
|
@ -802,9 +846,40 @@ function registerIpcHandlers() {
|
|||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-list', async (_, email: string, taskId: string) => {
|
||||
ipcMain.handle('get-file-list', async (_, email: string, taskId: string, projectId?: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.getFileList(email, taskId);
|
||||
return manager.getFileList(email, taskId, projectId);
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-task-files', async (_, email: string, taskId: string, projectId?: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.deleteTaskFiles(email, taskId, projectId);
|
||||
});
|
||||
|
||||
// New project management handlers
|
||||
ipcMain.handle('create-project-structure', async (_, email: string, projectId: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.createProjectStructure(email, projectId);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-project-list', async (_, email: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.getProjectList(email);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-tasks-in-project', async (_, email: string, projectId: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.getTasksInProject(email, projectId);
|
||||
});
|
||||
|
||||
ipcMain.handle('move-task-to-project', async (_, email: string, taskId: string, projectId: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.moveTaskToProject(email, taskId, projectId);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-project-file-list', async (_, email: string, projectId: string) => {
|
||||
const manager = checkManagerInstance(fileReader, 'FileReader');
|
||||
return manager.getProjectFileList(email, projectId);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-log-folder', async (_, email: string) => {
|
||||
|
|
@ -1102,6 +1177,11 @@ async function createWindow() {
|
|||
});
|
||||
});
|
||||
|
||||
// Mark window as ready and process any queued protocol URLs
|
||||
isWindowReady = true;
|
||||
log.info('Window is ready, processing queued protocol URLs...');
|
||||
processQueuedProtocolUrls();
|
||||
|
||||
// Now check and install dependencies
|
||||
let res:PromiseReturnType = await checkAndInstallDepsOnUpdate({ win });
|
||||
if (!res.success) {
|
||||
|
|
@ -1334,7 +1414,10 @@ app.on('window-all-closed', () => {
|
|||
webViewManager = null;
|
||||
}
|
||||
|
||||
// Reset window state
|
||||
win = null;
|
||||
isWindowReady = false;
|
||||
protocolUrlQueue = [];
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
|
|
@ -1387,6 +1470,10 @@ app.on('before-quit', async (event) => {
|
|||
global.gc();
|
||||
}
|
||||
|
||||
// Reset protocol handling state
|
||||
isWindowReady = false;
|
||||
protocolUrlQueue = [];
|
||||
|
||||
log.info('All cleanup completed, exiting...');
|
||||
} catch (error) {
|
||||
log.error('Error during cleanup:', error);
|
||||
|
|
|
|||
|
|
@ -230,10 +230,8 @@ class InstallLogs {
|
|||
|
||||
/**Display filtered logs based on severity */
|
||||
displayFilteredLogs(data:String) {
|
||||
if (!data) return;
|
||||
if (!data) return;
|
||||
const msg = data.toString().trimEnd();
|
||||
//Detect if uv sync is run
|
||||
detectInstallationLogs(msg);
|
||||
if (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("traceback")) {
|
||||
log.error(`BACKEND: [DEPS INSTALL] ${msg}`);
|
||||
safeMainWindowSend('install-dependencies-log', { type: 'stderr', data: data.toString() });
|
||||
|
|
@ -358,6 +356,29 @@ export async function installDependencies(version: string): Promise<PromiseRetur
|
|||
return true; // Not an error if the toolkit isn't installed
|
||||
}
|
||||
|
||||
// Check if npm dependencies are already installed
|
||||
const npmMarkerPath = path.join(toolkitPath, '.npm_dependencies_installed');
|
||||
const nodeModulesPath = path.join(toolkitPath, 'node_modules');
|
||||
const distPath = path.join(toolkitPath, 'dist');
|
||||
|
||||
// Check if marker exists and verify version
|
||||
if (fs.existsSync(npmMarkerPath) && fs.existsSync(nodeModulesPath) && fs.existsSync(distPath)) {
|
||||
try {
|
||||
const markerContent = JSON.parse(fs.readFileSync(npmMarkerPath, 'utf-8'));
|
||||
if (markerContent.version === version) {
|
||||
log.info('[DEPS INSTALL] hybrid_browser_toolkit npm dependencies already installed for current version, skipping...');
|
||||
return true;
|
||||
} else {
|
||||
log.info('[DEPS INSTALL] npm dependencies installed for different version, will reinstall...');
|
||||
// Clean up old installation
|
||||
fs.unlinkSync(npmMarkerPath);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('[DEPS INSTALL] Could not read npm marker file, will reinstall...', error);
|
||||
// If we can't read the marker, assume we need to reinstall
|
||||
}
|
||||
}
|
||||
|
||||
log.info('[DEPS INSTALL] Installing hybrid_browser_toolkit npm dependencies...');
|
||||
safeMainWindowSend('install-dependencies-log', {
|
||||
type: 'stdout',
|
||||
|
|
@ -515,6 +536,13 @@ export async function installDependencies(version: string): Promise<PromiseRetur
|
|||
// Non-critical, continue
|
||||
}
|
||||
|
||||
// Create marker file to indicate npm dependencies are installed
|
||||
fs.writeFileSync(npmMarkerPath, JSON.stringify({
|
||||
installedAt: new Date().toISOString(),
|
||||
version: version
|
||||
}));
|
||||
log.info('[DEPS INSTALL] Created npm dependencies marker file');
|
||||
|
||||
log.info('[DEPS INSTALL] hybrid_browser_toolkit dependencies installed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -542,6 +570,32 @@ export async function installDependencies(version: string): Promise<PromiseRetur
|
|||
// Set Installing Lock Files
|
||||
InstallLogs.setLockPath();
|
||||
|
||||
// Clean up npm dependencies marker when reinstalling Python deps
|
||||
// This ensures npm deps are reinstalled when Python environment changes
|
||||
try {
|
||||
let sitePackagesPath: string | null = null;
|
||||
const libPath = path.join(venvPath, 'lib');
|
||||
|
||||
if (fs.existsSync(libPath)) {
|
||||
const libContents = fs.readdirSync(libPath);
|
||||
const pythonDir = libContents.find(name => name.startsWith('python'));
|
||||
if (pythonDir) {
|
||||
sitePackagesPath = path.join(libPath, pythonDir, 'site-packages');
|
||||
}
|
||||
}
|
||||
|
||||
if (sitePackagesPath) {
|
||||
const npmMarkerPath = path.join(sitePackagesPath, 'camel', 'toolkits', 'hybrid_browser_toolkit', 'ts', '.npm_dependencies_installed');
|
||||
if (fs.existsSync(npmMarkerPath)) {
|
||||
fs.unlinkSync(npmMarkerPath);
|
||||
log.info('[DEPS INSTALL] Removed npm dependencies marker for fresh installation');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('[DEPS INSTALL] Could not clean npm marker file:', error);
|
||||
// Non-critical, continue
|
||||
}
|
||||
|
||||
// try default install
|
||||
const installSuccess = await runInstall([], version)
|
||||
if (installSuccess.success) {
|
||||
|
|
@ -592,6 +646,24 @@ export async function installDependencies(version: string): Promise<PromiseRetur
|
|||
let dependencyInstallationDetected = false;
|
||||
let installationNotificationSent = false;
|
||||
export function detectInstallationLogs(msg:string) {
|
||||
// CRITICAL FIX: Use file system to check if installation is complete
|
||||
// Don't rely on module variables as they can be reset during hot reload
|
||||
|
||||
// Check if dependencies are already installed
|
||||
const isAlreadyInstalled = fs.existsSync(installedLockPath);
|
||||
|
||||
// If installed lock file exists, dependencies are already installed
|
||||
// Skip all detection to avoid false positives
|
||||
if (isAlreadyInstalled) {
|
||||
// Dependencies are already installed, skip detection entirely
|
||||
return;
|
||||
}
|
||||
|
||||
// Also skip if notification was already sent (in current session)
|
||||
if (installationNotificationSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for UV dependency installation patterns
|
||||
const installPatterns = [
|
||||
"Resolved", // UV resolving dependencies
|
||||
|
|
@ -605,18 +677,18 @@ export function detectInstallationLogs(msg:string) {
|
|||
"× No solution found when resolving dependencies", // Dependency resolution issues
|
||||
"Audited" // UV auditing dependencies
|
||||
];
|
||||
|
||||
|
||||
// Detect if UV is installing dependencies
|
||||
if (!dependencyInstallationDetected && installPatterns.some(pattern =>
|
||||
if (!dependencyInstallationDetected && installPatterns.some(pattern =>
|
||||
msg.includes(pattern) && !msg.includes("Uvicorn running on")
|
||||
)) {
|
||||
dependencyInstallationDetected = true;
|
||||
log.info('[BACKEND STARTUP] UV dependency installation detected during uvicorn startup');
|
||||
|
||||
|
||||
// Create installing lock file to maintain consistency with install-deps.ts
|
||||
InstallLogs.setLockPath();
|
||||
log.info('[BACKEND STARTUP] Created uv_installing.lock file');
|
||||
|
||||
|
||||
// Notify frontend that installation has started (only once)
|
||||
if (!installationNotificationSent) {
|
||||
installationNotificationSent = true;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export class WebViewManager {
|
|||
}
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
// Use a separate session partition for webviews to isolate storage from main window
|
||||
// This ensures clearing webview storage won't affect main window's auth data
|
||||
partition: 'persist:agent-webview',
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: true,
|
||||
|
|
@ -269,6 +272,7 @@ export class WebViewManager {
|
|||
|
||||
if (!webViewInfo.view.webContents.isDestroyed()) {
|
||||
webViewInfo.view.webContents.removeAllListeners()
|
||||
// Now safe to clear all storage since webviews use separate partition
|
||||
webViewInfo.view.webContents.session.clearCache()
|
||||
webViewInfo.view.webContents.session.clearStorageData({
|
||||
storages: ['cookies', 'localstorage', 'websql', 'indexdb', 'serviceworkers', 'cachestorage']
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"compile-babel": "cd backend && uv run pybabel compile -d lang",
|
||||
"dev": "npm run compile-babel && vite",
|
||||
"clean-cache": "rimraf node_modules/.vite",
|
||||
"dev": "npm run clean-cache && npm run compile-babel && vite",
|
||||
"build": "npm run compile-babel && tsc && vite build && electron-builder -- --publish always",
|
||||
"build:mac": "npm run compile-babel && tsc && vite build && electron-builder --mac",
|
||||
"build:win": "npm run compile-babel && tsc && vite build && electron-builder --win",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"dependencies": {
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@fontsource/inter": "^5.2.5",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@monaco-editor/loader": "^1.5.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
|
@ -71,8 +73,10 @@
|
|||
"lucide-react": "^0.509.0",
|
||||
"mammoth": "^1.9.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "^12.23.23",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"postprocessing": "^6.37.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.4",
|
||||
"react-router-dom": "^7.6.0",
|
||||
|
|
@ -81,6 +85,7 @@
|
|||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar": "^7.4.3",
|
||||
"three": "^0.180.0",
|
||||
"tree-kill": "^1.2.2",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"unzipper": "^0.12.3",
|
||||
|
|
@ -112,6 +117,7 @@
|
|||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.4.2",
|
||||
"vite": "^5.4.11",
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ database_url=postgresql://postgres:postgres@localhost:5432/postgres
|
|||
# Chat Share Secret Key
|
||||
CHAT_SHARE_SECRET_KEY=put-your-secret-key-here
|
||||
CHAT_SHARE_SALT=put-your-encode-salt-here
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Use a Python image with uv pre-installed
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
|
||||
# Install the project into `/app`
|
||||
WORKDIR /app
|
||||
|
|
@ -15,6 +15,10 @@ ENV UV_PYTHON_INSTALL_MIRROR=https://registry.npmmirror.com/-/binary/python-buil
|
|||
ARG database_url
|
||||
ENV database_url=$database_url
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
python3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy dependency files first
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
"""modify_chat_history_add_project_id
|
||||
|
||||
Revision ID: eec7242b3a9b
|
||||
Revises: d74ab2a44600
|
||||
Create Date: 2025-10-15 14:46:47.904254
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "eec7242b3a9b"
|
||||
down_revision: Union[str, None] = "d74ab2a44600"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("chat_history", sa.Column("project_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
op.create_index(op.f("ix_chat_history_project_id"), "chat_history", ["project_id"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_chat_history_project_id"), table_name="chat_history")
|
||||
op.drop_column("chat_history", "project_id")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi_pagination import add_pagination
|
||||
|
||||
|
||||
api = FastAPI(swagger_ui_parameters={"persistAuthorization": True})
|
||||
add_pagination(api)
|
||||
|
|
|
|||
|
|
@ -6,47 +6,95 @@ from fastapi_babel import _
|
|||
from sqlmodel import Session, select, desc
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_chat_history")
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat History"])
|
||||
|
||||
|
||||
@router.post("/history", name="save chat history", response_model=ChatHistoryOut)
|
||||
@traceroot.trace()
|
||||
def create_chat_history(data: ChatHistoryIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
data.user_id = auth.user.id
|
||||
chat_history = ChatHistory(**data.model_dump())
|
||||
session.add(chat_history)
|
||||
session.commit()
|
||||
session.refresh(chat_history)
|
||||
return chat_history
|
||||
"""Save new chat history."""
|
||||
user_id = auth.user.id
|
||||
|
||||
try:
|
||||
data.user_id = user_id
|
||||
chat_history = ChatHistory(**data.model_dump())
|
||||
session.add(chat_history)
|
||||
session.commit()
|
||||
session.refresh(chat_history)
|
||||
logger.info("Chat history created", extra={"user_id": user_id, "history_id": chat_history.id, "task_id": data.task_id})
|
||||
return chat_history
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Chat history creation failed", extra={"user_id": user_id, "task_id": data.task_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/histories", name="get chat history")
|
||||
@traceroot.trace()
|
||||
def list_chat_history(session: Session = Depends(session), auth: Auth = Depends(auth_must)) -> Page[ChatHistoryOut]:
|
||||
stmt = select(ChatHistory).where(ChatHistory.user_id == auth.user.id).order_by(desc(ChatHistory.created_at))
|
||||
return paginate(session, stmt)
|
||||
"""List chat histories for current user."""
|
||||
user_id = auth.user.id
|
||||
stmt = select(ChatHistory).where(ChatHistory.user_id == user_id).order_by(desc(ChatHistory.created_at))
|
||||
result = paginate(session, stmt)
|
||||
total = result.total if hasattr(result, 'total') else 0
|
||||
logger.debug("Chat histories listed", extra={"user_id": user_id, "total": total})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/history/{history_id}", name="delete chat history")
|
||||
def delete_chat_history(history_id: str, session: Session = Depends(session)):
|
||||
@traceroot.trace()
|
||||
def delete_chat_history(history_id: str, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Delete chat history."""
|
||||
user_id = auth.user.id
|
||||
history = session.exec(select(ChatHistory).where(ChatHistory.id == history_id)).first()
|
||||
|
||||
if not history:
|
||||
raise HTTPException(status_code=404, detail="Caht History not found")
|
||||
session.delete(history)
|
||||
session.commit()
|
||||
return Response(status_code=204)
|
||||
logger.warning("Chat history not found for deletion", extra={"user_id": user_id, "history_id": history_id})
|
||||
raise HTTPException(status_code=404, detail="Chat History not found")
|
||||
|
||||
if history.user_id != user_id:
|
||||
logger.warning("Unauthorized deletion attempt", extra={"user_id": user_id, "history_id": history_id, "owner_id": history.user_id})
|
||||
raise HTTPException(status_code=403, detail="You are not allowed to delete this chat history")
|
||||
|
||||
try:
|
||||
session.delete(history)
|
||||
session.commit()
|
||||
logger.info("Chat history deleted", extra={"user_id": user_id, "history_id": history_id})
|
||||
return Response(status_code=204)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Chat history deletion failed", extra={"user_id": user_id, "history_id": history_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/history/{history_id}", name="update chat history", response_model=ChatHistoryOut)
|
||||
@traceroot.trace()
|
||||
def update_chat_history(
|
||||
history_id: int, data: ChatHistoryUpdate, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""Update chat history."""
|
||||
user_id = auth.user.id
|
||||
history = session.exec(select(ChatHistory).where(ChatHistory.id == history_id)).first()
|
||||
|
||||
if not history:
|
||||
logger.warning("Chat history not found for update", extra={"user_id": user_id, "history_id": history_id})
|
||||
raise HTTPException(status_code=404, detail="Chat History not found")
|
||||
if history.user_id != auth.user.id:
|
||||
|
||||
if history.user_id != user_id:
|
||||
logger.warning("Unauthorized update attempt", extra={"user_id": user_id, "history_id": history_id, "owner_id": history.user_id})
|
||||
raise HTTPException(status_code=403, detail="You are not allowed to update this chat history")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
history.update_fields(update_data)
|
||||
history.save(session)
|
||||
session.refresh(history)
|
||||
return history
|
||||
|
||||
try:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
history.update_fields(update_data)
|
||||
history.save(session)
|
||||
session.refresh(history)
|
||||
logger.info("Chat history updated", extra={"user_id": user_id, "history_id": history_id, "fields_updated": list(update_data.keys())})
|
||||
return history
|
||||
except Exception as e:
|
||||
logger.error("Chat history update failed", extra={"user_id": user_id, "history_id": history_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,78 +1,107 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlmodel import Session, asc, select
|
||||
from app.component.database import session
|
||||
import json
|
||||
import asyncio
|
||||
from itsdangerous import SignatureExpired, BadTimeSignature
|
||||
from starlette.responses import StreamingResponse
|
||||
from app.model.chat.chat_share import ChatHistoryShareOut, ChatShare, ChatShareIn
|
||||
from app.model.chat.chat_step import ChatStep
|
||||
from app.model.chat.chat_history import ChatHistory
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat Share"])
|
||||
|
||||
|
||||
@router.get("/share/info/{token}", name="Get shared chat info", response_model=ChatHistoryShareOut)
|
||||
def get_share_info(token: str, session: Session = Depends(session)):
|
||||
"""
|
||||
Get shared chat history info by token, excluding sensitive data.
|
||||
"""
|
||||
try:
|
||||
task_id = ChatShare.verify_token(token, False)
|
||||
except (SignatureExpired, BadTimeSignature):
|
||||
raise HTTPException(status_code=400, detail="Share link is invalid or has expired.")
|
||||
|
||||
stmt = select(ChatHistory).where(ChatHistory.task_id == task_id)
|
||||
history = session.exec(stmt).one_or_none()
|
||||
|
||||
if not history:
|
||||
raise HTTPException(status_code=404, detail="Chat history not found.")
|
||||
|
||||
return history
|
||||
|
||||
|
||||
@router.get("/share/playback/{token}", name="Playback shared chat via SSE")
|
||||
async def share_playback(token: str, session: Session = Depends(session), delay_time: float = 0):
|
||||
"""
|
||||
Playbacks the chat history via a sharing token (SSE).
|
||||
delay_time: control sse interval, max 5 seconds
|
||||
"""
|
||||
if delay_time > 5:
|
||||
delay_time = 5
|
||||
try:
|
||||
task_id = ChatShare.verify_token(token, False)
|
||||
except SignatureExpired:
|
||||
raise HTTPException(status_code=400, detail="Share link has expired.")
|
||||
except BadTimeSignature:
|
||||
raise HTTPException(status_code=400, detail="Share link is invalid.")
|
||||
|
||||
async def event_generator():
|
||||
stmt = select(ChatStep).where(ChatStep.task_id == task_id).order_by(asc(ChatStep.id))
|
||||
steps = session.exec(stmt).all()
|
||||
|
||||
if not steps:
|
||||
yield f"data: {json.dumps({'error': 'No steps found for this task.'})}\n\n"
|
||||
return
|
||||
|
||||
for step in steps:
|
||||
step_data = {
|
||||
"id": step.id,
|
||||
"task_id": step.task_id,
|
||||
"step": step.step,
|
||||
"data": step.data,
|
||||
"created_at": step.created_at.isoformat() if step.created_at else None,
|
||||
}
|
||||
yield f"data: {json.dumps(step_data)}\n\n"
|
||||
if delay_time > 0 and step.step != "create_agent":
|
||||
await asyncio.sleep(delay_time)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/share", name="Generate sharable link for a task(1 day expiration)")
|
||||
def create_share_link(data: ChatShareIn):
|
||||
"""
|
||||
Generates a sharing token with an expiration time for the specified task_id.
|
||||
"""
|
||||
share_token = ChatShare.generate_token(data.task_id)
|
||||
return {"share_token": share_token}
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlmodel import Session, asc, select
|
||||
from app.component.database import session
|
||||
import json
|
||||
import asyncio
|
||||
from itsdangerous import SignatureExpired, BadTimeSignature
|
||||
from starlette.responses import StreamingResponse
|
||||
from app.model.chat.chat_share import ChatHistoryShareOut, ChatShare, ChatShareIn
|
||||
from app.model.chat.chat_step import ChatStep
|
||||
from app.model.chat.chat_history import ChatHistory
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_chat_share")
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat Share"])
|
||||
|
||||
|
||||
@router.get("/share/info/{token}", name="Get shared chat info", response_model=ChatHistoryShareOut)
|
||||
@traceroot.trace()
|
||||
def get_share_info(token: str, session: Session = Depends(session)):
|
||||
"""
|
||||
Get shared chat history info by token, excluding sensitive data.
|
||||
"""
|
||||
try:
|
||||
task_id = ChatShare.verify_token(token, False)
|
||||
except SignatureExpired:
|
||||
logger.warning("Shared chat access failed: token expired", extra={"token_prefix": token[:10]})
|
||||
raise HTTPException(status_code=400, detail="Share link is invalid or has expired.")
|
||||
except BadTimeSignature:
|
||||
logger.warning("Shared chat access failed: invalid token", extra={"token_prefix": token[:10]})
|
||||
raise HTTPException(status_code=400, detail="Share link is invalid or has expired.")
|
||||
|
||||
stmt = select(ChatHistory).where(ChatHistory.task_id == task_id)
|
||||
history = session.exec(stmt).one_or_none()
|
||||
|
||||
if not history:
|
||||
logger.warning("Shared chat not found", extra={"task_id": task_id})
|
||||
raise HTTPException(status_code=404, detail="Chat history not found.")
|
||||
|
||||
logger.info("Shared chat info accessed", extra={"task_id": task_id})
|
||||
return history
|
||||
|
||||
|
||||
@router.get("/share/playback/{token}", name="Playback shared chat via SSE")
|
||||
@traceroot.trace()
|
||||
async def share_playback(token: str, session: Session = Depends(session), delay_time: float = 0):
|
||||
"""
|
||||
Playbacks the chat history via a sharing token (SSE).
|
||||
delay_time: control sse interval, max 5 seconds
|
||||
"""
|
||||
if delay_time > 5:
|
||||
logger.debug("Delay time capped", extra={"requested": delay_time, "capped": 5})
|
||||
delay_time = 5
|
||||
|
||||
try:
|
||||
task_id = ChatShare.verify_token(token, False)
|
||||
except SignatureExpired:
|
||||
logger.warning("Shared chat playback failed: token expired", extra={"token_prefix": token[:10]})
|
||||
raise HTTPException(status_code=400, detail="Share link has expired.")
|
||||
except BadTimeSignature:
|
||||
logger.warning("Shared chat playback failed: invalid token", extra={"token_prefix": token[:10]})
|
||||
raise HTTPException(status_code=400, detail="Share link is invalid.")
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
stmt = select(ChatStep).where(ChatStep.task_id == task_id).order_by(asc(ChatStep.id))
|
||||
steps = session.exec(stmt).all()
|
||||
|
||||
if not steps:
|
||||
logger.warning("No steps found for playback", extra={"task_id": task_id})
|
||||
yield f"data: {json.dumps({'error': 'No steps found for this task.'})}\n\n"
|
||||
return
|
||||
|
||||
logger.info("Shared chat playback started", extra={"task_id": task_id, "step_count": len(steps), "delay_time": delay_time})
|
||||
|
||||
for idx, step in enumerate(steps, start=1):
|
||||
step_data = {
|
||||
"id": step.id,
|
||||
"task_id": step.task_id,
|
||||
"step": step.step,
|
||||
"data": step.data,
|
||||
"created_at": step.created_at.isoformat() if step.created_at else None,
|
||||
}
|
||||
yield f"data: {json.dumps(step_data)}\n\n"
|
||||
|
||||
if delay_time > 0 and step.step != "create_agent":
|
||||
await asyncio.sleep(delay_time)
|
||||
|
||||
logger.info("Shared chat playback completed", extra={"task_id": task_id, "step_count": len(steps)})
|
||||
except Exception as e:
|
||||
logger.error("Shared chat playback error", extra={"task_id": task_id, "error": str(e)}, exc_info=True)
|
||||
yield f"data: {json.dumps({'error': 'Playback error occurred.'})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/share", name="Generate sharable link for a task(1 day expiration)")
|
||||
@traceroot.trace()
|
||||
def create_share_link(data: ChatShareIn):
|
||||
"""Generate sharing token with 1-day expiration for task."""
|
||||
try:
|
||||
share_token = ChatShare.generate_token(data.task_id)
|
||||
logger.info("Share link created", extra={"task_id": data.task_id, "token_prefix": share_token[:10]})
|
||||
return {"share_token": share_token}
|
||||
except Exception as e:
|
||||
logger.error("Share link creation failed", extra={"task_id": data.task_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,81 +1,138 @@
|
|||
from app.model.chat.chat_snpshot import ChatSnapshot, ChatSnapshotIn
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Response, APIRouter
|
||||
from sqlmodel import Session, select
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat Snapshot Management"])
|
||||
|
||||
|
||||
@router.get("/snapshots", name="list chat snapshots", response_model=List[ChatSnapshot])
|
||||
async def list_chat_snapshots(
|
||||
api_task_id: Optional[str] = None,
|
||||
camel_task_id: Optional[str] = None,
|
||||
browser_url: Optional[str] = None,
|
||||
session: Session = Depends(session),
|
||||
):
|
||||
query = select(ChatSnapshot)
|
||||
if api_task_id is not None:
|
||||
query = query.where(ChatSnapshot.api_task_id == api_task_id)
|
||||
if camel_task_id is not None:
|
||||
query = query.where(ChatSnapshot.camel_task_id == camel_task_id)
|
||||
if browser_url is not None:
|
||||
query = query.where(ChatSnapshot.browser_url == browser_url)
|
||||
snapshots = session.exec(query).all()
|
||||
return snapshots
|
||||
|
||||
|
||||
@router.get("/snapshots/{snapshot_id}", name="get chat snapshot", response_model=ChatSnapshot)
|
||||
async def get_chat_snapshot(snapshot_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
snapshot = session.get(ChatSnapshot, snapshot_id)
|
||||
if not snapshot:
|
||||
raise HTTPException(status_code=404, detail=_("Chat snapshot not found"))
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.post("/snapshots", name="create chat snapshot", response_model=ChatSnapshot)
|
||||
async def create_chat_snapshot(
|
||||
snapshot: ChatSnapshotIn, auth: Auth = Depends(auth_must), session: Session = Depends(session)
|
||||
):
|
||||
image_path = ChatSnapshotIn.save_image(auth.user.id, snapshot.api_task_id, snapshot.image_base64)
|
||||
chat_snapshot = ChatSnapshot(
|
||||
user_id=auth.user.id,
|
||||
api_task_id=snapshot.api_task_id,
|
||||
camel_task_id=snapshot.camel_task_id,
|
||||
browser_url=snapshot.browser_url,
|
||||
image_path=image_path,
|
||||
)
|
||||
session.add(chat_snapshot)
|
||||
session.commit()
|
||||
session.refresh(chat_snapshot)
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@router.put("/snapshots/{snapshot_id}", name="update chat snapshot", response_model=ChatSnapshot)
|
||||
async def update_chat_snapshot(
|
||||
snapshot_id: int,
|
||||
snapshot_update: ChatSnapshot,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
db_snapshot = session.get(ChatSnapshot, snapshot_id)
|
||||
if not db_snapshot:
|
||||
raise HTTPException(status_code=404, detail=_("Chat snapshot not found"))
|
||||
for key, value in snapshot_update.dict(exclude_unset=True).items():
|
||||
setattr(db_snapshot, key, value)
|
||||
session.add(db_snapshot)
|
||||
session.commit()
|
||||
session.refresh(db_snapshot)
|
||||
return db_snapshot
|
||||
|
||||
|
||||
@router.delete("/snapshots/{snapshot_id}", name="delete chat snapshot")
|
||||
async def delete_chat_snapshot(snapshot_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
db_snapshot = session.get(ChatSnapshot, snapshot_id)
|
||||
if not db_snapshot:
|
||||
raise HTTPException(status_code=404, detail=_("Chat snapshot not found"))
|
||||
session.delete(db_snapshot)
|
||||
session.commit()
|
||||
return Response(status_code=204)
|
||||
from app.model.chat.chat_snpshot import ChatSnapshot, ChatSnapshotIn
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Response, APIRouter
|
||||
from sqlmodel import Session, select
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_chat_snapshot")
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat Snapshot Management"])
|
||||
|
||||
|
||||
@router.get("/snapshots", name="list chat snapshots", response_model=List[ChatSnapshot])
|
||||
@traceroot.trace()
|
||||
async def list_chat_snapshots(
|
||||
api_task_id: Optional[str] = None,
|
||||
camel_task_id: Optional[str] = None,
|
||||
browser_url: Optional[str] = None,
|
||||
session: Session = Depends(session),
|
||||
):
|
||||
"""List chat snapshots with optional filtering."""
|
||||
query = select(ChatSnapshot)
|
||||
if api_task_id is not None:
|
||||
query = query.where(ChatSnapshot.api_task_id == api_task_id)
|
||||
if camel_task_id is not None:
|
||||
query = query.where(ChatSnapshot.camel_task_id == camel_task_id)
|
||||
if browser_url is not None:
|
||||
query = query.where(ChatSnapshot.browser_url == browser_url)
|
||||
|
||||
snapshots = session.exec(query).all()
|
||||
logger.debug("Snapshots listed", extra={"api_task_id": api_task_id, "camel_task_id": camel_task_id, "count": len(snapshots)})
|
||||
return snapshots
|
||||
|
||||
|
||||
@router.get("/snapshots/{snapshot_id}", name="get chat snapshot", response_model=ChatSnapshot)
|
||||
@traceroot.trace()
|
||||
async def get_chat_snapshot(snapshot_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Get specific chat snapshot."""
|
||||
user_id = auth.user.id
|
||||
snapshot = session.get(ChatSnapshot, snapshot_id)
|
||||
|
||||
if not snapshot:
|
||||
logger.warning("Snapshot not found", extra={"user_id": user_id, "snapshot_id": snapshot_id})
|
||||
raise HTTPException(status_code=404, detail=_("Chat snapshot not found"))
|
||||
|
||||
logger.debug("Snapshot retrieved", extra={"user_id": user_id, "snapshot_id": snapshot_id, "api_task_id": snapshot.api_task_id})
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.post("/snapshots", name="create chat snapshot", response_model=ChatSnapshot)
|
||||
@traceroot.trace()
|
||||
async def create_chat_snapshot(
|
||||
snapshot: ChatSnapshotIn, auth: Auth = Depends(auth_must), session: Session = Depends(session)
|
||||
):
|
||||
"""Create new chat snapshot from image."""
|
||||
user_id = auth.user.id
|
||||
|
||||
try:
|
||||
image_path = ChatSnapshotIn.save_image(user_id, snapshot.api_task_id, snapshot.image_base64)
|
||||
chat_snapshot = ChatSnapshot(
|
||||
user_id=user_id,
|
||||
api_task_id=snapshot.api_task_id,
|
||||
camel_task_id=snapshot.camel_task_id,
|
||||
browser_url=snapshot.browser_url,
|
||||
image_path=image_path,
|
||||
)
|
||||
session.add(chat_snapshot)
|
||||
session.commit()
|
||||
session.refresh(chat_snapshot)
|
||||
logger.info("Snapshot created", extra={"user_id": user_id, "snapshot_id": chat_snapshot.id, "api_task_id": snapshot.api_task_id, "image_path": image_path})
|
||||
return chat_snapshot
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Snapshot creation failed", extra={"user_id": user_id, "api_task_id": snapshot.api_task_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/snapshots/{snapshot_id}", name="update chat snapshot", response_model=ChatSnapshot)
|
||||
@traceroot.trace()
|
||||
async def update_chat_snapshot(
|
||||
snapshot_id: int,
|
||||
snapshot_update: ChatSnapshot,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
"""Update chat snapshot."""
|
||||
user_id = auth.user.id
|
||||
db_snapshot = session.get(ChatSnapshot, snapshot_id)
|
||||
|
||||
if not db_snapshot:
|
||||
logger.warning("Snapshot not found for update", extra={"user_id": user_id, "snapshot_id": snapshot_id})
|
||||
raise HTTPException(status_code=404, detail=_("Chat snapshot not found"))
|
||||
|
||||
if db_snapshot.user_id != user_id:
|
||||
logger.warning("Unauthorized snapshot update", extra={"user_id": user_id, "snapshot_id": snapshot_id, "owner_id": db_snapshot.user_id})
|
||||
raise HTTPException(status_code=403, detail=_("You are not allowed to update this snapshot"))
|
||||
|
||||
try:
|
||||
update_data = snapshot_update.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_snapshot, key, value)
|
||||
session.add(db_snapshot)
|
||||
session.commit()
|
||||
session.refresh(db_snapshot)
|
||||
logger.info("Snapshot updated", extra={"user_id": user_id, "snapshot_id": snapshot_id, "fields_updated": list(update_data.keys())})
|
||||
return db_snapshot
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Snapshot update failed", extra={"user_id": user_id, "snapshot_id": snapshot_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/snapshots/{snapshot_id}", name="delete chat snapshot")
|
||||
@traceroot.trace()
|
||||
async def delete_chat_snapshot(snapshot_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Delete chat snapshot."""
|
||||
user_id = auth.user.id
|
||||
db_snapshot = session.get(ChatSnapshot, snapshot_id)
|
||||
|
||||
if not db_snapshot:
|
||||
logger.warning("Snapshot not found for deletion", extra={"user_id": user_id, "snapshot_id": snapshot_id})
|
||||
raise HTTPException(status_code=404, detail=_("Chat snapshot not found"))
|
||||
|
||||
if db_snapshot.user_id != user_id:
|
||||
logger.warning("Unauthorized snapshot deletion", extra={"user_id": user_id, "snapshot_id": snapshot_id, "owner_id": db_snapshot.user_id})
|
||||
raise HTTPException(status_code=403, detail=_("You are not allowed to delete this snapshot"))
|
||||
|
||||
try:
|
||||
session.delete(db_snapshot)
|
||||
session.commit()
|
||||
logger.info("Snapshot deleted", extra={"user_id": user_id, "snapshot_id": snapshot_id, "image_path": db_snapshot.image_path})
|
||||
return Response(status_code=204)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Snapshot deletion failed", extra={"user_id": user_id, "snapshot_id": snapshot_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,105 +1,163 @@
|
|||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session, asc, select
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from app.model.chat.chat_step import ChatStep, ChatStepOut, ChatStepIn
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat Step Management"])
|
||||
|
||||
|
||||
@router.get("/steps", name="list chat steps", response_model=List[ChatStepOut])
|
||||
async def list_chat_steps(
|
||||
task_id: str, step: Optional[str] = None, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
query = select(ChatStep)
|
||||
if task_id is not None:
|
||||
query = query.where(ChatStep.task_id == task_id)
|
||||
if step is not None:
|
||||
query = query.where(ChatStep.step == step)
|
||||
chat_steps = session.exec(query).all()
|
||||
return chat_steps
|
||||
|
||||
|
||||
@router.get("/steps/playback/{task_id}", name="Playback Chat Step via SSE")
|
||||
async def share_playback(
|
||||
task_id: str, delay_time: float = 0, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""
|
||||
Playbacks the chat steps (SSE).
|
||||
"""
|
||||
if delay_time > 5:
|
||||
delay_time = 5
|
||||
|
||||
async def event_generator():
|
||||
stmt = select(ChatStep).where(ChatStep.task_id == task_id).order_by(asc(ChatStep.id))
|
||||
steps = session.exec(stmt).all()
|
||||
|
||||
if not steps:
|
||||
yield f"data: {json.dumps({'error': 'No steps found for this task.'})}\n\n"
|
||||
return
|
||||
|
||||
for step in steps:
|
||||
step_data = {
|
||||
"id": step.id,
|
||||
"task_id": step.task_id,
|
||||
"step": step.step,
|
||||
"data": step.data,
|
||||
"created_at": step.created_at.isoformat() if step.created_at else None,
|
||||
}
|
||||
yield f"data: {json.dumps(step_data)}\n\n"
|
||||
if delay_time > 0:
|
||||
await asyncio.sleep(delay_time)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/steps/{step_id}", name="get chat step", response_model=ChatStepOut)
|
||||
async def get_chat_step(step_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
chat_step = session.get(ChatStep, step_id)
|
||||
if not chat_step:
|
||||
raise HTTPException(status_code=404, detail=_("Chat step not found"))
|
||||
return chat_step
|
||||
|
||||
|
||||
@router.post("/steps", name="create chat step")
|
||||
# TODO Limit request sources
|
||||
async def create_chat_step(step: ChatStepIn, session: Session = Depends(session)):
|
||||
chat_step = ChatStep(
|
||||
task_id=step.task_id,
|
||||
step=step.step,
|
||||
data=step.data,
|
||||
)
|
||||
session.add(chat_step)
|
||||
session.commit()
|
||||
session.refresh(chat_step)
|
||||
return {"code": 200, "msg": "success"}
|
||||
|
||||
|
||||
@router.put("/steps/{step_id}", name="update chat step", response_model=ChatStepOut)
|
||||
async def update_chat_step(
|
||||
step_id: int, chat_step_update: ChatStep, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
db_chat_step = session.get(ChatStep, step_id)
|
||||
if not db_chat_step:
|
||||
raise HTTPException(status_code=404, detail=_("Chat step not found"))
|
||||
for key, value in chat_step_update.dict(exclude_unset=True).items():
|
||||
setattr(db_chat_step, key, value)
|
||||
session.add(db_chat_step)
|
||||
session.commit()
|
||||
session.refresh(db_chat_step)
|
||||
return db_chat_step
|
||||
|
||||
|
||||
@router.delete("/steps/{step_id}", name="delete chat step")
|
||||
async def delete_chat_step(step_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
db_chat_step = session.get(ChatStep, step_id)
|
||||
if not db_chat_step:
|
||||
raise HTTPException(status_code=404, detail=_("Chat step not found"))
|
||||
session.delete(db_chat_step)
|
||||
session.commit()
|
||||
return Response(status_code=204)
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session, asc, select
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from app.model.chat.chat_step import ChatStep, ChatStepOut, ChatStepIn
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_chat_step")
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["Chat Step Management"])
|
||||
|
||||
|
||||
@router.get("/steps", name="list chat steps", response_model=List[ChatStepOut])
|
||||
@traceroot.trace()
|
||||
async def list_chat_steps(
|
||||
task_id: str, step: Optional[str] = None, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""List chat steps for a task with optional step type filtering."""
|
||||
user_id = auth.user.id
|
||||
query = select(ChatStep)
|
||||
if task_id is not None:
|
||||
query = query.where(ChatStep.task_id == task_id)
|
||||
if step is not None:
|
||||
query = query.where(ChatStep.step == step)
|
||||
|
||||
chat_steps = session.exec(query).all()
|
||||
logger.debug("Chat steps listed", extra={"user_id": user_id, "task_id": task_id, "step_type": step, "count": len(chat_steps)})
|
||||
return chat_steps
|
||||
|
||||
|
||||
@router.get("/steps/playback/{task_id}", name="Playback Chat Step via SSE")
|
||||
@traceroot.trace()
|
||||
async def share_playback(
|
||||
task_id: str, delay_time: float = 0, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""Playback chat steps via SSE stream."""
|
||||
user_id = auth.user.id
|
||||
if delay_time > 5:
|
||||
logger.debug("Delay time capped", extra={"user_id": user_id, "task_id": task_id, "requested": delay_time, "capped": 5})
|
||||
delay_time = 5
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
stmt = select(ChatStep).where(ChatStep.task_id == task_id).order_by(asc(ChatStep.id))
|
||||
steps = session.exec(stmt).all()
|
||||
|
||||
if not steps:
|
||||
logger.warning("No steps found for playback", extra={"user_id": user_id, "task_id": task_id})
|
||||
yield f"data: {json.dumps({'error': 'No steps found for this task.'})}\n\n"
|
||||
return
|
||||
|
||||
logger.info("Chat step playback started", extra={"user_id": user_id, "task_id": task_id, "step_count": len(steps), "delay_time": delay_time})
|
||||
|
||||
for step in steps:
|
||||
step_data = {
|
||||
"id": step.id,
|
||||
"task_id": step.task_id,
|
||||
"step": step.step,
|
||||
"data": step.data,
|
||||
"created_at": step.created_at.isoformat() if step.created_at else None,
|
||||
}
|
||||
yield f"data: {json.dumps(step_data)}\n\n"
|
||||
if delay_time > 0:
|
||||
await asyncio.sleep(delay_time)
|
||||
|
||||
|
||||
logger.info("Chat step playback completed", extra={"user_id": user_id, "task_id": task_id, "step_count": len(steps)})
|
||||
except Exception as e:
|
||||
logger.error("Chat step playback error", extra={"user_id": user_id, "task_id": task_id, "error": str(e)}, exc_info=True)
|
||||
yield f"data: {json.dumps({'error': 'Playback error occurred.'})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/steps/{step_id}", name="get chat step", response_model=ChatStepOut)
|
||||
@traceroot.trace()
|
||||
async def get_chat_step(step_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Get specific chat step."""
|
||||
user_id = auth.user.id
|
||||
chat_step = session.get(ChatStep, step_id)
|
||||
|
||||
if not chat_step:
|
||||
logger.warning("Chat step not found", extra={"user_id": user_id, "step_id": step_id})
|
||||
raise HTTPException(status_code=404, detail=_("Chat step not found"))
|
||||
|
||||
logger.debug("Chat step retrieved", extra={"user_id": user_id, "step_id": step_id, "task_id": chat_step.task_id})
|
||||
return chat_step
|
||||
|
||||
|
||||
@router.post("/steps", name="create chat step")
|
||||
@traceroot.trace()
|
||||
async def create_chat_step(step: ChatStepIn, session: Session = Depends(session)):
|
||||
"""Create new chat step. TODO: Implement request source validation."""
|
||||
try:
|
||||
chat_step = ChatStep(
|
||||
task_id=step.task_id,
|
||||
step=step.step,
|
||||
data=step.data,
|
||||
)
|
||||
session.add(chat_step)
|
||||
session.commit()
|
||||
session.refresh(chat_step)
|
||||
logger.info("Chat step created", extra={"step_id": chat_step.id, "task_id": step.task_id, "step_type": step.step})
|
||||
return {"code": 200, "msg": "success"}
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Chat step creation failed", extra={"task_id": step.task_id, "step_type": step.step, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/steps/{step_id}", name="update chat step", response_model=ChatStepOut)
|
||||
@traceroot.trace()
|
||||
async def update_chat_step(
|
||||
step_id: int, chat_step_update: ChatStep, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""Update chat step."""
|
||||
user_id = auth.user.id
|
||||
db_chat_step = session.get(ChatStep, step_id)
|
||||
|
||||
if not db_chat_step:
|
||||
logger.warning("Chat step not found for update", extra={"user_id": user_id, "step_id": step_id})
|
||||
raise HTTPException(status_code=404, detail=_("Chat step not found"))
|
||||
|
||||
try:
|
||||
update_data = chat_step_update.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_chat_step, key, value)
|
||||
session.add(db_chat_step)
|
||||
session.commit()
|
||||
session.refresh(db_chat_step)
|
||||
logger.info("Chat step updated", extra={"user_id": user_id, "step_id": step_id, "task_id": db_chat_step.task_id, "fields_updated": list(update_data.keys())})
|
||||
return db_chat_step
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Chat step update failed", extra={"user_id": user_id, "step_id": step_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/steps/{step_id}", name="delete chat step")
|
||||
@traceroot.trace()
|
||||
async def delete_chat_step(step_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Delete chat step."""
|
||||
user_id = auth.user.id
|
||||
db_chat_step = session.get(ChatStep, step_id)
|
||||
|
||||
if not db_chat_step:
|
||||
logger.warning("Chat step not found for deletion", extra={"user_id": user_id, "step_id": step_id})
|
||||
raise HTTPException(status_code=404, detail=_("Chat step not found"))
|
||||
|
||||
try:
|
||||
session.delete(db_chat_step)
|
||||
session.commit()
|
||||
logger.info("Chat step deleted", extra={"user_id": user_id, "step_id": step_id, "task_id": db_chat_step.task_id})
|
||||
return Response(status_code=204)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Chat step deletion failed", extra={"user_id": user_id, "step_id": step_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,121 +1,172 @@
|
|||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from sqlmodel import Session, select, or_
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from app.model.config.config import Config, ConfigCreate, ConfigUpdate, ConfigInfo, ConfigOut
|
||||
|
||||
router = APIRouter(tags=["Config Management"])
|
||||
|
||||
|
||||
@router.get("/configs", name="list configs", response_model=list[ConfigOut])
|
||||
async def list_configs(
|
||||
config_group: Optional[str] = None, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
query = select(Config)
|
||||
user_id = auth.user.id
|
||||
if user_id is not None:
|
||||
query = query.where(Config.user_id == user_id)
|
||||
if config_group is not None:
|
||||
query = query.where(Config.config_group == config_group)
|
||||
configs = session.exec(query).all()
|
||||
return configs
|
||||
|
||||
|
||||
@router.get("/configs/{config_id}", name="get config", response_model=ConfigOut)
|
||||
async def get_config(
|
||||
config_id: int,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
query = select(Config).where(Config.user_id == auth.user.id)
|
||||
|
||||
if config_id is not None:
|
||||
query = query.where(Config.id == config_id)
|
||||
|
||||
config = session.exec(query).first()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail=_("Configuration not found"))
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/configs", name="create config", response_model=ConfigOut)
|
||||
async def create_config(config: ConfigCreate, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
if not ConfigInfo.is_valid_env_var(config.config_group, config.config_name):
|
||||
raise HTTPException(status_code=400, detail=_("Config Name is valid"))
|
||||
|
||||
# Check if configuration already exists
|
||||
existing_config = session.exec(
|
||||
select(Config).where(Config.user_id == auth.user.id, Config.config_name == config.config_name)
|
||||
).first()
|
||||
|
||||
if existing_config:
|
||||
raise HTTPException(status_code=400, detail=_("Configuration already exists for this user"))
|
||||
|
||||
db_config = Config(
|
||||
user_id=auth.user.id,
|
||||
config_name=config.config_name,
|
||||
config_value=config.config_value,
|
||||
config_group=config.config_group,
|
||||
)
|
||||
session.add(db_config)
|
||||
session.commit()
|
||||
session.refresh(db_config)
|
||||
return db_config
|
||||
|
||||
|
||||
@router.put("/configs/{config_id}", name="update config", response_model=ConfigOut)
|
||||
async def update_config(
|
||||
config_id: int, config_update: ConfigUpdate, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
db_config = session.exec(select(Config).where(Config.id == config_id, Config.user_id == auth.user.id)).first()
|
||||
|
||||
if not db_config:
|
||||
raise HTTPException(status_code=404, detail=_("Configuration not found"))
|
||||
|
||||
# Check if configuration group is valid
|
||||
if not ConfigInfo.is_valid_env_var(config_update.config_group, config_update.config_name):
|
||||
raise HTTPException(status_code=400, detail=_("Invalid configuration group"))
|
||||
|
||||
# Check for conflicts with other configurations
|
||||
existing_config = session.exec(
|
||||
select(Config).where(
|
||||
Config.user_id == auth.user.id,
|
||||
Config.config_name == config_update.config_name,
|
||||
Config.id != config_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_config:
|
||||
raise HTTPException(status_code=400, detail=_("Configuration already exists for this user"))
|
||||
|
||||
db_config.config_name = config_update.config_name
|
||||
db_config.config_value = config_update.config_value
|
||||
|
||||
session.add(db_config)
|
||||
session.commit()
|
||||
session.refresh(db_config)
|
||||
return db_config
|
||||
|
||||
|
||||
@router.delete("/configs/{config_id}", name="delete config")
|
||||
async def delete_config(config_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
db_config = session.exec(select(Config).where(Config.id == config_id, Config.user_id == auth.user.id)).first()
|
||||
|
||||
if not db_config:
|
||||
raise HTTPException(status_code=404, detail=_("Configuration not found"))
|
||||
session.delete(db_config)
|
||||
session.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/config/info", name="get config info")
|
||||
async def get_config_info(
|
||||
show_all: bool = Query(False, description="Show all config info, including those with empty env_vars"),
|
||||
):
|
||||
configs = ConfigInfo.getinfo()
|
||||
if show_all:
|
||||
return configs
|
||||
return {k: v for k, v in configs.items() if v.get("env_vars") and len(v["env_vars"]) > 0}
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from sqlmodel import Session, select, or_
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from app.model.config.config import Config, ConfigCreate, ConfigUpdate, ConfigInfo, ConfigOut
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_config_controller")
|
||||
|
||||
router = APIRouter(tags=["Config Management"])
|
||||
|
||||
|
||||
@router.get("/configs", name="list configs", response_model=list[ConfigOut])
|
||||
@traceroot.trace()
|
||||
async def list_configs(
|
||||
config_group: Optional[str] = None, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""List user's configurations with optional group filtering."""
|
||||
user_id = auth.user.id
|
||||
query = select(Config).where(Config.user_id == user_id)
|
||||
|
||||
if config_group is not None:
|
||||
query = query.where(Config.config_group == config_group)
|
||||
|
||||
configs = session.exec(query).all()
|
||||
logger.debug("Configs listed", extra={"user_id": user_id, "config_group": config_group, "count": len(configs)})
|
||||
return configs
|
||||
|
||||
|
||||
@router.get("/configs/{config_id}", name="get config", response_model=ConfigOut)
|
||||
@traceroot.trace()
|
||||
async def get_config(
|
||||
config_id: int,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
query = select(Config).where(Config.user_id == auth.user.id)
|
||||
|
||||
if config_id is not None:
|
||||
query = query.where(Config.id == config_id)
|
||||
|
||||
config = session.exec(query).first()
|
||||
|
||||
if not config:
|
||||
logger.warning("Config not found")
|
||||
raise HTTPException(status_code=404, detail=_("Configuration not found"))
|
||||
|
||||
logger.debug("Config retrieved")
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/configs", name="create config", response_model=ConfigOut)
|
||||
@traceroot.trace()
|
||||
async def create_config(config: ConfigCreate, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Create new configuration."""
|
||||
user_id = auth.user.id
|
||||
|
||||
if not ConfigInfo.is_valid_env_var(config.config_group, config.config_name):
|
||||
logger.warning("Config validation failed", extra={"user_id": user_id, "config_group": config.config_group, "config_name": config.config_name})
|
||||
raise HTTPException(status_code=400, detail=_("Invalid config name or group"))
|
||||
|
||||
# Check if configuration already exists
|
||||
existing_config = session.exec(
|
||||
select(Config).where(Config.user_id == user_id, Config.config_name == config.config_name)
|
||||
).first()
|
||||
|
||||
if existing_config:
|
||||
logger.warning("Config creation failed: already exists", extra={"user_id": user_id, "config_name": config.config_name})
|
||||
raise HTTPException(status_code=400, detail=_("Configuration already exists for this user"))
|
||||
|
||||
try:
|
||||
db_config = Config(
|
||||
user_id=user_id,
|
||||
config_name=config.config_name,
|
||||
config_value=config.config_value,
|
||||
config_group=config.config_group,
|
||||
)
|
||||
session.add(db_config)
|
||||
session.commit()
|
||||
session.refresh(db_config)
|
||||
logger.info("Config created", extra={"user_id": user_id, "config_id": db_config.id, "config_group": config.config_group, "config_name": config.config_name})
|
||||
return db_config
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Config creation failed", extra={"user_id": user_id, "config_name": config.config_name, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/configs/{config_id}", name="update config", response_model=ConfigOut)
|
||||
@traceroot.trace()
|
||||
async def update_config(
|
||||
config_id: int, config_update: ConfigUpdate, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""Update configuration."""
|
||||
user_id = auth.user.id
|
||||
db_config = session.exec(select(Config).where(Config.id == config_id, Config.user_id == user_id)).first()
|
||||
|
||||
if not db_config:
|
||||
logger.warning("Config not found for update", extra={"user_id": user_id, "config_id": config_id})
|
||||
raise HTTPException(status_code=404, detail=_("Configuration not found"))
|
||||
|
||||
# Check if configuration group is valid
|
||||
if not ConfigInfo.is_valid_env_var(config_update.config_group, config_update.config_name):
|
||||
logger.warning("Config update validation failed", extra={"user_id": user_id, "config_id": config_id, "config_group": config_update.config_group})
|
||||
raise HTTPException(status_code=400, detail=_("Invalid configuration group"))
|
||||
|
||||
# Check for conflicts with other configurations
|
||||
existing_config = session.exec(
|
||||
select(Config).where(
|
||||
Config.user_id == user_id,
|
||||
Config.config_name == config_update.config_name,
|
||||
Config.id != config_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_config:
|
||||
logger.warning("Config update failed: duplicate name", extra={"user_id": user_id, "config_id": config_id, "config_name": config_update.config_name})
|
||||
raise HTTPException(status_code=400, detail=_("Configuration already exists for this user"))
|
||||
|
||||
try:
|
||||
db_config.config_name = config_update.config_name
|
||||
db_config.config_value = config_update.config_value
|
||||
db_config.config_group = config_update.config_group
|
||||
session.add(db_config)
|
||||
session.commit()
|
||||
session.refresh(db_config)
|
||||
logger.info("Config updated", extra={"user_id": user_id, "config_id": config_id, "config_group": config_update.config_group})
|
||||
return db_config
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Config update failed", extra={"user_id": user_id, "config_id": config_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/configs/{config_id}", name="delete config")
|
||||
@traceroot.trace()
|
||||
async def delete_config(config_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Delete configuration."""
|
||||
user_id = auth.user.id
|
||||
db_config = session.exec(select(Config).where(Config.id == config_id, Config.user_id == user_id)).first()
|
||||
|
||||
if not db_config:
|
||||
logger.warning("Config not found for deletion", extra={"user_id": user_id, "config_id": config_id})
|
||||
raise HTTPException(status_code=404, detail=_("Configuration not found"))
|
||||
|
||||
try:
|
||||
session.delete(db_config)
|
||||
session.commit()
|
||||
logger.info("Config deleted", extra={"user_id": user_id, "config_id": config_id, "config_name": db_config.config_name})
|
||||
return Response(status_code=204)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Config deletion failed", extra={"user_id": user_id, "config_id": config_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/config/info", name="get config info")
|
||||
@traceroot.trace()
|
||||
async def get_config_info(
|
||||
show_all: bool = Query(False, description="Show all config info, including those with empty env_vars"),
|
||||
):
|
||||
"""Get available configuration templates and info."""
|
||||
configs = ConfigInfo.getinfo()
|
||||
if show_all:
|
||||
logger.debug("Config info retrieved", extra={"show_all": True, "count": len(configs)})
|
||||
return configs
|
||||
|
||||
filtered = {k: v for k, v in configs.items() if v.get("env_vars") and len(v["env_vars"]) > 0}
|
||||
logger.debug("Config info retrieved", extra={"show_all": False, "total_count": len(configs), "filtered_count": len(filtered)})
|
||||
return filtered
|
||||
15
server/app/controller/health_controller.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
|
||||
|
||||
@router.get("/health", name="health check", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""Health check endpoint for monitoring and container orchestration."""
|
||||
return HealthResponse(status="ok", service="eigent-server")
|
||||
|
|
@ -1,214 +1,262 @@
|
|||
import os
|
||||
from typing import Dict
|
||||
from fastapi import Depends, HTTPException, APIRouter
|
||||
from fastapi_babel import _
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
from sqlmodel import Session, col, select
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from app.model.mcp.mcp import Mcp, McpOut, McpType
|
||||
from app.model.mcp.mcp_env import McpEnv, Status as McpEnvStatus
|
||||
from app.model.mcp.mcp_user import McpImportType, McpUser, Status
|
||||
from loguru import logger
|
||||
from camel.toolkits.mcp_toolkit import MCPToolkit
|
||||
from app.component.environment import env
|
||||
|
||||
from app.component.validator.McpServer import (
|
||||
McpRemoteServer,
|
||||
McpServerItem,
|
||||
validate_mcp_remote_servers,
|
||||
validate_mcp_servers,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Mcp Servers"])
|
||||
|
||||
|
||||
async def pre_instantiate_mcp_toolkit(config_dict: dict) -> bool:
|
||||
"""
|
||||
Pre-instantiate MCP toolkit to complete authentication process
|
||||
|
||||
Args:
|
||||
config_dict: MCP server configuration dictionary
|
||||
|
||||
Returns:
|
||||
bool: Whether successfully instantiated and connected
|
||||
"""
|
||||
try:
|
||||
# Ensure unified auth directory for all mcp servers
|
||||
for server_config in config_dict.get("mcpServers", {}).values():
|
||||
if "env" not in server_config:
|
||||
server_config["env"] = {}
|
||||
# Set global auth directory to persist authentication across tasks
|
||||
if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]:
|
||||
server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env(
|
||||
"MCP_REMOTE_CONFIG_DIR",
|
||||
os.path.expanduser("~/.mcp-auth")
|
||||
)
|
||||
|
||||
# Create MCP toolkit and attempt to connect
|
||||
mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=30)
|
||||
await mcp_toolkit.connect()
|
||||
|
||||
# Get tools list to ensure connection is successful
|
||||
tools = mcp_toolkit.get_tools()
|
||||
logger.info(f"Successfully pre-instantiated MCP toolkit with {len(tools)} tools")
|
||||
|
||||
# Disconnect, authentication info is already saved
|
||||
await mcp_toolkit.disconnect()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to pre-instantiate MCP toolkit: {e!r}")
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/mcps", name="mcp list")
|
||||
async def gets(
|
||||
keyword: str | None = None,
|
||||
category_id: int | None = None,
|
||||
mine: int | None = None,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
) -> Page[McpOut]:
|
||||
stmt = (
|
||||
select(Mcp)
|
||||
.where(Mcp.no_delete())
|
||||
.options(
|
||||
selectinload(Mcp.category),
|
||||
selectinload(Mcp.envs),
|
||||
with_loader_criteria(McpEnv, col(McpEnv.status) == McpEnvStatus.in_use),
|
||||
)
|
||||
# .order_by(col(Mcp.sort).desc())
|
||||
)
|
||||
if keyword:
|
||||
stmt = stmt.where(col(Mcp.key).like(f"%{keyword.lower()}%"))
|
||||
if category_id:
|
||||
stmt = stmt.where(Mcp.category_id == category_id)
|
||||
if mine and auth:
|
||||
stmt = (
|
||||
stmt.join(McpUser)
|
||||
.where(McpUser.user_id == auth.user.id)
|
||||
.options(
|
||||
selectinload(Mcp.mcp_user),
|
||||
with_loader_criteria(McpUser, col(McpUser.user_id) == auth.user.id),
|
||||
)
|
||||
)
|
||||
return paginate(session, stmt)
|
||||
|
||||
|
||||
@router.get("/mcp", name="mcp detail", response_model=McpOut)
|
||||
async def get(id: int, session: Session = Depends(session)):
|
||||
stmt = select(Mcp).where(Mcp.no_delete(), Mcp.id == id).options(selectinload(Mcp.category), selectinload(Mcp.envs))
|
||||
model = session.exec(stmt).one()
|
||||
return model
|
||||
|
||||
|
||||
@router.post("/mcp/install", name="mcp install")
|
||||
async def install(mcp_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
mcp = session.get_one(Mcp, mcp_id)
|
||||
if not mcp:
|
||||
raise HTTPException(status_code=404, detail=_("Mcp not found"))
|
||||
exists = session.exec(select(McpUser).where(McpUser.mcp_id == mcp.id, McpUser.user_id == auth.user.id)).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail=_("mcp is installed"))
|
||||
|
||||
install_command: dict = mcp.install_command
|
||||
|
||||
# Pre-instantiate MCP toolkit for authentication
|
||||
config_dict = {
|
||||
"mcpServers": {
|
||||
mcp.key: install_command
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success = await pre_instantiate_mcp_toolkit(config_dict)
|
||||
if not success:
|
||||
logger.warning(f"Pre-instantiation failed for MCP {mcp.key}, but continuing with installation")
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception during pre-instantiation for MCP {mcp.key}: {e}")
|
||||
|
||||
mcp_user = McpUser(
|
||||
mcp_id=mcp.id,
|
||||
user_id=auth.user.id,
|
||||
mcp_name=mcp.name,
|
||||
mcp_key=mcp.key,
|
||||
mcp_desc=mcp.description,
|
||||
type=mcp.type,
|
||||
status=Status.enable,
|
||||
command=install_command["command"],
|
||||
args=install_command["args"],
|
||||
env=install_command["env"],
|
||||
server_url=None,
|
||||
)
|
||||
mcp_user.save()
|
||||
return mcp_user
|
||||
|
||||
|
||||
@router.post("/mcp/import/{mcp_type}", name="mcp import")
|
||||
async def import_mcp(
|
||||
mcp_type: McpImportType, mcp_data: dict, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
logger.debug(mcp_type, mcp_type.value)
|
||||
|
||||
if mcp_type == McpImportType.Local:
|
||||
is_valid, res = validate_mcp_servers(mcp_data)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=res)
|
||||
mcp_data: Dict[str, McpServerItem] = res.mcpServers
|
||||
|
||||
for name, data in mcp_data.items():
|
||||
# Pre-instantiate MCP toolkit for authentication
|
||||
config_dict = {
|
||||
"mcpServers": {
|
||||
name: {
|
||||
"command": data.command,
|
||||
"args": data.args,
|
||||
"env": data.env or {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success = await pre_instantiate_mcp_toolkit(config_dict)
|
||||
if not success:
|
||||
logger.warning(f"Pre-instantiation failed for local MCP {name}, but continuing with installation")
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception during pre-instantiation for local MCP {name}: {e}")
|
||||
|
||||
mcp_user = McpUser(
|
||||
mcp_id=0,
|
||||
user_id=auth.user.id,
|
||||
mcp_name=name,
|
||||
mcp_key=name,
|
||||
mcp_desc=name,
|
||||
type=McpType.Local,
|
||||
status=Status.enable,
|
||||
command=data.command,
|
||||
args=data.args,
|
||||
env=data.env,
|
||||
server_url=None,
|
||||
)
|
||||
mcp_user.save()
|
||||
return {"message": "Local MCP servers imported successfully", "count": len(mcp_data)}
|
||||
elif mcp_type == McpImportType.Remote:
|
||||
is_valid, res = validate_mcp_remote_servers(mcp_data)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=res)
|
||||
data: McpRemoteServer = res
|
||||
|
||||
# For remote servers, we don't need to pre-instantiate as they typically don't require authentication
|
||||
# but we can still try to validate the connection if needed
|
||||
|
||||
mcp_user = McpUser(
|
||||
mcp_id=0,
|
||||
user_id=auth.user.id,
|
||||
type=McpType.Remote,
|
||||
status=Status.enable,
|
||||
mcp_name=data.server_name,
|
||||
server_url=data.server_url,
|
||||
)
|
||||
mcp_user.save()
|
||||
return mcp_user
|
||||
import os
|
||||
from typing import Dict
|
||||
from fastapi import Depends, HTTPException, APIRouter
|
||||
from fastapi_babel import _
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
from sqlmodel import Session, col, select
|
||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from app.model.mcp.mcp import Mcp, McpOut, McpType
|
||||
from app.model.mcp.mcp_env import McpEnv, Status as McpEnvStatus
|
||||
from app.model.mcp.mcp_user import McpImportType, McpUser, Status
|
||||
from camel.toolkits.mcp_toolkit import MCPToolkit
|
||||
from app.component.environment import env
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_mcp_controller")
|
||||
|
||||
from app.component.validator.McpServer import (
|
||||
McpRemoteServer,
|
||||
McpServerItem,
|
||||
validate_mcp_remote_servers,
|
||||
validate_mcp_servers,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Mcp Servers"])
|
||||
|
||||
|
||||
async def pre_instantiate_mcp_toolkit(config_dict: dict) -> bool:
|
||||
"""
|
||||
Pre-instantiate MCP toolkit to complete authentication process
|
||||
|
||||
Args:
|
||||
config_dict: MCP server configuration dictionary
|
||||
|
||||
Returns:
|
||||
bool: Whether successfully instantiated and connected
|
||||
"""
|
||||
try:
|
||||
# Ensure unified auth directory for all mcp servers
|
||||
for server_config in config_dict.get("mcpServers", {}).values():
|
||||
if "env" not in server_config:
|
||||
server_config["env"] = {}
|
||||
# Set global auth directory to persist authentication across tasks
|
||||
if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]:
|
||||
server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env(
|
||||
"MCP_REMOTE_CONFIG_DIR",
|
||||
os.path.expanduser("~/.mcp-auth")
|
||||
)
|
||||
|
||||
# Create MCP toolkit and attempt to connect
|
||||
mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=30)
|
||||
await mcp_toolkit.connect()
|
||||
|
||||
# Get tools list to ensure connection is successful
|
||||
tools = mcp_toolkit.get_tools()
|
||||
logger.info("MCP toolkit pre-instantiated", extra={"tools_count": len(tools)})
|
||||
|
||||
# Disconnect, authentication info is already saved
|
||||
await mcp_toolkit.disconnect()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MCP toolkit pre-instantiation failed", extra={"error": str(e)}, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/mcps", name="mcp list")
|
||||
@traceroot.trace()
|
||||
async def gets(
|
||||
keyword: str | None = None,
|
||||
category_id: int | None = None,
|
||||
mine: int | None = None,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
) -> Page[McpOut]:
|
||||
"""List MCP servers with optional filtering."""
|
||||
user_id = auth.user.id
|
||||
stmt = (
|
||||
select(Mcp)
|
||||
.where(Mcp.no_delete())
|
||||
.options(
|
||||
selectinload(Mcp.category),
|
||||
selectinload(Mcp.envs),
|
||||
with_loader_criteria(McpEnv, col(McpEnv.status) == McpEnvStatus.in_use),
|
||||
)
|
||||
)
|
||||
if keyword:
|
||||
stmt = stmt.where(col(Mcp.key).like(f"%{keyword.lower()}%"))
|
||||
if category_id:
|
||||
stmt = stmt.where(Mcp.category_id == category_id)
|
||||
if mine and auth:
|
||||
stmt = (
|
||||
stmt.join(McpUser)
|
||||
.where(McpUser.user_id == user_id)
|
||||
.options(
|
||||
selectinload(Mcp.mcp_user),
|
||||
with_loader_criteria(McpUser, col(McpUser.user_id) == user_id),
|
||||
)
|
||||
)
|
||||
|
||||
result = paginate(session, stmt)
|
||||
total = result.total if hasattr(result, 'total') else 0
|
||||
logger.debug("MCP list retrieved", extra={"user_id": user_id, "keyword": keyword, "category_id": category_id, "mine": mine, "total": total})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/mcp", name="mcp detail", response_model=McpOut)
|
||||
@traceroot.trace()
|
||||
async def get(id: int, session: Session = Depends(session)):
|
||||
"""Get MCP server details."""
|
||||
try:
|
||||
stmt = select(Mcp).where(Mcp.no_delete(), Mcp.id == id).options(selectinload(Mcp.category), selectinload(Mcp.envs))
|
||||
model = session.exec(stmt).one()
|
||||
logger.debug("MCP detail retrieved", extra={"mcp_id": id, "mcp_key": model.key})
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.warning("MCP not found", extra={"mcp_id": id})
|
||||
raise HTTPException(status_code=404, detail=_("Mcp not found"))
|
||||
|
||||
|
||||
@router.post("/mcp/install", name="mcp install")
|
||||
@traceroot.trace()
|
||||
async def install(mcp_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Install MCP server for user."""
|
||||
user_id = auth.user.id
|
||||
|
||||
mcp = session.get_one(Mcp, mcp_id)
|
||||
if not mcp:
|
||||
logger.warning("MCP install failed: MCP not found", extra={"user_id": user_id, "mcp_id": mcp_id})
|
||||
raise HTTPException(status_code=404, detail=_("Mcp not found"))
|
||||
|
||||
exists = session.exec(select(McpUser).where(McpUser.mcp_id == mcp.id, McpUser.user_id == user_id)).first()
|
||||
if exists:
|
||||
logger.warning("MCP install failed: already installed", extra={"user_id": user_id, "mcp_id": mcp_id, "mcp_key": mcp.key})
|
||||
raise HTTPException(status_code=400, detail=_("mcp is installed"))
|
||||
|
||||
install_command: dict = mcp.install_command
|
||||
|
||||
# Pre-instantiate MCP toolkit for authentication
|
||||
config_dict = {
|
||||
"mcpServers": {
|
||||
mcp.key: install_command
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success = await pre_instantiate_mcp_toolkit(config_dict)
|
||||
if not success:
|
||||
logger.warning("MCP pre-instantiation failed, continuing with installation", extra={"user_id": user_id, "mcp_id": mcp_id, "mcp_key": mcp.key})
|
||||
else:
|
||||
logger.debug("MCP toolkit pre-instantiated", extra={"mcp_key": mcp.key})
|
||||
except Exception as e:
|
||||
logger.warning("MCP pre-instantiation exception", extra={"user_id": user_id, "mcp_key": mcp.key, "error": str(e)}, exc_info=True)
|
||||
|
||||
try:
|
||||
mcp_user = McpUser(
|
||||
mcp_id=mcp.id,
|
||||
user_id=user_id,
|
||||
mcp_name=mcp.name,
|
||||
mcp_key=mcp.key,
|
||||
mcp_desc=mcp.description,
|
||||
type=mcp.type,
|
||||
status=Status.enable,
|
||||
command=install_command["command"],
|
||||
args=install_command["args"],
|
||||
env=install_command["env"],
|
||||
server_url=None,
|
||||
)
|
||||
mcp_user.save()
|
||||
logger.info("MCP installed", extra={"user_id": user_id, "mcp_id": mcp_id, "mcp_key": mcp.key})
|
||||
return mcp_user
|
||||
except Exception as e:
|
||||
logger.error("MCP installation failed", extra={"user_id": user_id, "mcp_id": mcp_id, "mcp_key": mcp.key, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/mcp/import/{mcp_type}", name="mcp import")
|
||||
@traceroot.trace()
|
||||
async def import_mcp(
|
||||
mcp_type: McpImportType, mcp_data: dict, session: Session = Depends(session), auth: Auth = Depends(auth_must)
|
||||
):
|
||||
"""Import MCP servers (local or remote)."""
|
||||
user_id = auth.user.id
|
||||
|
||||
if mcp_type == McpImportType.Local:
|
||||
logger.info("Importing local MCP servers", extra={"user_id": user_id})
|
||||
is_valid, res = validate_mcp_servers(mcp_data)
|
||||
if not is_valid:
|
||||
logger.warning("Local MCP import validation failed", extra={"user_id": user_id, "error": res})
|
||||
raise HTTPException(status_code=400, detail=res)
|
||||
|
||||
mcp_data: Dict[str, McpServerItem] = res.mcpServers
|
||||
imported_count = 0
|
||||
|
||||
for name, data in mcp_data.items():
|
||||
config_dict = {
|
||||
"mcpServers": {
|
||||
name: {
|
||||
"command": data.command,
|
||||
"args": data.args,
|
||||
"env": data.env or {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success = await pre_instantiate_mcp_toolkit(config_dict)
|
||||
if not success:
|
||||
logger.warning("Local MCP pre-instantiation failed, continuing", extra={"user_id": user_id, "mcp_name": name})
|
||||
except Exception as e:
|
||||
logger.warning("Local MCP pre-instantiation exception", extra={"user_id": user_id, "mcp_name": name, "error": str(e)})
|
||||
|
||||
try:
|
||||
mcp_user = McpUser(
|
||||
mcp_id=0,
|
||||
user_id=user_id,
|
||||
mcp_name=name,
|
||||
mcp_key=name,
|
||||
mcp_desc=name,
|
||||
type=McpType.Local,
|
||||
status=Status.enable,
|
||||
command=data.command,
|
||||
args=data.args,
|
||||
env=data.env,
|
||||
server_url=None,
|
||||
)
|
||||
mcp_user.save()
|
||||
imported_count += 1
|
||||
except Exception as e:
|
||||
logger.error("Failed to import local MCP", extra={"user_id": user_id, "mcp_name": name, "error": str(e)}, exc_info=True)
|
||||
|
||||
logger.info("Local MCPs imported", extra={"user_id": user_id, "count": imported_count})
|
||||
return {"message": "Local MCP servers imported successfully", "count": imported_count}
|
||||
|
||||
elif mcp_type == McpImportType.Remote:
|
||||
logger.info("Importing remote MCP server", extra={"user_id": user_id})
|
||||
is_valid, res = validate_mcp_remote_servers(mcp_data)
|
||||
if not is_valid:
|
||||
logger.warning("Remote MCP import validation failed", extra={"user_id": user_id, "error": res})
|
||||
raise HTTPException(status_code=400, detail=res)
|
||||
|
||||
data: McpRemoteServer = res
|
||||
|
||||
try:
|
||||
# For remote servers, we don't need to pre-instantiate as they typically don't require authentication
|
||||
# but we can still try to validate the connection if needed
|
||||
mcp_user = McpUser(
|
||||
mcp_id=0,
|
||||
user_id=user_id,
|
||||
type=McpType.Remote,
|
||||
status=Status.enable,
|
||||
mcp_name=data.server_name,
|
||||
server_url=data.server_url,
|
||||
)
|
||||
mcp_user.save()
|
||||
logger.info("Remote MCP imported", extra={"user_id": user_id, "server_name": data.server_name, "server_url": data.server_url})
|
||||
return mcp_user
|
||||
except Exception as e:
|
||||
logger.error("Remote MCP import failed", extra={"user_id": user_id, "server_name": data.server_name, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,173 +1,196 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from exa_py import Exa
|
||||
from loguru import logger
|
||||
from app.component.auth import key_must
|
||||
from app.component.environment import env_not_empty
|
||||
from app.model.mcp.proxy import ExaSearch
|
||||
from typing import Any, cast
|
||||
import requests
|
||||
|
||||
from app.model.user.key import Key
|
||||
|
||||
|
||||
router = APIRouter(prefix="/proxy", tags=["Mcp Servers"])
|
||||
|
||||
|
||||
@router.post("/exa")
|
||||
def exa_search(search: ExaSearch, key: Key = Depends(key_must)):
|
||||
EXA_API_KEY = env_not_empty("EXA_API_KEY")
|
||||
try:
|
||||
exa = Exa(EXA_API_KEY)
|
||||
|
||||
if search.num_results is not None and not 0 < search.num_results <= 100:
|
||||
raise ValueError("num_results must be between 1 and 100")
|
||||
|
||||
if search.include_text is not None:
|
||||
if len(search.include_text) > 1:
|
||||
raise ValueError("include_text can only contain 1 string")
|
||||
if len(search.include_text[0].split()) > 5:
|
||||
raise ValueError("include_text string cannot be longer than 5 words")
|
||||
|
||||
if search.exclude_text is not None:
|
||||
if len(search.exclude_text) > 1:
|
||||
raise ValueError("exclude_text can only contain 1 string")
|
||||
if len(search.exclude_text[0].split()) > 5:
|
||||
raise ValueError("exclude_text string cannot be longer than 5 words")
|
||||
|
||||
# Call Exa API with direct parameters
|
||||
if search.text:
|
||||
results = cast(
|
||||
dict[str, Any],
|
||||
exa.search_and_contents(
|
||||
query=search.query,
|
||||
type=search.search_type,
|
||||
category=search.category,
|
||||
num_results=search.num_results,
|
||||
include_text=search.include_text,
|
||||
exclude_text=search.exclude_text,
|
||||
use_autoprompt=search.use_autoprompt,
|
||||
text=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
results = cast(
|
||||
dict[str, Any],
|
||||
exa.search(
|
||||
query=search.query,
|
||||
type=search.search_type,
|
||||
category=search.category,
|
||||
num_results=search.num_results,
|
||||
include_text=search.include_text,
|
||||
exclude_text=search.exclude_text,
|
||||
use_autoprompt=search.use_autoprompt,
|
||||
),
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Exa search failed: {e!s}"}
|
||||
|
||||
|
||||
@router.get("/google")
|
||||
def google_search(query: str, search_type: str = "web", key: Key = Depends(key_must)):
|
||||
# https://developers.google.com/custom-search/v1/overview
|
||||
GOOGLE_API_KEY = env_not_empty("GOOGLE_API_KEY")
|
||||
# https://cse.google.com/cse/all
|
||||
SEARCH_ENGINE_ID = env_not_empty("SEARCH_ENGINE_ID")
|
||||
|
||||
# Using the first page
|
||||
start_page_idx = 1
|
||||
# Different language may get different result
|
||||
search_language = "en"
|
||||
# How many pages to return
|
||||
num_result_pages = 10
|
||||
# Constructing the URL
|
||||
# Doc: https://developers.google.com/custom-search/v1/using_rest
|
||||
base_url = (
|
||||
f"https://www.googleapis.com/customsearch/v1?"
|
||||
f"key={GOOGLE_API_KEY}&cx={SEARCH_ENGINE_ID}&q={query}&start="
|
||||
f"{start_page_idx}&lr={search_language}&num={num_result_pages}"
|
||||
)
|
||||
|
||||
if search_type == "image":
|
||||
url = base_url + "&searchType=image"
|
||||
else:
|
||||
url = base_url
|
||||
|
||||
responses = []
|
||||
# Fetch the results given the URL
|
||||
try:
|
||||
# Make the get
|
||||
result = requests.get(url)
|
||||
data = result.json()
|
||||
|
||||
# Get the result items
|
||||
if "items" in data:
|
||||
search_items = data.get("items")
|
||||
|
||||
# Iterate over results found
|
||||
for i, search_item in enumerate(search_items, start=1):
|
||||
if search_type == "image":
|
||||
# Process image search results
|
||||
title = search_item.get("title")
|
||||
image_url = search_item.get("link")
|
||||
display_link = search_item.get("displayLink")
|
||||
|
||||
# Get context URL (page containing the image)
|
||||
image_info = search_item.get("image", {})
|
||||
context_url = image_info.get("contextLink", "")
|
||||
|
||||
# Get image dimensions if available
|
||||
width = image_info.get("width")
|
||||
height = image_info.get("height")
|
||||
|
||||
response = {
|
||||
"result_id": i,
|
||||
"title": title,
|
||||
"image_url": image_url,
|
||||
"display_link": display_link,
|
||||
"context_url": context_url,
|
||||
}
|
||||
|
||||
# Add dimensions if available
|
||||
if width:
|
||||
response["width"] = int(width)
|
||||
if height:
|
||||
response["height"] = int(height)
|
||||
|
||||
responses.append(response)
|
||||
else:
|
||||
# Process web search results (existing logic)
|
||||
# Check metatags are present
|
||||
if "pagemap" not in search_item:
|
||||
continue
|
||||
if "metatags" not in search_item["pagemap"]:
|
||||
continue
|
||||
if "og:description" in search_item["pagemap"]["metatags"][0]:
|
||||
long_description = search_item["pagemap"]["metatags"][0]["og:description"]
|
||||
else:
|
||||
long_description = "N/A"
|
||||
# Get the page title
|
||||
title = search_item.get("title")
|
||||
# Page snippet
|
||||
snippet = search_item.get("snippet")
|
||||
|
||||
# Extract the page url
|
||||
link = search_item.get("link")
|
||||
response = {
|
||||
"result_id": i,
|
||||
"title": title,
|
||||
"description": snippet,
|
||||
"long_description": long_description,
|
||||
"url": link,
|
||||
}
|
||||
responses.append(response)
|
||||
else:
|
||||
error_info = data.get("error", {})
|
||||
logger.error(f"Google search failed - API response: {error_info}")
|
||||
responses.append({"error": f"Google search failed - API response: {error_info}"})
|
||||
|
||||
except Exception as e:
|
||||
responses.append({"error": f"google search failed: {e!s}"})
|
||||
return responses
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from exa_py import Exa
|
||||
from app.component.auth import key_must
|
||||
from app.component.environment import env_not_empty
|
||||
from app.model.mcp.proxy import ExaSearch
|
||||
from typing import Any, cast
|
||||
import requests
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_proxy_controller")
|
||||
|
||||
from app.model.user.key import Key
|
||||
|
||||
|
||||
router = APIRouter(prefix="/proxy", tags=["Mcp Servers"])
|
||||
|
||||
|
||||
@router.post("/exa")
|
||||
@traceroot.trace()
|
||||
def exa_search(search: ExaSearch, key: Key = Depends(key_must)):
|
||||
"""Search using Exa API."""
|
||||
EXA_API_KEY = env_not_empty("EXA_API_KEY")
|
||||
try:
|
||||
# Validate input parameters
|
||||
if search.num_results is not None and not 0 < search.num_results <= 100:
|
||||
logger.warning("Invalid exa search parameter", extra={"param": "num_results", "value": search.num_results})
|
||||
raise ValueError("num_results must be between 1 and 100")
|
||||
|
||||
if search.include_text is not None and len(search.include_text) > 0:
|
||||
if len(search.include_text) > 1:
|
||||
logger.warning("Invalid exa search parameter", extra={"param": "include_text", "reason": "more than 1 string"})
|
||||
raise ValueError("include_text can only contain 1 string")
|
||||
if len(search.include_text[0].split()) > 5:
|
||||
logger.warning("Invalid exa search parameter", extra={"param": "include_text", "reason": "exceeds 5 words"})
|
||||
raise ValueError("include_text string cannot be longer than 5 words")
|
||||
|
||||
if search.exclude_text is not None and len(search.exclude_text) > 0:
|
||||
if len(search.exclude_text) > 1:
|
||||
logger.warning("Invalid exa search parameter", extra={"param": "exclude_text", "reason": "more than 1 string"})
|
||||
raise ValueError("exclude_text can only contain 1 string")
|
||||
if len(search.exclude_text[0].split()) > 5:
|
||||
logger.warning("Invalid exa search parameter", extra={"param": "exclude_text", "reason": "exceeds 5 words"})
|
||||
raise ValueError("exclude_text string cannot be longer than 5 words")
|
||||
|
||||
exa = Exa(EXA_API_KEY)
|
||||
|
||||
# Call Exa API with direct parameters
|
||||
if search.text:
|
||||
results = cast(
|
||||
dict[str, Any],
|
||||
exa.search_and_contents(
|
||||
query=search.query,
|
||||
type=search.search_type,
|
||||
category=search.category,
|
||||
num_results=search.num_results,
|
||||
include_text=search.include_text,
|
||||
exclude_text=search.exclude_text,
|
||||
use_autoprompt=search.use_autoprompt,
|
||||
text=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
results = cast(
|
||||
dict[str, Any],
|
||||
exa.search(
|
||||
query=search.query,
|
||||
type=search.search_type,
|
||||
category=search.category,
|
||||
num_results=search.num_results,
|
||||
include_text=search.include_text,
|
||||
exclude_text=search.exclude_text,
|
||||
use_autoprompt=search.use_autoprompt,
|
||||
),
|
||||
)
|
||||
|
||||
result_count = len(results.get("results", [])) if "results" in results else 0
|
||||
logger.info("Exa search completed", extra={"query": search.query, "search_type": search.search_type, "result_count": result_count})
|
||||
return results
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Exa search validation error", extra={"error": str(e)})
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
except Exception as e:
|
||||
logger.error("Exa search failed", extra={"query": search.query, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/google")
|
||||
@traceroot.trace()
|
||||
def google_search(query: str, search_type: str = "web", key: Key = Depends(key_must)):
|
||||
"""Search using Google Custom Search API."""
|
||||
# https://developers.google.com/custom-search/v1/overview
|
||||
GOOGLE_API_KEY = env_not_empty("GOOGLE_API_KEY")
|
||||
# https://cse.google.com/cse/all
|
||||
SEARCH_ENGINE_ID = env_not_empty("SEARCH_ENGINE_ID")
|
||||
|
||||
# Using the first page
|
||||
start_page_idx = 1
|
||||
# Different language may get different result
|
||||
search_language = "en"
|
||||
# How many pages to return
|
||||
num_result_pages = 10
|
||||
|
||||
# Constructing the URL
|
||||
# Doc: https://developers.google.com/custom-search/v1/using_rest
|
||||
base_url = (
|
||||
f"https://www.googleapis.com/customsearch/v1?"
|
||||
f"key={GOOGLE_API_KEY}&cx={SEARCH_ENGINE_ID}&q={query}&start="
|
||||
f"{start_page_idx}&lr={search_language}&num={num_result_pages}"
|
||||
)
|
||||
|
||||
if search_type == "image":
|
||||
url = base_url + "&searchType=image"
|
||||
else:
|
||||
url = base_url
|
||||
|
||||
responses = []
|
||||
|
||||
try:
|
||||
# Make the GET request
|
||||
result = requests.get(url)
|
||||
data = result.json()
|
||||
|
||||
# Get the result items
|
||||
if "items" in data:
|
||||
search_items = data.get("items")
|
||||
|
||||
# Iterate over results found
|
||||
for i, search_item in enumerate(search_items, start=1):
|
||||
if search_type == "image":
|
||||
# Process image search results
|
||||
title = search_item.get("title")
|
||||
image_url = search_item.get("link")
|
||||
display_link = search_item.get("displayLink")
|
||||
|
||||
# Get context URL (page containing the image)
|
||||
image_info = search_item.get("image", {})
|
||||
context_url = image_info.get("contextLink", "")
|
||||
|
||||
# Get image dimensions if available
|
||||
width = image_info.get("width")
|
||||
height = image_info.get("height")
|
||||
|
||||
response = {
|
||||
"result_id": i,
|
||||
"title": title,
|
||||
"image_url": image_url,
|
||||
"display_link": display_link,
|
||||
"context_url": context_url,
|
||||
}
|
||||
|
||||
# Add dimensions if available
|
||||
if width:
|
||||
response["width"] = int(width)
|
||||
if height:
|
||||
response["height"] = int(height)
|
||||
|
||||
responses.append(response)
|
||||
else:
|
||||
# Process web search results
|
||||
# Check metatags are present
|
||||
if "pagemap" not in search_item:
|
||||
continue
|
||||
if "metatags" not in search_item["pagemap"]:
|
||||
continue
|
||||
if "og:description" in search_item["pagemap"]["metatags"][0]:
|
||||
long_description = search_item["pagemap"]["metatags"][0]["og:description"]
|
||||
else:
|
||||
long_description = "N/A"
|
||||
# Get the page title
|
||||
title = search_item.get("title")
|
||||
# Page snippet
|
||||
snippet = search_item.get("snippet")
|
||||
|
||||
# Extract the page url
|
||||
link = search_item.get("link")
|
||||
response = {
|
||||
"result_id": i,
|
||||
"title": title,
|
||||
"description": snippet,
|
||||
"long_description": long_description,
|
||||
"url": link,
|
||||
}
|
||||
responses.append(response)
|
||||
|
||||
logger.info("Google search completed", extra={"query": query, "search_type": search_type, "result_count": len(responses)})
|
||||
else:
|
||||
error_info = data.get("error", {})
|
||||
logger.error("Google search API error", extra={"query": query, "api_error": error_info})
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Google search failed", extra={"query": query, "search_type": search_type, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
return responses
|
||||
|
|
@ -1,139 +1,181 @@
|
|||
import os
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from sqlmodel import Session, select
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from app.model.mcp.mcp_user import McpUser, McpUserIn, McpUserOut, McpUserUpdate, Status
|
||||
from app.model.mcp.mcp import Mcp
|
||||
from loguru import logger
|
||||
from camel.toolkits.mcp_toolkit import MCPToolkit
|
||||
from app.component.environment import env
|
||||
|
||||
router = APIRouter(tags=["McpUser Management"])
|
||||
|
||||
|
||||
async def pre_instantiate_mcp_toolkit(config_dict: dict) -> bool:
|
||||
"""
|
||||
Pre-instantiate MCP toolkit to complete authentication process
|
||||
|
||||
Args:
|
||||
config_dict: MCP server configuration dictionary
|
||||
|
||||
Returns:
|
||||
bool: Whether successfully instantiated and connected
|
||||
"""
|
||||
try:
|
||||
# Ensure unified auth directory for all mcp servers
|
||||
for server_config in config_dict.get("mcpServers", {}).values():
|
||||
if "env" not in server_config:
|
||||
server_config["env"] = {}
|
||||
# Set global auth directory to persist authentication across tasks
|
||||
if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]:
|
||||
server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env(
|
||||
"MCP_REMOTE_CONFIG_DIR",
|
||||
os.path.expanduser("~/.mcp-auth")
|
||||
)
|
||||
|
||||
# Create MCP toolkit and attempt to connect
|
||||
mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=30)
|
||||
await mcp_toolkit.connect()
|
||||
|
||||
# Get tools list to ensure connection is successful
|
||||
tools = mcp_toolkit.get_tools()
|
||||
logger.info(f"Successfully pre-instantiated MCP toolkit with {len(tools)} tools")
|
||||
|
||||
# Disconnect, authentication info is already saved
|
||||
await mcp_toolkit.disconnect()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to pre-instantiate MCP toolkit: {e!r}")
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/mcp/users", name="list mcp users", response_model=List[McpUserOut])
|
||||
async def list_mcp_users(
|
||||
mcp_id: Optional[int] = None,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
user_id = auth.user.id
|
||||
query = select(McpUser)
|
||||
if mcp_id is not None:
|
||||
query = query.where(McpUser.mcp_id == mcp_id)
|
||||
if user_id is not None:
|
||||
query = query.where(McpUser.user_id == user_id)
|
||||
mcp_users = session.exec(query).all()
|
||||
return mcp_users
|
||||
|
||||
|
||||
@router.get("/mcp/users/{mcp_user_id}", name="get mcp user", response_model=McpUserOut)
|
||||
async def get_mcp_user(mcp_user_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
query = select(McpUser).where(McpUser.id == mcp_user_id)
|
||||
mcp_user = session.exec(query).first()
|
||||
if not mcp_user:
|
||||
raise HTTPException(status_code=404, detail=_("McpUser not found"))
|
||||
return mcp_user
|
||||
|
||||
|
||||
@router.post("/mcp/users", name="create mcp user", response_model=McpUserOut)
|
||||
async def create_mcp_user(mcp_user: McpUserIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
exists = session.exec(
|
||||
select(McpUser).where(McpUser.mcp_id == mcp_user.mcp_id, McpUser.user_id == auth.user.id)
|
||||
).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail=_("mcp is installed"))
|
||||
|
||||
# Get MCP configuration from the main Mcp table
|
||||
mcp = session.get(Mcp, mcp_user.mcp_id)
|
||||
if mcp and mcp.install_command:
|
||||
# Pre-instantiate MCP toolkit for authentication
|
||||
config_dict = {
|
||||
"mcpServers": {
|
||||
mcp.key: mcp.install_command
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success = await pre_instantiate_mcp_toolkit(config_dict)
|
||||
if not success:
|
||||
logger.warning(f"Pre-instantiation failed for MCP {mcp.key}, but continuing with user creation")
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception during pre-instantiation for MCP {mcp.key}: {e}")
|
||||
|
||||
db_mcp_user = McpUser(mcp_id=mcp_user.mcp_id, user_id=auth.user.id, env=mcp_user.env)
|
||||
session.add(db_mcp_user)
|
||||
session.commit()
|
||||
session.refresh(db_mcp_user)
|
||||
return db_mcp_user
|
||||
|
||||
|
||||
@router.put("/mcp/users/{id}", name="update mcp user")
|
||||
async def update_mcp_user(
|
||||
id: int,
|
||||
update_item: McpUserUpdate,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
model = session.get(McpUser, id)
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail=_("Mcp Info not found"))
|
||||
if model.user_id != auth.user.id:
|
||||
raise HTTPException(status_code=400, detail=_("current user have no permission to modify"))
|
||||
update_data = update_item.model_dump(exclude_unset=True)
|
||||
model.update_fields(update_data)
|
||||
model.save(session)
|
||||
session.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.delete("/mcp/users/{mcp_user_id}", name="delete mcp user")
|
||||
async def delete_mcp_user(mcp_user_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
db_mcp_user = session.get(McpUser, mcp_user_id)
|
||||
if not db_mcp_user:
|
||||
raise HTTPException(status_code=404, detail=_("Mcp Info not found"))
|
||||
session.delete(db_mcp_user)
|
||||
session.commit()
|
||||
return Response(status_code=204)
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from sqlmodel import Session, select
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from fastapi_babel import _
|
||||
from app.model.mcp.mcp_user import McpUser, McpUserIn, McpUserOut, McpUserUpdate, Status
|
||||
from app.model.mcp.mcp import Mcp
|
||||
from camel.toolkits.mcp_toolkit import MCPToolkit
|
||||
from app.component.environment import env
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_mcp_user_controller")
|
||||
|
||||
router = APIRouter(tags=["McpUser Management"])
|
||||
|
||||
|
||||
async def pre_instantiate_mcp_toolkit(config_dict: dict) -> bool:
|
||||
"""
|
||||
Pre-instantiate MCP toolkit to complete authentication process
|
||||
|
||||
Args:
|
||||
config_dict: MCP server configuration dictionary
|
||||
|
||||
Returns:
|
||||
bool: Whether successfully instantiated and connected
|
||||
"""
|
||||
try:
|
||||
# Ensure unified auth directory for all mcp servers
|
||||
for server_config in config_dict.get("mcpServers", {}).values():
|
||||
if "env" not in server_config:
|
||||
server_config["env"] = {}
|
||||
# Set global auth directory to persist authentication across tasks
|
||||
if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]:
|
||||
server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env(
|
||||
"MCP_REMOTE_CONFIG_DIR",
|
||||
os.path.expanduser("~/.mcp-auth")
|
||||
)
|
||||
|
||||
# Create MCP toolkit and attempt to connect
|
||||
mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=30)
|
||||
await mcp_toolkit.connect()
|
||||
|
||||
# Get tools list to ensure connection is successful
|
||||
tools = mcp_toolkit.get_tools()
|
||||
logger.info("MCP toolkit pre-instantiated", extra={"tools_count": len(tools)})
|
||||
|
||||
# Disconnect, authentication info is already saved
|
||||
await mcp_toolkit.disconnect()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MCP toolkit pre-instantiation failed", extra={"error": str(e)}, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/mcp/users", name="list mcp users", response_model=List[McpUserOut])
|
||||
@traceroot.trace()
|
||||
async def list_mcp_users(
|
||||
mcp_id: Optional[int] = None,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
"""List MCP users for current user."""
|
||||
user_id = auth.user.id
|
||||
query = select(McpUser)
|
||||
if mcp_id is not None:
|
||||
query = query.where(McpUser.mcp_id == mcp_id)
|
||||
if user_id is not None:
|
||||
query = query.where(McpUser.user_id == user_id)
|
||||
mcp_users = session.exec(query).all()
|
||||
logger.debug("MCP users listed", extra={"user_id": user_id, "mcp_id": mcp_id, "count": len(mcp_users)})
|
||||
return mcp_users
|
||||
|
||||
|
||||
@router.get("/mcp/users/{mcp_user_id}", name="get mcp user", response_model=McpUserOut)
|
||||
@traceroot.trace()
|
||||
async def get_mcp_user(mcp_user_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Get MCP user details."""
|
||||
query = select(McpUser).where(McpUser.id == mcp_user_id)
|
||||
mcp_user = session.exec(query).first()
|
||||
if not mcp_user:
|
||||
logger.warning("MCP user not found", extra={"user_id": auth.user.id, "mcp_user_id": mcp_user_id})
|
||||
raise HTTPException(status_code=404, detail=_("McpUser not found"))
|
||||
logger.debug("MCP user retrieved", extra={"user_id": auth.user.id, "mcp_user_id": mcp_user_id, "mcp_id": mcp_user.mcp_id})
|
||||
return mcp_user
|
||||
|
||||
|
||||
@router.post("/mcp/users", name="create mcp user", response_model=McpUserOut)
|
||||
@traceroot.trace()
|
||||
async def create_mcp_user(mcp_user: McpUserIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Create MCP user installation."""
|
||||
user_id = auth.user.id
|
||||
mcp_id = mcp_user.mcp_id
|
||||
|
||||
exists = session.exec(
|
||||
select(McpUser).where(McpUser.mcp_id == mcp_id, McpUser.user_id == user_id)
|
||||
).first()
|
||||
if exists:
|
||||
logger.warning("MCP already installed", extra={"user_id": user_id, "mcp_id": mcp_id})
|
||||
raise HTTPException(status_code=400, detail=_("mcp is installed"))
|
||||
|
||||
# Get MCP configuration from the main Mcp table
|
||||
mcp = session.get(Mcp, mcp_id)
|
||||
if mcp and mcp.install_command:
|
||||
config_dict = {
|
||||
"mcpServers": {
|
||||
mcp.key: mcp.install_command
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
success = await pre_instantiate_mcp_toolkit(config_dict)
|
||||
if not success:
|
||||
logger.warning("MCP pre-instantiation failed, continuing", extra={"user_id": user_id, "mcp_id": mcp_id, "mcp_key": mcp.key})
|
||||
except Exception as e:
|
||||
logger.warning("MCP pre-instantiation exception", extra={"user_id": user_id, "mcp_id": mcp_id, "error": str(e)}, exc_info=True)
|
||||
|
||||
try:
|
||||
db_mcp_user = McpUser(mcp_id=mcp_id, user_id=user_id, env=mcp_user.env)
|
||||
session.add(db_mcp_user)
|
||||
session.commit()
|
||||
session.refresh(db_mcp_user)
|
||||
logger.info("MCP user created", extra={"user_id": user_id, "mcp_id": mcp_id, "mcp_user_id": db_mcp_user.id})
|
||||
return db_mcp_user
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("MCP user creation failed", extra={"user_id": user_id, "mcp_id": mcp_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/mcp/users/{id}", name="update mcp user")
|
||||
@traceroot.trace()
|
||||
async def update_mcp_user(
|
||||
id: int,
|
||||
update_item: McpUserUpdate,
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
):
|
||||
"""Update MCP user settings."""
|
||||
user_id = auth.user.id
|
||||
model = session.get(McpUser, id)
|
||||
if not model:
|
||||
logger.warning("MCP user not found for update", extra={"user_id": user_id, "mcp_user_id": id})
|
||||
raise HTTPException(status_code=404, detail=_("Mcp Info not found"))
|
||||
if model.user_id != user_id:
|
||||
logger.warning("Unauthorized MCP user update", extra={"user_id": user_id, "mcp_user_id": id, "owner_id": model.user_id})
|
||||
raise HTTPException(status_code=400, detail=_("current user have no permission to modify"))
|
||||
|
||||
try:
|
||||
update_data = update_item.model_dump(exclude_unset=True)
|
||||
model.update_fields(update_data)
|
||||
model.save(session)
|
||||
session.refresh(model)
|
||||
logger.info("MCP user updated", extra={"user_id": user_id, "mcp_user_id": id})
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error("MCP user update failed", extra={"user_id": user_id, "mcp_user_id": id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/mcp/users/{mcp_user_id}", name="delete mcp user")
|
||||
@traceroot.trace()
|
||||
async def delete_mcp_user(mcp_user_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Delete MCP user installation."""
|
||||
user_id = auth.user.id
|
||||
db_mcp_user = session.get(McpUser, mcp_user_id)
|
||||
if not db_mcp_user:
|
||||
logger.warning("MCP user not found for deletion", extra={"user_id": user_id, "mcp_user_id": mcp_user_id})
|
||||
raise HTTPException(status_code=404, detail=_("Mcp Info not found"))
|
||||
|
||||
try:
|
||||
session.delete(db_mcp_user)
|
||||
session.commit()
|
||||
logger.info("MCP user deleted", extra={"user_id": user_id, "mcp_user_id": mcp_user_id, "mcp_id": db_mcp_user.mcp_id})
|
||||
return Response(status_code=204)
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("MCP user deletion failed", extra={"user_id": user_id, "mcp_user_id": mcp_user_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,58 +1,81 @@
|
|||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
|
||||
from app.component.environment import env
|
||||
from app.component.oauth_adapter import OauthCallbackPayload, get_oauth_adapter
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(prefix="/oauth", tags=["Oauth Servers"])
|
||||
|
||||
|
||||
@router.get("/{app}/login", name="OAuth Login Redirect")
|
||||
def oauth_login(app: str, request: Request, state: Optional[str] = None):
|
||||
try:
|
||||
callback_url = str(request.url_for("OAuth Callback", app=app))
|
||||
if callback_url.startswith("http://"):
|
||||
callback_url = "https://" + callback_url[len("http://") :]
|
||||
adapter = get_oauth_adapter(app, callback_url)
|
||||
url = adapter.get_authorize_url(state)
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Failed to generate authorization URL")
|
||||
return RedirectResponse(str(url))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{app}/callback", name="OAuth Callback")
|
||||
def oauth_callback(app: str, request: Request, code: Optional[str] = None, state: Optional[str] = None):
|
||||
if not code:
|
||||
raise HTTPException(status_code=400, detail="Missing code parameter")
|
||||
redirect_url = f"eigent://callback/oauth?provider={app}&code={code}&state={state}"
|
||||
html_content = f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type='text/javascript'>
|
||||
window.location.href = '{redirect_url}';
|
||||
</script>
|
||||
<p>Redirecting, please wait...</p>
|
||||
<button onclick='window.close()'>Close this window</button>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@router.post("/{app}/token", name="OAuth Fetch Token")
|
||||
def fetch_token(app: str, request: Request, data: OauthCallbackPayload):
|
||||
try:
|
||||
callback_url = str(request.url_for("OAuth Callback", app=app))
|
||||
if callback_url.startswith("http://"):
|
||||
callback_url = "https://" + callback_url[len("http://") :]
|
||||
|
||||
adapter = get_oauth_adapter(app, callback_url)
|
||||
token_data = adapter.fetch_token(data.code)
|
||||
return JSONResponse(token_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
|
||||
from app.component.environment import env
|
||||
from app.component.oauth_adapter import OauthCallbackPayload, get_oauth_adapter
|
||||
from typing import Optional
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_oauth_controller")
|
||||
|
||||
router = APIRouter(prefix="/oauth", tags=["Oauth Servers"])
|
||||
|
||||
|
||||
@router.get("/{app}/login", name="OAuth Login Redirect")
|
||||
@traceroot.trace()
|
||||
def oauth_login(app: str, request: Request, state: Optional[str] = None):
|
||||
"""Redirect user to OAuth provider's authorization endpoint."""
|
||||
try:
|
||||
callback_url = str(request.url_for("OAuth Callback", app=app))
|
||||
if callback_url.startswith("http://"):
|
||||
callback_url = "https://" + callback_url[len("http://") :]
|
||||
|
||||
adapter = get_oauth_adapter(app, callback_url)
|
||||
url = adapter.get_authorize_url(state)
|
||||
|
||||
if not url:
|
||||
logger.error("Failed to generate authorization URL", extra={"provider": app, "callback_url": callback_url})
|
||||
raise HTTPException(status_code=400, detail="Failed to generate authorization URL")
|
||||
|
||||
logger.info("OAuth login initiated", extra={"provider": app})
|
||||
return RedirectResponse(str(url))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("OAuth login failed", extra={"provider": app, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=400, detail="OAuth login failed")
|
||||
|
||||
|
||||
@router.get("/{app}/callback", name="OAuth Callback")
|
||||
@traceroot.trace()
|
||||
def oauth_callback(app: str, request: Request, code: Optional[str] = None, state: Optional[str] = None):
|
||||
"""Handle OAuth provider callback and redirect to client app."""
|
||||
if not code:
|
||||
logger.warning("OAuth callback missing code", extra={"provider": app})
|
||||
raise HTTPException(status_code=400, detail="Missing code parameter")
|
||||
|
||||
logger.info("OAuth callback received", extra={"provider": app, "has_state": state is not None})
|
||||
|
||||
redirect_url = f"eigent://callback/oauth?provider={app}&code={code}&state={state}"
|
||||
html_content = f"""
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type='text/javascript'>
|
||||
window.location.href = '{redirect_url}';
|
||||
</script>
|
||||
<p>Redirecting, please wait...</p>
|
||||
<button onclick='window.close()'>Close this window</button>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@router.post("/{app}/token", name="OAuth Fetch Token")
|
||||
@traceroot.trace()
|
||||
def fetch_token(app: str, request: Request, data: OauthCallbackPayload):
|
||||
"""Exchange authorization code for access token."""
|
||||
try:
|
||||
callback_url = str(request.url_for("OAuth Callback", app=app))
|
||||
if callback_url.startswith("http://"):
|
||||
callback_url = "https://" + callback_url[len("http://") :]
|
||||
|
||||
adapter = get_oauth_adapter(app, callback_url)
|
||||
token_data = adapter.fetch_token(data.code)
|
||||
logger.info("OAuth token fetched", extra={"provider": app})
|
||||
return JSONResponse(token_data)
|
||||
except Exception as e:
|
||||
logger.error("OAuth token fetch failed", extra={"provider": app, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,100 +1,140 @@
|
|||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from fastapi_babel import _
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
from sqlalchemy import update
|
||||
from sqlmodel import Session, select, col
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.model.provider.provider import Provider, ProviderIn, ProviderOut, ProviderPreferIn
|
||||
|
||||
|
||||
router = APIRouter(tags=["Provider Management"])
|
||||
|
||||
|
||||
@router.get("/providers", name="list providers", response_model=Page[ProviderOut])
|
||||
async def gets(
|
||||
keyword: str | None = None,
|
||||
prefer: Optional[bool] = Query(None, description="Filter by prefer status"),
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
) -> Page[ProviderOut]:
|
||||
user_id = auth.user.id
|
||||
stmt = select(Provider).where(Provider.user_id == user_id, Provider.no_delete())
|
||||
if keyword:
|
||||
stmt = stmt.where(col(Provider.provider_name).like(f"%{keyword}%"))
|
||||
if prefer is not None:
|
||||
stmt = stmt.where(Provider.prefer == prefer)
|
||||
stmt = stmt.order_by(col(Provider.created_at).desc(), col(Provider.id).desc()) # Added for consistent pagination
|
||||
return paginate(session, stmt)
|
||||
|
||||
|
||||
@router.get("/provider", name="get provider detail", response_model=ProviderOut)
|
||||
async def get(id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
stmt = select(Provider).where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == id)
|
||||
model = session.exec(stmt).one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail=_("Provider not found"))
|
||||
return model
|
||||
|
||||
|
||||
@router.post("/provider", name="create provider", response_model=ProviderOut)
|
||||
async def post(data: ProviderIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
model = Provider(**data.model_dump(), user_id=user_id)
|
||||
model.save(session)
|
||||
return model
|
||||
|
||||
|
||||
@router.put("/provider/{id}", name="update provider", response_model=ProviderOut)
|
||||
async def put(id: int, data: ProviderIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
model = session.exec(
|
||||
select(Provider).where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == id)
|
||||
).one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail=_("Provider not found"))
|
||||
model.model_type = data.model_type
|
||||
model.provider_name = data.provider_name
|
||||
model.api_key = data.api_key
|
||||
model.endpoint_url = data.endpoint_url
|
||||
model.encrypted_config = data.encrypted_config
|
||||
model.is_vaild = data.is_vaild
|
||||
model.save(session)
|
||||
session.refresh(model)
|
||||
return model
|
||||
|
||||
|
||||
@router.delete("/provider/{id}", name="delete provider")
|
||||
async def delete(id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
model = session.exec(
|
||||
select(Provider).where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == id)
|
||||
).one_or_none()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail=_("Provider not found"))
|
||||
model.delete(session)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/provider/prefer", name="set provider prefer")
|
||||
async def set_prefer(data: ProviderPreferIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
try:
|
||||
# 1. current user's all provider prefer set to false
|
||||
session.exec(update(Provider).where(Provider.user_id == user_id, Provider.no_delete()).values(prefer=False))
|
||||
# 2. set the prefer of the specified provider_id to true
|
||||
session.exec(
|
||||
update(Provider)
|
||||
.where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == data.provider_id)
|
||||
.values(prefer=True)
|
||||
)
|
||||
session.commit()
|
||||
return {"success": True}
|
||||
except SQLAlchemyError as e:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
from typing import List, Optional
|
||||
from fastapi import Depends, HTTPException, Query, Response, APIRouter
|
||||
from fastapi_babel import _
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlmodel import paginate
|
||||
from sqlalchemy import update
|
||||
from sqlmodel import Session, select, col
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.component.database import session
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.model.provider.provider import Provider, ProviderIn, ProviderOut, ProviderPreferIn
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_provider_controller")
|
||||
|
||||
router = APIRouter(tags=["Provider Management"])
|
||||
|
||||
|
||||
@router.get("/providers", name="list providers", response_model=Page[ProviderOut])
|
||||
@traceroot.trace()
|
||||
async def gets(
|
||||
keyword: str | None = None,
|
||||
prefer: Optional[bool] = Query(None, description="Filter by prefer status"),
|
||||
session: Session = Depends(session),
|
||||
auth: Auth = Depends(auth_must),
|
||||
) -> Page[ProviderOut]:
|
||||
"""List user's providers with optional filtering."""
|
||||
user_id = auth.user.id
|
||||
stmt = select(Provider).where(Provider.user_id == user_id, Provider.no_delete())
|
||||
if keyword:
|
||||
stmt = stmt.where(col(Provider.provider_name).like(f"%{keyword}%"))
|
||||
if prefer is not None:
|
||||
stmt = stmt.where(Provider.prefer == prefer)
|
||||
stmt = stmt.order_by(col(Provider.created_at).desc(), col(Provider.id).desc())
|
||||
logger.debug("Providers listed", extra={"user_id": user_id, "keyword": keyword, "prefer_filter": prefer})
|
||||
return paginate(session, stmt)
|
||||
|
||||
|
||||
@router.get("/provider", name="get provider detail", response_model=ProviderOut)
|
||||
@traceroot.trace()
|
||||
async def get(id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Get provider details."""
|
||||
user_id = auth.user.id
|
||||
stmt = select(Provider).where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == id)
|
||||
model = session.exec(stmt).one_or_none()
|
||||
if not model:
|
||||
logger.warning("Provider not found", extra={"user_id": user_id, "provider_id": id})
|
||||
raise HTTPException(status_code=404, detail=_("Provider not found"))
|
||||
logger.debug("Provider retrieved", extra={"user_id": user_id, "provider_id": id})
|
||||
return model
|
||||
|
||||
|
||||
@router.post("/provider", name="create provider", response_model=ProviderOut)
|
||||
@traceroot.trace()
|
||||
async def post(data: ProviderIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Create a new provider."""
|
||||
user_id = auth.user.id
|
||||
try:
|
||||
model = Provider(**data.model_dump(), user_id=user_id)
|
||||
model.save(session)
|
||||
logger.info("Provider created", extra={"user_id": user_id, "provider_id": model.id, "provider_name": data.provider_name})
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error("Provider creation failed", extra={"user_id": user_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/provider/{id}", name="update provider", response_model=ProviderOut)
|
||||
@traceroot.trace()
|
||||
async def put(id: int, data: ProviderIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Update provider details."""
|
||||
user_id = auth.user.id
|
||||
model = session.exec(
|
||||
select(Provider).where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == id)
|
||||
).one_or_none()
|
||||
if not model:
|
||||
logger.warning("Provider not found for update", extra={"user_id": user_id, "provider_id": id})
|
||||
raise HTTPException(status_code=404, detail=_("Provider not found"))
|
||||
|
||||
try:
|
||||
model.model_type = data.model_type
|
||||
model.provider_name = data.provider_name
|
||||
model.api_key = data.api_key
|
||||
model.endpoint_url = data.endpoint_url
|
||||
model.encrypted_config = data.encrypted_config
|
||||
model.is_vaild = data.is_vaild
|
||||
model.save(session)
|
||||
session.refresh(model)
|
||||
logger.info("Provider updated", extra={"user_id": user_id, "provider_id": id, "provider_name": data.provider_name})
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error("Provider update failed", extra={"user_id": user_id, "provider_id": id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/provider/{id}", name="delete provider")
|
||||
@traceroot.trace()
|
||||
async def delete(id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Delete a provider."""
|
||||
user_id = auth.user.id
|
||||
model = session.exec(
|
||||
select(Provider).where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == id)
|
||||
).one_or_none()
|
||||
if not model:
|
||||
logger.warning("Provider not found for deletion", extra={"user_id": user_id, "provider_id": id})
|
||||
raise HTTPException(status_code=404, detail=_("Provider not found"))
|
||||
|
||||
try:
|
||||
model.delete(session)
|
||||
logger.info("Provider deleted", extra={"user_id": user_id, "provider_id": id})
|
||||
return Response(status_code=204)
|
||||
except Exception as e:
|
||||
logger.error("Provider deletion failed", extra={"user_id": user_id, "provider_id": id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/provider/prefer", name="set provider prefer")
|
||||
@traceroot.trace()
|
||||
async def set_prefer(data: ProviderPreferIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Set preferred provider for user."""
|
||||
user_id = auth.user.id
|
||||
provider_id = data.provider_id
|
||||
|
||||
try:
|
||||
# 1. Set all current user's providers prefer to false
|
||||
session.exec(update(Provider).where(Provider.user_id == user_id, Provider.no_delete()).values(prefer=False))
|
||||
# 2. Set the prefer of the specified provider_id to true
|
||||
session.exec(
|
||||
update(Provider)
|
||||
.where(Provider.user_id == user_id, Provider.no_delete(), Provider.id == provider_id)
|
||||
.values(prefer=True)
|
||||
)
|
||||
session.commit()
|
||||
logger.info("Preferred provider set", extra={"user_id": user_id, "provider_id": provider_id})
|
||||
return {"success": True}
|
||||
except SQLAlchemyError as e:
|
||||
session.rollback()
|
||||
logger.error("Failed to set preferred provider", extra={"user_id": user_id, "provider_id": provider_id, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
|
@ -1,90 +1,114 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi_babel import _
|
||||
from sqlmodel import Session
|
||||
from app.component import code
|
||||
from app.component.auth import Auth
|
||||
from app.component.database import session
|
||||
from app.component.encrypt import password_verify
|
||||
from app.component.stack_auth import StackAuth
|
||||
from app.exception.exception import UserException
|
||||
from app.model.user.user import LoginByPasswordIn, LoginResponse, Status, User, RegisterIn
|
||||
from loguru import logger
|
||||
from app.component.environment import env
|
||||
|
||||
|
||||
router = APIRouter(tags=["Login/Registration"])
|
||||
|
||||
|
||||
@router.post("/login", name="login by email or password")
|
||||
async def by_password(data: LoginByPasswordIn, session: Session = Depends(session)) -> LoginResponse:
|
||||
"""
|
||||
User login with email and password
|
||||
"""
|
||||
user = User.by(User.email == data.email, s=session).one_or_none()
|
||||
if not user or not password_verify(data.password, user.password):
|
||||
raise UserException(code.password, _("Account or password error"))
|
||||
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)
|
||||
|
||||
|
||||
@router.post("/login-by_stack", name="login by stack")
|
||||
async def by_stack_auth(
|
||||
token: str,
|
||||
type: str = "signup",
|
||||
invite_code: str | None = None,
|
||||
session: Session = Depends(session),
|
||||
):
|
||||
try:
|
||||
stack_id = await StackAuth.user_id(token)
|
||||
info = await StackAuth.user_info(token)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
raise HTTPException(500, detail=_(f"{e}"))
|
||||
user = User.by(User.stack_id == stack_id, s=session).one_or_none()
|
||||
|
||||
if not user:
|
||||
# Only signup can create user
|
||||
if type != "signup":
|
||||
raise UserException(code.error, _("User not found"))
|
||||
with session as s:
|
||||
try:
|
||||
user = User(
|
||||
username=info["username"] if "username" in info else None,
|
||||
nickname=info["display_name"],
|
||||
email=info["primary_email"],
|
||||
avatar=info["profile_image_url"],
|
||||
stack_id=stack_id,
|
||||
)
|
||||
s.add(user)
|
||||
s.commit()
|
||||
session.refresh(user)
|
||||
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)
|
||||
except Exception as e:
|
||||
s.rollback()
|
||||
logger.error(f"Failed to register: {e}")
|
||||
raise UserException(code.error, _("Failed to register"))
|
||||
else:
|
||||
if user.status == Status.Block:
|
||||
raise UserException(code.error, _("Your account has been blocked."))
|
||||
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)
|
||||
|
||||
|
||||
@router.post("/register", name="register by email/password")
|
||||
async def register(data: RegisterIn, session: Session = Depends(session)):
|
||||
# Check if email is already registered
|
||||
if User.by(User.email == data.email, s=session).one_or_none():
|
||||
raise UserException(code.error, _("Email already registered"))
|
||||
|
||||
with session as s:
|
||||
try:
|
||||
user = User(
|
||||
email=data.email,
|
||||
password=data.password,
|
||||
)
|
||||
s.add(user)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
except Exception as e:
|
||||
s.rollback()
|
||||
logger.error(f"Failed to register: {e}")
|
||||
raise UserException(code.error, _("Failed to register"))
|
||||
return {"status": "success"}
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi_babel import _
|
||||
from sqlmodel import Session
|
||||
from app.component import code
|
||||
from app.component.auth import Auth
|
||||
from app.component.database import session
|
||||
from app.component.encrypt import password_verify
|
||||
from app.component.stack_auth import StackAuth
|
||||
from app.exception.exception import UserException
|
||||
from app.model.user.user import LoginByPasswordIn, LoginResponse, Status, User, RegisterIn
|
||||
from app.component.environment import env
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_login_controller")
|
||||
|
||||
|
||||
router = APIRouter(tags=["Login/Registration"])
|
||||
|
||||
|
||||
@router.post("/login", name="login by email or password")
|
||||
@traceroot.trace()
|
||||
async def by_password(data: LoginByPasswordIn, session: Session = Depends(session)) -> LoginResponse:
|
||||
"""
|
||||
User login with email and password
|
||||
"""
|
||||
email = data.email
|
||||
user = User.by(User.email == email, s=session).one_or_none()
|
||||
|
||||
if not user:
|
||||
logger.warning("Login failed: user not found", extra={"email": email})
|
||||
raise UserException(code.password, _("Account or password error"))
|
||||
|
||||
if not password_verify(data.password, user.password):
|
||||
logger.warning("Login failed: invalid password", extra={"user_id": user.id, "email": email})
|
||||
raise UserException(code.password, _("Account or password error"))
|
||||
|
||||
logger.info("User login successful", extra={"user_id": user.id, "email": email})
|
||||
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)
|
||||
|
||||
|
||||
@router.post("/login-by_stack", name="login by stack")
|
||||
@traceroot.trace()
|
||||
async def by_stack_auth(
|
||||
token: str,
|
||||
type: str = "signup",
|
||||
invite_code: str | None = None,
|
||||
session: Session = Depends(session),
|
||||
):
|
||||
try:
|
||||
stack_id = await StackAuth.user_id(token)
|
||||
info = await StackAuth.user_info(token)
|
||||
except Exception as e:
|
||||
logger.error("Stack auth failed", extra={"type": type, "error": str(e)}, exc_info=True)
|
||||
raise HTTPException(500, detail=_("Authentication failed"))
|
||||
|
||||
user = User.by(User.stack_id == stack_id, s=session).one_or_none()
|
||||
|
||||
if not user:
|
||||
if type != "signup":
|
||||
logger.warning("Stack auth signup blocked: user not found", extra={"stack_id": stack_id, "type": type})
|
||||
raise UserException(code.error, _("User not found"))
|
||||
|
||||
with session as s:
|
||||
try:
|
||||
user = User(
|
||||
username=info["username"] if "username" in info else None,
|
||||
nickname=info["display_name"],
|
||||
email=info["primary_email"],
|
||||
avatar=info["profile_image_url"],
|
||||
stack_id=stack_id,
|
||||
)
|
||||
s.add(user)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
logger.info("New user registered via stack", extra={"user_id": user.id, "email": user.email, "stack_id": stack_id})
|
||||
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)
|
||||
except Exception as e:
|
||||
s.rollback()
|
||||
logger.error("Stack auth registration failed", extra={"stack_id": stack_id, "error": str(e)}, exc_info=True)
|
||||
raise UserException(code.error, _("Failed to register"))
|
||||
else:
|
||||
if user.status == Status.Block:
|
||||
logger.warning("Blocked user login attempt", extra={"user_id": user.id, "stack_id": stack_id})
|
||||
raise UserException(code.error, _("Your account has been blocked."))
|
||||
|
||||
logger.info("User login via stack successful", extra={"user_id": user.id, "email": user.email, "stack_id": stack_id})
|
||||
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)
|
||||
|
||||
|
||||
@router.post("/register", name="register by email/password")
|
||||
@traceroot.trace()
|
||||
async def register(data: RegisterIn, session: Session = Depends(session)):
|
||||
email = data.email
|
||||
|
||||
if User.by(User.email == email, s=session).one_or_none():
|
||||
logger.warning("Registration failed: email already exists", extra={"email": email})
|
||||
raise UserException(code.error, _("Email already registered"))
|
||||
|
||||
with session as s:
|
||||
try:
|
||||
user = User(
|
||||
email=email,
|
||||
password=data.password,
|
||||
)
|
||||
s.add(user)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
logger.info("User registered successfully", extra={"user_id": user.id, "email": email})
|
||||
except Exception as e:
|
||||
s.rollback()
|
||||
logger.error("User registration failed", extra={"email": email, "error": str(e)}, exc_info=True)
|
||||
raise UserException(code.error, _("Failed to register"))
|
||||
|
||||
return {"status": "success"}
|
||||
|
|
@ -1,115 +1,151 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from app.model.user.privacy import UserPrivacy, UserPrivacySettings
|
||||
from app.model.user.user import User, UserIn, UserOut, UserProfile
|
||||
from app.model.user.user_stat import UserStat, UserStatActionIn, UserStatOut
|
||||
from app.model.chat.chat_history import ChatHistory
|
||||
from app.model.mcp.mcp_user import McpUser
|
||||
from app.model.config.config import Config
|
||||
from app.model.chat.chat_snpshot import ChatSnapshot
|
||||
from app.model.user.user_credits_record import UserCreditsRecord
|
||||
|
||||
|
||||
router = APIRouter(tags=["User"])
|
||||
|
||||
|
||||
@router.get("/user", name="user info", response_model=UserOut)
|
||||
def get(auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
# 获取用户信息时触发积分刷新
|
||||
user: User = auth.user
|
||||
user.refresh_credits_on_active(session)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/user", name="update user info", response_model=UserOut)
|
||||
def put(data: UserIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
model = auth.user
|
||||
model.username = data.username
|
||||
model.save(session)
|
||||
return model
|
||||
|
||||
|
||||
@router.put("/user/profile", name="update user profile", response_model=UserProfile)
|
||||
def put_profile(data: UserProfile, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
model = auth.user
|
||||
model.nickname = data.nickname
|
||||
model.fullname = data.fullname
|
||||
model.work_desc = data.work_desc
|
||||
model.save(session)
|
||||
return model
|
||||
|
||||
|
||||
@router.get("/user/privacy", name="get user privacy")
|
||||
def get_privacy(session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
stmt = select(UserPrivacy).where(UserPrivacy.user_id == user_id)
|
||||
model = session.exec(stmt).one_or_none()
|
||||
|
||||
if not model:
|
||||
return UserPrivacySettings.default_settings()
|
||||
return model.pricacy_setting
|
||||
|
||||
|
||||
@router.put("/user/privacy", name="update user privacy")
|
||||
def put_privacy(data: UserPrivacySettings, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
user_id = auth.user.id
|
||||
stmt = select(UserPrivacy).where(UserPrivacy.user_id == user_id)
|
||||
model = session.exec(stmt).one_or_none()
|
||||
default_settings = UserPrivacySettings.default_settings()
|
||||
|
||||
if model:
|
||||
model.pricacy_setting = {**model.pricacy_setting, **data.model_dump()}
|
||||
model.save(session)
|
||||
else:
|
||||
model = UserPrivacy(user_id=user_id, pricacy_setting={**default_settings, **data.model_dump()})
|
||||
model.save(session)
|
||||
|
||||
return model.pricacy_setting
|
||||
|
||||
|
||||
@router.get("/user/current_credits", name="get user current credits")
|
||||
def get_user_credits(auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
user = auth.user
|
||||
user.refresh_credits_on_active(session)
|
||||
credits = user.credits
|
||||
daily_credits: UserCreditsRecord | None = UserCreditsRecord.get_daily_balance(user.id)
|
||||
current_daily_credits = 0
|
||||
if daily_credits:
|
||||
current_daily_credits = daily_credits.amount - daily_credits.balance
|
||||
credits += current_daily_credits if current_daily_credits > 0 else 0
|
||||
return {"credits": credits, "daily_credits": current_daily_credits}
|
||||
|
||||
|
||||
@router.get("/user/stat", name="get user stat", response_model=UserStatOut)
|
||||
def get_user_stat(auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
"""Get current user's operation statistics."""
|
||||
stat = session.exec(select(UserStat).where(UserStat.user_id == auth.user.id)).first()
|
||||
data = UserStatOut()
|
||||
if stat:
|
||||
data = UserStatOut(**stat.model_dump())
|
||||
else:
|
||||
data = UserStatOut(user_id=auth.user.id)
|
||||
data.task_queries = ChatHistory.count(ChatHistory.user_id == auth.user.id, s=session)
|
||||
mcp = McpUser.count(McpUser.user_id == auth.user.id, s=session)
|
||||
tool: list = session.exec(
|
||||
select(func.count("*")).where(Config.user_id == auth.user.id).group_by(Config.config_group)
|
||||
).all()
|
||||
tool = tool.__len__()
|
||||
data.mcp_install_count = mcp + tool
|
||||
data.storage_used = ChatSnapshot.caclDir(ChatSnapshot.get_user_dir(auth.user.id))
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/user/stat", name="record user stat")
|
||||
def record_user_stat(
|
||||
data: UserStatActionIn,
|
||||
auth: Auth = Depends(auth_must),
|
||||
session: Session = Depends(session),
|
||||
):
|
||||
"""Record or update current user's operation statistics."""
|
||||
data.user_id = auth.user.id
|
||||
stat = UserStat.record_action(session, data)
|
||||
return stat
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from app.model.user.privacy import UserPrivacy, UserPrivacySettings
|
||||
from app.model.user.user import User, UserIn, UserOut, UserProfile
|
||||
from app.model.user.user_stat import UserStat, UserStatActionIn, UserStatOut
|
||||
from app.model.chat.chat_history import ChatHistory
|
||||
from app.model.mcp.mcp_user import McpUser
|
||||
from app.model.config.config import Config
|
||||
from app.model.chat.chat_snpshot import ChatSnapshot
|
||||
from app.model.user.user_credits_record import UserCreditsRecord
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_user_controller")
|
||||
|
||||
router = APIRouter(tags=["User"])
|
||||
|
||||
|
||||
@router.get("/user", name="user info", response_model=UserOut)
|
||||
@traceroot.trace()
|
||||
def get(auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
"""Get current user information and refresh credits."""
|
||||
user: User = auth.user
|
||||
user.refresh_credits_on_active(session)
|
||||
logger.debug("User info retrieved", extra={"user_id": user.id})
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/user", name="update user info", response_model=UserOut)
|
||||
@traceroot.trace()
|
||||
def put(data: UserIn, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Update user basic information."""
|
||||
model = auth.user
|
||||
model.username = data.username
|
||||
model.save(session)
|
||||
logger.info("User info updated", extra={"user_id": model.id, "username": data.username})
|
||||
return model
|
||||
|
||||
|
||||
@router.put("/user/profile", name="update user profile", response_model=UserProfile)
|
||||
@traceroot.trace()
|
||||
def put_profile(data: UserProfile, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Update user profile details."""
|
||||
model = auth.user
|
||||
model.nickname = data.nickname
|
||||
model.fullname = data.fullname
|
||||
model.work_desc = data.work_desc
|
||||
model.save(session)
|
||||
logger.info("User profile updated", extra={"user_id": model.id, "nickname": data.nickname})
|
||||
return model
|
||||
|
||||
|
||||
@router.get("/user/privacy", name="get user privacy")
|
||||
@traceroot.trace()
|
||||
def get_privacy(session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Get user privacy settings."""
|
||||
user_id = auth.user.id
|
||||
stmt = select(UserPrivacy).where(UserPrivacy.user_id == user_id)
|
||||
model = session.exec(stmt).one_or_none()
|
||||
|
||||
if not model:
|
||||
logger.debug("Privacy settings not found, returning defaults", extra={"user_id": user_id})
|
||||
return UserPrivacySettings.default_settings()
|
||||
|
||||
logger.debug("Privacy settings retrieved", extra={"user_id": user_id})
|
||||
return model.pricacy_setting
|
||||
|
||||
|
||||
@router.put("/user/privacy", name="update user privacy")
|
||||
@traceroot.trace()
|
||||
def put_privacy(data: UserPrivacySettings, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
|
||||
"""Update user privacy settings."""
|
||||
user_id = auth.user.id
|
||||
stmt = select(UserPrivacy).where(UserPrivacy.user_id == user_id)
|
||||
model = session.exec(stmt).one_or_none()
|
||||
default_settings = UserPrivacySettings.default_settings()
|
||||
|
||||
if model:
|
||||
model.pricacy_setting = {**model.pricacy_setting, **data.model_dump()}
|
||||
model.save(session)
|
||||
logger.info("Privacy settings updated", extra={"user_id": user_id})
|
||||
else:
|
||||
model = UserPrivacy(user_id=user_id, pricacy_setting={**default_settings, **data.model_dump()})
|
||||
model.save(session)
|
||||
logger.info("Privacy settings created", extra={"user_id": user_id})
|
||||
|
||||
return model.pricacy_setting
|
||||
|
||||
|
||||
@router.get("/user/current_credits", name="get user current credits")
|
||||
@traceroot.trace()
|
||||
def get_user_credits(auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
"""Get user's current credit balance."""
|
||||
user = auth.user
|
||||
user.refresh_credits_on_active(session)
|
||||
credits = user.credits
|
||||
daily_credits: UserCreditsRecord | None = UserCreditsRecord.get_daily_balance(user.id)
|
||||
current_daily_credits = 0
|
||||
if daily_credits:
|
||||
current_daily_credits = daily_credits.amount - daily_credits.balance
|
||||
credits += current_daily_credits if current_daily_credits > 0 else 0
|
||||
|
||||
logger.debug("Credits retrieved", extra={"user_id": user.id, "total_credits": credits, "daily_credits": current_daily_credits})
|
||||
return {"credits": credits, "daily_credits": current_daily_credits}
|
||||
|
||||
|
||||
@router.get("/user/stat", name="get user stat", response_model=UserStatOut)
|
||||
@traceroot.trace()
|
||||
def get_user_stat(auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
"""Get current user's operation statistics."""
|
||||
user_id = auth.user.id
|
||||
stat = session.exec(select(UserStat).where(UserStat.user_id == user_id)).first()
|
||||
data = UserStatOut()
|
||||
|
||||
if stat:
|
||||
data = UserStatOut(**stat.model_dump())
|
||||
else:
|
||||
data = UserStatOut(user_id=user_id)
|
||||
|
||||
data.task_queries = ChatHistory.count(ChatHistory.user_id == user_id, s=session)
|
||||
mcp = McpUser.count(McpUser.user_id == user_id, s=session)
|
||||
tool: list = session.exec(
|
||||
select(func.count("*")).where(Config.user_id == user_id).group_by(Config.config_group)
|
||||
).all()
|
||||
tool = tool.__len__()
|
||||
data.mcp_install_count = mcp + tool
|
||||
data.storage_used = ChatSnapshot.caclDir(ChatSnapshot.get_user_dir(user_id))
|
||||
|
||||
logger.debug("User stats retrieved", extra={
|
||||
"user_id": user_id,
|
||||
"task_queries": data.task_queries,
|
||||
"mcp_install_count": data.mcp_install_count,
|
||||
"storage_used": data.storage_used
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/user/stat", name="record user stat")
|
||||
@traceroot.trace()
|
||||
def record_user_stat(
|
||||
data: UserStatActionIn,
|
||||
auth: Auth = Depends(auth_must),
|
||||
session: Session = Depends(session),
|
||||
):
|
||||
"""Record or update current user's operation statistics."""
|
||||
data.user_id = auth.user.id
|
||||
stat = UserStat.record_action(session, data)
|
||||
logger.info("User stat recorded", extra={"user_id": data.user_id, "action": data.action if hasattr(data, 'action') else "unknown"})
|
||||
return stat
|
||||
|
|
@ -1,24 +1,36 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.component import code
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from app.component.encrypt import password_hash, password_verify
|
||||
from app.exception.exception import UserException
|
||||
from app.model.user.user import UpdatePassword, UserOut
|
||||
from fastapi_babel import _
|
||||
|
||||
router = APIRouter(tags=["User"])
|
||||
|
||||
|
||||
@router.put("/user/update-password", name="update password", response_model=UserOut)
|
||||
def update_password(data: UpdatePassword, auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
model = auth.user
|
||||
if not password_verify(data.password, model.password):
|
||||
raise UserException(code.error, _("Password is incorrect"))
|
||||
if data.new_password != data.re_new_password:
|
||||
raise UserException(code.error, _("The two passwords do not match"))
|
||||
model.password = password_hash(data.new_password)
|
||||
model.save(session)
|
||||
return model
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.component import code
|
||||
from app.component.auth import Auth, auth_must
|
||||
from app.component.database import session
|
||||
from app.component.encrypt import password_hash, password_verify
|
||||
from app.exception.exception import UserException
|
||||
from app.model.user.user import UpdatePassword, UserOut
|
||||
from fastapi_babel import _
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("server_password_controller")
|
||||
|
||||
router = APIRouter(tags=["User"])
|
||||
|
||||
|
||||
@router.put("/user/update-password", name="update password", response_model=UserOut)
|
||||
@traceroot.trace()
|
||||
def update_password(data: UpdatePassword, auth: Auth = Depends(auth_must), session: Session = Depends(session)):
|
||||
"""Update user password after verifying current password."""
|
||||
user_id = auth.user.id
|
||||
model = auth.user
|
||||
|
||||
if not password_verify(data.password, model.password):
|
||||
logger.warning("Password update failed: incorrect current password", extra={"user_id": user_id})
|
||||
raise UserException(code.error, _("Password is incorrect"))
|
||||
|
||||
if data.new_password != data.re_new_password:
|
||||
logger.warning("Password update failed: new passwords do not match", extra={"user_id": user_id})
|
||||
raise UserException(code.error, _("The two passwords do not match"))
|
||||
|
||||
model.password = password_hash(data.new_password)
|
||||
model.save(session)
|
||||
logger.info("Password updated successfully", extra={"user_id": user_id})
|
||||
return model
|
||||
|
|
@ -4,7 +4,7 @@ from typing import Optional
|
|||
from enum import IntEnum
|
||||
from sqlalchemy_utils import ChoiceType
|
||||
from app.model.abstract.model import AbstractModel, DefaultTimes
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
class ChatStatus(IntEnum):
|
||||
|
|
@ -16,6 +16,7 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True):
|
|||
id: int = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(index=True)
|
||||
task_id: str = Field(index=True, unique=True)
|
||||
project_id: str = Field(index=True, unique=False, nullable=True)
|
||||
question: str
|
||||
language: str
|
||||
model_platform: str
|
||||
|
|
@ -34,6 +35,7 @@ class ChatHistory(AbstractModel, DefaultTimes, table=True):
|
|||
|
||||
class ChatHistoryIn(BaseModel):
|
||||
task_id: str
|
||||
project_id: str | None = None
|
||||
user_id: int | None = None
|
||||
question: str
|
||||
language: str
|
||||
|
|
@ -54,6 +56,7 @@ class ChatHistoryIn(BaseModel):
|
|||
class ChatHistoryOut(BaseModel):
|
||||
id: int
|
||||
task_id: str
|
||||
project_id: str | None = None
|
||||
question: str
|
||||
language: str
|
||||
model_platform: str
|
||||
|
|
@ -68,9 +71,17 @@ class ChatHistoryOut(BaseModel):
|
|||
tokens: int
|
||||
status: int
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_project_id_from_task_id(self):
|
||||
"""fill by task_id when project_id is None"""
|
||||
if self.project_id is None:
|
||||
self.project_id = self.task_id
|
||||
return self
|
||||
|
||||
|
||||
class ChatHistoryUpdate(BaseModel):
|
||||
project_name: str | None = None
|
||||
summary: str | None = None
|
||||
tokens: int | None = None
|
||||
status: int | None = None
|
||||
project_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,381 +1,383 @@
|
|||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Relationship, SQLModel, Field, Column, col, select, Session
|
||||
from sqlalchemy_utils import ChoiceType
|
||||
from sqlalchemy import Boolean, SmallInteger, text
|
||||
from app.model.abstract.model import AbstractModel, DefaultTimes
|
||||
from datetime import date, datetime, timedelta
|
||||
from app.model.user.key import ModelType
|
||||
from app.component.database import session_make
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class CreditsChannel(IntEnum):
|
||||
register = 1 # 注册赠送
|
||||
invite = 2 # 邀请赠送
|
||||
daily = 3 # 每日刷新积分
|
||||
monthly = 4 # 每月刷新积分
|
||||
paid = 5 # 付费积分
|
||||
addon = 6 # 加量包
|
||||
consume = 7 # 任务消费
|
||||
|
||||
|
||||
class CreditsPriority(IntEnum):
|
||||
daily = 1 # 每日刷新积分
|
||||
monthly = 2 # 每月刷新积分
|
||||
paid = 3 # 付费积分
|
||||
addon = 4 # 加量包
|
||||
|
||||
|
||||
class CreditsPoint(IntEnum):
|
||||
register = 1000
|
||||
invite = 500
|
||||
special_register = 1500 # 1000 register + 500 invite credit
|
||||
|
||||
|
||||
class UserCreditsRecord(AbstractModel, DefaultTimes, table=True):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(index=True, foreign_key="user.id")
|
||||
invite_by: int = Field(default=None, nullable=True, description="invite by user id")
|
||||
invite_code: str = Field(default="", max_length=255)
|
||||
amount: int = Field(default=0)
|
||||
balance: int = Field(default=0)
|
||||
channel: CreditsChannel = Field(
|
||||
default=CreditsChannel.register.value, sa_column=Column(ChoiceType(CreditsChannel, SmallInteger()))
|
||||
)
|
||||
source_id: int = Field(default=0, description="source id")
|
||||
remark: str = Field(default="", max_length=255)
|
||||
expire_at: datetime = Field(default=None, nullable=True, description="Expiration time")
|
||||
used: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, server_default=text("false")),
|
||||
description="Is this record used/expired",
|
||||
)
|
||||
used_at: datetime = Field(default=None, nullable=True, description="Time when this record was used/expired")
|
||||
|
||||
@classmethod
|
||||
def get_permanent_credits(cls, user_id: int) -> int:
|
||||
"""
|
||||
获取可用的token总量,直接用SQL聚合sum
|
||||
Returns:
|
||||
int: 可用的token总量
|
||||
"""
|
||||
session = session_make()
|
||||
from sqlalchemy import func
|
||||
|
||||
statement = (
|
||||
select(func.sum(UserCreditsRecord.amount))
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(
|
||||
UserCreditsRecord.channel.in_(
|
||||
[
|
||||
CreditsChannel.register,
|
||||
CreditsChannel.invite,
|
||||
CreditsChannel.paid,
|
||||
CreditsChannel.addon,
|
||||
CreditsChannel.monthly,
|
||||
]
|
||||
)
|
||||
)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where((UserCreditsRecord.expire_at.is_(None)) | (col(UserCreditsRecord.expire_at) > datetime.now()))
|
||||
)
|
||||
result = session.exec(statement).first()
|
||||
return result or 0
|
||||
|
||||
@classmethod
|
||||
def get_temp_credits(cls, user_id: int) -> tuple[int, date]:
|
||||
"""
|
||||
1. 获取可用的临时token总量,需要通过credits 然后根据model_type来计算
|
||||
2. 每天只允许赠送一次临时的量
|
||||
|
||||
Returns:
|
||||
int: 可用的临时token总量
|
||||
"""
|
||||
session = session_make()
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where(UserCreditsRecord.expire_at.is_not(None))
|
||||
.where(col(UserCreditsRecord.expire_at) > datetime.now())
|
||||
)
|
||||
record: UserCreditsRecord = session.exec(statement).first()
|
||||
if record is None:
|
||||
return 0, None
|
||||
return record.amount - record.balance, record.expire_at
|
||||
|
||||
@classmethod
|
||||
def consume_credits(cls, user_id: int, amount: int, session: Session, source_id: int = 0, remark: str = ""):
|
||||
"""
|
||||
消耗积分,优先消耗每日积分(daily),再消耗monthly、paid、addon等。
|
||||
消耗时更新UserCreditsRecord的balance字段,记录已消耗积分数。
|
||||
同时生成积分消耗记录,更新用户积分credits字段(不包括每日积分)。
|
||||
避免重复生成积分消耗记录和重复扣减积分。
|
||||
"""
|
||||
|
||||
# 检查是否已有积分消耗记录
|
||||
existing_consume_record = None
|
||||
if source_id > 0:
|
||||
existing_consume_record = session.exec(
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.consume)
|
||||
.where(UserCreditsRecord.source_id == source_id)
|
||||
).first()
|
||||
|
||||
if existing_consume_record:
|
||||
# 如果新amount更大,需要额外消耗积分
|
||||
if amount > 0:
|
||||
existing_consume_record.amount -= amount
|
||||
session.add(existing_consume_record)
|
||||
# 直接处理额外的积分消耗,不生成新的消耗记录
|
||||
cls._consume_credits_internal_update(user_id, amount, session, source_id, remark)
|
||||
# 如果新amount更小,需要退还积分(这里可以根据业务需求决定是否实现)
|
||||
else:
|
||||
# 暂时不实现退还逻辑,可以根据需要添加
|
||||
pass
|
||||
|
||||
session.commit()
|
||||
return
|
||||
|
||||
# 没有现有记录,执行正常的积分消耗流程
|
||||
cls._consume_credits_internal(user_id, amount, session, source_id, remark)
|
||||
|
||||
@classmethod
|
||||
def _consume_credits_internal(
|
||||
cls, user_id: int, amount: int, session: Session, source_id: int = 0, remark: str = ""
|
||||
):
|
||||
"""
|
||||
内部积分消耗逻辑,处理实际的积分扣减
|
||||
"""
|
||||
from app.model.user.user import User
|
||||
|
||||
remain = amount
|
||||
now = datetime.now()
|
||||
consumed_from_daily = 0
|
||||
consumed_from_other = 0
|
||||
|
||||
# 优先消耗daily
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where(UserCreditsRecord.expire_at.is_not(None))
|
||||
.where(col(UserCreditsRecord.expire_at) > now)
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
daily_records = session.exec(statement).first()
|
||||
if daily_records:
|
||||
can_consume = daily_records.amount - daily_records.balance
|
||||
use = min(remain, can_consume)
|
||||
daily_records.balance += use
|
||||
session.add(daily_records)
|
||||
remain -= use
|
||||
consumed_from_daily = use
|
||||
if remain == 0:
|
||||
# 生成积分消耗记录
|
||||
consume_record = UserCreditsRecord(
|
||||
user_id=user_id,
|
||||
amount=-amount,
|
||||
channel=CreditsChannel.consume,
|
||||
source_id=source_id,
|
||||
remark=remark or f"Consumed {amount} credits (daily: {consumed_from_daily})",
|
||||
)
|
||||
session.add(consume_record)
|
||||
session.commit()
|
||||
return
|
||||
|
||||
# 若daily不够,继续消耗monthly/paid/addon
|
||||
if remain > 0:
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(
|
||||
UserCreditsRecord.channel.in_(
|
||||
[
|
||||
CreditsChannel.monthly,
|
||||
CreditsChannel.paid,
|
||||
CreditsChannel.addon,
|
||||
CreditsChannel.register,
|
||||
CreditsChannel.invite,
|
||||
]
|
||||
)
|
||||
)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where((UserCreditsRecord.expire_at.is_(None)) | (col(UserCreditsRecord.expire_at) > now))
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
other_records = session.exec(statement).all()
|
||||
for record in other_records:
|
||||
can_consume = record.amount - record.balance
|
||||
if can_consume <= 0:
|
||||
continue
|
||||
use = min(remain, can_consume)
|
||||
record.balance += use
|
||||
session.add(record)
|
||||
remain -= use
|
||||
consumed_from_other += use
|
||||
if remain == 0:
|
||||
break
|
||||
|
||||
# 更新用户积分字段(只扣除非每日积分消耗的部分)
|
||||
if consumed_from_other > 0:
|
||||
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||
if user:
|
||||
user.credits -= consumed_from_other
|
||||
session.add(user)
|
||||
|
||||
# 生成积分消耗记录
|
||||
consume_record = UserCreditsRecord(
|
||||
user_id=user_id,
|
||||
amount=-amount,
|
||||
channel=CreditsChannel.consume,
|
||||
source_id=source_id,
|
||||
remark=remark or f"Consumed {amount} credits (daily: {consumed_from_daily}, other: {consumed_from_other})",
|
||||
)
|
||||
session.add(consume_record)
|
||||
session.commit()
|
||||
|
||||
if remain > 0:
|
||||
raise Exception(f"Insufficient credits: need {amount}, remain {remain}")
|
||||
|
||||
@classmethod
|
||||
def _consume_credits_internal_update(
|
||||
cls, user_id: int, amount: int, session: Session, source_id: int = 0, remark: str = ""
|
||||
):
|
||||
"""
|
||||
内部积分消耗逻辑(更新模式),处理实际的积分扣减但不生成新的消耗记录
|
||||
用于更新现有消耗记录时的额外积分消耗
|
||||
"""
|
||||
from app.model.user.user import User
|
||||
|
||||
remain = amount
|
||||
now = datetime.now()
|
||||
consumed_from_daily = 0
|
||||
consumed_from_other = 0
|
||||
|
||||
# 优先消耗daily
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where(UserCreditsRecord.expire_at.is_not(None))
|
||||
.where(col(UserCreditsRecord.expire_at) > now)
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
daily_records = session.exec(statement).first()
|
||||
if daily_records:
|
||||
can_consume = daily_records.amount - daily_records.balance
|
||||
use = min(remain, can_consume)
|
||||
daily_records.balance += use
|
||||
session.add(daily_records)
|
||||
remain -= use
|
||||
consumed_from_daily = use
|
||||
if remain == 0:
|
||||
# 不生成新的消耗记录,只更新现有记录
|
||||
return
|
||||
|
||||
# 若daily不够,继续消耗monthly/paid/addon
|
||||
if remain > 0:
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(
|
||||
UserCreditsRecord.channel.in_(
|
||||
[
|
||||
CreditsChannel.monthly,
|
||||
CreditsChannel.paid,
|
||||
CreditsChannel.addon,
|
||||
CreditsChannel.register,
|
||||
CreditsChannel.invite,
|
||||
]
|
||||
)
|
||||
)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where((UserCreditsRecord.expire_at.is_(None)) | (col(UserCreditsRecord.expire_at) > now))
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
other_records = session.exec(statement).all()
|
||||
for record in other_records:
|
||||
can_consume = record.amount - record.balance
|
||||
if can_consume <= 0:
|
||||
continue
|
||||
use = min(remain, can_consume)
|
||||
record.balance += use
|
||||
session.add(record)
|
||||
remain -= use
|
||||
consumed_from_other += use
|
||||
if remain == 0:
|
||||
break
|
||||
logger.info(f"consumed_from_other: {consumed_from_other}")
|
||||
# 更新用户积分字段(只扣除非每日积分消耗的部分)
|
||||
if consumed_from_other > 0:
|
||||
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||
if user:
|
||||
user.credits -= consumed_from_other
|
||||
session.add(user)
|
||||
|
||||
# 不生成新的消耗记录,因为现有记录已经在主函数中更新了
|
||||
|
||||
if remain > 0:
|
||||
raise Exception(f"Insufficient credits: need {amount}, remain {remain}")
|
||||
|
||||
@classmethod
|
||||
def get_daily_balance_sum(cls, user_id: int) -> int:
|
||||
"""
|
||||
获取用户所有每日积分(daily channel)的balance字段之和
|
||||
"""
|
||||
session = session_make()
|
||||
statement = (
|
||||
select(UserCreditsRecord.balance)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
)
|
||||
balances = session.exec(statement).all()
|
||||
return sum(balances) if balances else 0
|
||||
|
||||
@classmethod
|
||||
def get_daily_balance(cls, user_id: int) -> int:
|
||||
"""
|
||||
获取用户当前的每日积分数据
|
||||
"""
|
||||
session = session_make()
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
)
|
||||
record = session.exec(statement).first()
|
||||
return record
|
||||
|
||||
|
||||
class UserCreditsRecordWithChatOut(BaseModel):
|
||||
"""扩展的积分记录输出模型,包含聊天历史信息"""
|
||||
|
||||
amount: int
|
||||
balance: int
|
||||
channel: CreditsChannel
|
||||
source_id: int
|
||||
expire_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
# 聊天历史相关字段(当channel为consume且source_id有效时)
|
||||
chat_project_name: Optional[str] = None
|
||||
chat_tokens: Optional[int] = None
|
||||
|
||||
|
||||
class UserCreditsRecordOut(BaseModel):
|
||||
amount: int
|
||||
balance: int
|
||||
channel: CreditsChannel
|
||||
source_id: int
|
||||
remark: str
|
||||
expire_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Relationship, SQLModel, Field, Column, col, select, Session
|
||||
from sqlalchemy_utils import ChoiceType
|
||||
from sqlalchemy import Boolean, SmallInteger, text
|
||||
from app.model.abstract.model import AbstractModel, DefaultTimes
|
||||
from datetime import date, datetime, timedelta
|
||||
from app.model.user.key import ModelType
|
||||
from app.component.database import session_make
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
|
||||
logger = traceroot.get_logger("user_credits_record")
|
||||
|
||||
|
||||
class CreditsChannel(IntEnum):
|
||||
register = 1 # 注册赠送
|
||||
invite = 2 # 邀请赠送
|
||||
daily = 3 # 每日刷新积分
|
||||
monthly = 4 # 每月刷新积分
|
||||
paid = 5 # 付费积分
|
||||
addon = 6 # 加量包
|
||||
consume = 7 # 任务消费
|
||||
|
||||
|
||||
class CreditsPriority(IntEnum):
|
||||
daily = 1 # 每日刷新积分
|
||||
monthly = 2 # 每月刷新积分
|
||||
paid = 3 # 付费积分
|
||||
addon = 4 # 加量包
|
||||
|
||||
|
||||
class CreditsPoint(IntEnum):
|
||||
register = 1000
|
||||
invite = 500
|
||||
special_register = 1500 # 1000 register + 500 invite credit
|
||||
|
||||
|
||||
class UserCreditsRecord(AbstractModel, DefaultTimes, table=True):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(index=True, foreign_key="user.id")
|
||||
invite_by: int = Field(default=None, nullable=True, description="invite by user id")
|
||||
invite_code: str = Field(default="", max_length=255)
|
||||
amount: int = Field(default=0)
|
||||
balance: int = Field(default=0)
|
||||
channel: CreditsChannel = Field(
|
||||
default=CreditsChannel.register.value, sa_column=Column(ChoiceType(CreditsChannel, SmallInteger()))
|
||||
)
|
||||
source_id: int = Field(default=0, description="source id")
|
||||
remark: str = Field(default="", max_length=255)
|
||||
expire_at: datetime = Field(default=None, nullable=True, description="Expiration time")
|
||||
used: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, server_default=text("false")),
|
||||
description="Is this record used/expired",
|
||||
)
|
||||
used_at: datetime = Field(default=None, nullable=True, description="Time when this record was used/expired")
|
||||
|
||||
@classmethod
|
||||
def get_permanent_credits(cls, user_id: int) -> int:
|
||||
"""
|
||||
获取可用的token总量,直接用SQL聚合sum
|
||||
Returns:
|
||||
int: 可用的token总量
|
||||
"""
|
||||
session = session_make()
|
||||
from sqlalchemy import func
|
||||
|
||||
statement = (
|
||||
select(func.sum(UserCreditsRecord.amount))
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(
|
||||
UserCreditsRecord.channel.in_(
|
||||
[
|
||||
CreditsChannel.register,
|
||||
CreditsChannel.invite,
|
||||
CreditsChannel.paid,
|
||||
CreditsChannel.addon,
|
||||
CreditsChannel.monthly,
|
||||
]
|
||||
)
|
||||
)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where((UserCreditsRecord.expire_at.is_(None)) | (col(UserCreditsRecord.expire_at) > datetime.now()))
|
||||
)
|
||||
result = session.exec(statement).first()
|
||||
return result or 0
|
||||
|
||||
@classmethod
|
||||
def get_temp_credits(cls, user_id: int) -> tuple[int, date]:
|
||||
"""
|
||||
1. 获取可用的临时token总量,需要通过credits 然后根据model_type来计算
|
||||
2. 每天只允许赠送一次临时的量
|
||||
|
||||
Returns:
|
||||
int: 可用的临时token总量
|
||||
"""
|
||||
session = session_make()
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where(UserCreditsRecord.expire_at.is_not(None))
|
||||
.where(col(UserCreditsRecord.expire_at) > datetime.now())
|
||||
)
|
||||
record: UserCreditsRecord = session.exec(statement).first()
|
||||
if record is None:
|
||||
return 0, None
|
||||
return record.amount - record.balance, record.expire_at
|
||||
|
||||
@classmethod
|
||||
def consume_credits(cls, user_id: int, amount: int, session: Session, source_id: int = 0, remark: str = ""):
|
||||
"""
|
||||
消耗积分,优先消耗每日积分(daily),再消耗monthly、paid、addon等。
|
||||
消耗时更新UserCreditsRecord的balance字段,记录已消耗积分数。
|
||||
同时生成积分消耗记录,更新用户积分credits字段(不包括每日积分)。
|
||||
避免重复生成积分消耗记录和重复扣减积分。
|
||||
"""
|
||||
|
||||
# 检查是否已有积分消耗记录
|
||||
existing_consume_record = None
|
||||
if source_id > 0:
|
||||
existing_consume_record = session.exec(
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.consume)
|
||||
.where(UserCreditsRecord.source_id == source_id)
|
||||
).first()
|
||||
|
||||
if existing_consume_record:
|
||||
# 如果新amount更大,需要额外消耗积分
|
||||
if amount > 0:
|
||||
existing_consume_record.amount -= amount
|
||||
session.add(existing_consume_record)
|
||||
# 直接处理额外的积分消耗,不生成新的消耗记录
|
||||
cls._consume_credits_internal_update(user_id, amount, session, source_id, remark)
|
||||
# 如果新amount更小,需要退还积分(这里可以根据业务需求决定是否实现)
|
||||
else:
|
||||
# 暂时不实现退还逻辑,可以根据需要添加
|
||||
pass
|
||||
|
||||
session.commit()
|
||||
return
|
||||
|
||||
# 没有现有记录,执行正常的积分消耗流程
|
||||
cls._consume_credits_internal(user_id, amount, session, source_id, remark)
|
||||
|
||||
@classmethod
|
||||
def _consume_credits_internal(
|
||||
cls, user_id: int, amount: int, session: Session, source_id: int = 0, remark: str = ""
|
||||
):
|
||||
"""
|
||||
内部积分消耗逻辑,处理实际的积分扣减
|
||||
"""
|
||||
from app.model.user.user import User
|
||||
|
||||
remain = amount
|
||||
now = datetime.now()
|
||||
consumed_from_daily = 0
|
||||
consumed_from_other = 0
|
||||
|
||||
# 优先消耗daily
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where(UserCreditsRecord.expire_at.is_not(None))
|
||||
.where(col(UserCreditsRecord.expire_at) > now)
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
daily_records = session.exec(statement).first()
|
||||
if daily_records:
|
||||
can_consume = daily_records.amount - daily_records.balance
|
||||
use = min(remain, can_consume)
|
||||
daily_records.balance += use
|
||||
session.add(daily_records)
|
||||
remain -= use
|
||||
consumed_from_daily = use
|
||||
if remain == 0:
|
||||
# 生成积分消耗记录
|
||||
consume_record = UserCreditsRecord(
|
||||
user_id=user_id,
|
||||
amount=-amount,
|
||||
channel=CreditsChannel.consume,
|
||||
source_id=source_id,
|
||||
remark=remark or f"Consumed {amount} credits (daily: {consumed_from_daily})",
|
||||
)
|
||||
session.add(consume_record)
|
||||
session.commit()
|
||||
return
|
||||
|
||||
# 若daily不够,继续消耗monthly/paid/addon
|
||||
if remain > 0:
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(
|
||||
UserCreditsRecord.channel.in_(
|
||||
[
|
||||
CreditsChannel.monthly,
|
||||
CreditsChannel.paid,
|
||||
CreditsChannel.addon,
|
||||
CreditsChannel.register,
|
||||
CreditsChannel.invite,
|
||||
]
|
||||
)
|
||||
)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where((UserCreditsRecord.expire_at.is_(None)) | (col(UserCreditsRecord.expire_at) > now))
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
other_records = session.exec(statement).all()
|
||||
for record in other_records:
|
||||
can_consume = record.amount - record.balance
|
||||
if can_consume <= 0:
|
||||
continue
|
||||
use = min(remain, can_consume)
|
||||
record.balance += use
|
||||
session.add(record)
|
||||
remain -= use
|
||||
consumed_from_other += use
|
||||
if remain == 0:
|
||||
break
|
||||
|
||||
# 更新用户积分字段(只扣除非每日积分消耗的部分)
|
||||
if consumed_from_other > 0:
|
||||
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||
if user:
|
||||
user.credits -= consumed_from_other
|
||||
session.add(user)
|
||||
|
||||
# 生成积分消耗记录
|
||||
consume_record = UserCreditsRecord(
|
||||
user_id=user_id,
|
||||
amount=-amount,
|
||||
channel=CreditsChannel.consume,
|
||||
source_id=source_id,
|
||||
remark=remark or f"Consumed {amount} credits (daily: {consumed_from_daily}, other: {consumed_from_other})",
|
||||
)
|
||||
session.add(consume_record)
|
||||
session.commit()
|
||||
|
||||
if remain > 0:
|
||||
raise Exception(f"Insufficient credits: need {amount}, remain {remain}")
|
||||
|
||||
@classmethod
|
||||
def _consume_credits_internal_update(
|
||||
cls, user_id: int, amount: int, session: Session, source_id: int = 0, remark: str = ""
|
||||
):
|
||||
"""
|
||||
内部积分消耗逻辑(更新模式),处理实际的积分扣减但不生成新的消耗记录
|
||||
用于更新现有消耗记录时的额外积分消耗
|
||||
"""
|
||||
from app.model.user.user import User
|
||||
|
||||
remain = amount
|
||||
now = datetime.now()
|
||||
consumed_from_daily = 0
|
||||
consumed_from_other = 0
|
||||
|
||||
# 优先消耗daily
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where(UserCreditsRecord.expire_at.is_not(None))
|
||||
.where(col(UserCreditsRecord.expire_at) > now)
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
daily_records = session.exec(statement).first()
|
||||
if daily_records:
|
||||
can_consume = daily_records.amount - daily_records.balance
|
||||
use = min(remain, can_consume)
|
||||
daily_records.balance += use
|
||||
session.add(daily_records)
|
||||
remain -= use
|
||||
consumed_from_daily = use
|
||||
if remain == 0:
|
||||
# 不生成新的消耗记录,只更新现有记录
|
||||
return
|
||||
|
||||
# 若daily不够,继续消耗monthly/paid/addon
|
||||
if remain > 0:
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(
|
||||
UserCreditsRecord.channel.in_(
|
||||
[
|
||||
CreditsChannel.monthly,
|
||||
CreditsChannel.paid,
|
||||
CreditsChannel.addon,
|
||||
CreditsChannel.register,
|
||||
CreditsChannel.invite,
|
||||
]
|
||||
)
|
||||
)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
.where((UserCreditsRecord.expire_at.is_(None)) | (col(UserCreditsRecord.expire_at) > now))
|
||||
.order_by(UserCreditsRecord.expire_at)
|
||||
)
|
||||
other_records = session.exec(statement).all()
|
||||
for record in other_records:
|
||||
can_consume = record.amount - record.balance
|
||||
if can_consume <= 0:
|
||||
continue
|
||||
use = min(remain, can_consume)
|
||||
record.balance += use
|
||||
session.add(record)
|
||||
remain -= use
|
||||
consumed_from_other += use
|
||||
if remain == 0:
|
||||
break
|
||||
logger.info(f"consumed_from_other: {consumed_from_other}")
|
||||
# 更新用户积分字段(只扣除非每日积分消耗的部分)
|
||||
if consumed_from_other > 0:
|
||||
user = session.exec(select(User).where(User.id == user_id)).first()
|
||||
if user:
|
||||
user.credits -= consumed_from_other
|
||||
session.add(user)
|
||||
|
||||
# 不生成新的消耗记录,因为现有记录已经在主函数中更新了
|
||||
|
||||
if remain > 0:
|
||||
raise Exception(f"Insufficient credits: need {amount}, remain {remain}")
|
||||
|
||||
@classmethod
|
||||
def get_daily_balance_sum(cls, user_id: int) -> int:
|
||||
"""
|
||||
获取用户所有每日积分(daily channel)的balance字段之和
|
||||
"""
|
||||
session = session_make()
|
||||
statement = (
|
||||
select(UserCreditsRecord.balance)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
)
|
||||
balances = session.exec(statement).all()
|
||||
return sum(balances) if balances else 0
|
||||
|
||||
@classmethod
|
||||
def get_daily_balance(cls, user_id: int) -> int:
|
||||
"""
|
||||
获取用户当前的每日积分数据
|
||||
"""
|
||||
session = session_make()
|
||||
statement = (
|
||||
select(UserCreditsRecord)
|
||||
.where(UserCreditsRecord.user_id == user_id)
|
||||
.where(UserCreditsRecord.channel == CreditsChannel.daily)
|
||||
.where(UserCreditsRecord.used == False)
|
||||
)
|
||||
record = session.exec(statement).first()
|
||||
return record
|
||||
|
||||
|
||||
class UserCreditsRecordWithChatOut(BaseModel):
|
||||
"""扩展的积分记录输出模型,包含聊天历史信息"""
|
||||
|
||||
amount: int
|
||||
balance: int
|
||||
channel: CreditsChannel
|
||||
source_id: int
|
||||
expire_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
# 聊天历史相关字段(当channel为consume且source_id有效时)
|
||||
chat_project_name: Optional[str] = None
|
||||
chat_tokens: Optional[int] = None
|
||||
|
||||
|
||||
class UserCreditsRecordOut(BaseModel):
|
||||
amount: int
|
||||
balance: int
|
||||
channel: CreditsChannel
|
||||
source_id: int
|
||||
remark: str
|
||||
expire_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
from app import api
|
||||
from app.component.environment import auto_include_routers, env
|
||||
from loguru import logger
|
||||
import os
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
prefix = env("url_prefix", "")
|
||||
auto_include_routers(api, prefix, "app/controller")
|
||||
public_dir = os.environ.get("PUBLIC_DIR") or os.path.join(os.path.dirname(__file__), "app", "public")
|
||||
# Ensure static directory exists or gracefully skip mounting
|
||||
if not os.path.isdir(public_dir):
|
||||
try:
|
||||
os.makedirs(public_dir, exist_ok=True)
|
||||
logger.warning(f"Public directory did not exist. Created: {public_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Public directory missing and could not be created: {public_dir}. Error: {e}")
|
||||
public_dir = None
|
||||
|
||||
if public_dir and os.path.isdir(public_dir):
|
||||
api.mount("/public", StaticFiles(directory=public_dir), name="public")
|
||||
else:
|
||||
logger.warning("Skipping /public mount because public directory is unavailable")
|
||||
|
||||
logger.add(
|
||||
"runtime/log/app.log",
|
||||
rotation="10 MB",
|
||||
retention="10 days",
|
||||
level="DEBUG",
|
||||
enqueue=True,
|
||||
)
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
|
||||
# Add project root to Python path to import shared utils
|
||||
_project_root = pathlib.Path(__file__).parent.parent
|
||||
if str(_project_root) not in sys.path:
|
||||
sys.path.insert(0, str(_project_root))
|
||||
|
||||
from utils import traceroot_wrapper as traceroot
|
||||
from app import api
|
||||
from app.component.environment import auto_include_routers, env
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
# Only initialize traceroot if enabled
|
||||
if traceroot.is_enabled():
|
||||
from traceroot.integrations.fastapi import connect_fastapi
|
||||
connect_fastapi(api)
|
||||
|
||||
logger = traceroot.get_logger("server_main")
|
||||
|
||||
prefix = env("url_prefix", "")
|
||||
auto_include_routers(api, prefix, "app/controller")
|
||||
public_dir = os.environ.get("PUBLIC_DIR") or os.path.join(os.path.dirname(__file__), "app", "public")
|
||||
if not os.path.isdir(public_dir):
|
||||
try:
|
||||
os.makedirs(public_dir, exist_ok=True)
|
||||
logger.warning(f"Public directory did not exist. Created: {public_dir}")
|
||||
except Exception as e:
|
||||
logger.error(f"Public directory missing and could not be created: {public_dir}. Error: {e}")
|
||||
public_dir = None
|
||||
|
||||
if public_dir and os.path.isdir(public_dir):
|
||||
api.mount("/public", StaticFiles(directory=public_dir), name="public")
|
||||
else:
|
||||
logger.warning("Skipping /public mount because public directory is unavailable")
|
||||
|
|
|
|||
|
|
@ -1,40 +1,41 @@
|
|||
[project]
|
||||
name = "Eigent"
|
||||
version = "0.1.0"
|
||||
description = "Eigent"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.15.2",
|
||||
"click>=8.1.8",
|
||||
"fastapi>=0.115.12",
|
||||
"fastapi-babel>=1.0.0",
|
||||
"fastapi-pagination>=0.12.34",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"bcrypt==4.0.1",
|
||||
"pydantic-i18n>=0.4.5",
|
||||
"pydantic[email]>=2.11.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"sqlalchemy-utils>=0.41.2",
|
||||
"sqlmodel>=0.0.24",
|
||||
"pandas>=2.2.3",
|
||||
"openpyxl>=3.1.5",
|
||||
"pandas>=2.2.3",
|
||||
"arrow>=1.3.0",
|
||||
"fastapi-filter>=2.0.1",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"convert-case>=1.2.3",
|
||||
"python-multipart>=0.0.20",
|
||||
"loguru>=0.7.3",
|
||||
"httpx>=0.28.1",
|
||||
"pydash>=8.0.5",
|
||||
"requests>=2.32.4",
|
||||
"itsdangerous>=2.2.0",
|
||||
"cryptography>=45.0.4",
|
||||
"sqids>=0.5.2",
|
||||
"exa-py>=1.14.16",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
[project]
|
||||
name = "Eigent"
|
||||
version = "0.1.0"
|
||||
description = "Eigent"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.15.2",
|
||||
"openai>=1.99.3,<2",
|
||||
"camel-ai==0.2.76a13",
|
||||
"pydantic[email]>=2.11.1",
|
||||
"click>=8.1.8",
|
||||
"fastapi>=0.115.12",
|
||||
"fastapi-babel>=1.0.0",
|
||||
"fastapi-pagination>=0.12.34",
|
||||
"passlib[bcrypt]>=1.7.4",
|
||||
"bcrypt==4.0.1",
|
||||
"pydantic-i18n>=0.4.5",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-dotenv>=1.1.0",
|
||||
"sqlalchemy-utils>=0.41.2",
|
||||
"sqlmodel>=0.0.24",
|
||||
"pandas>=2.2.3",
|
||||
"openpyxl>=3.1.5",
|
||||
"arrow>=1.3.0",
|
||||
"fastapi-filter>=2.0.1",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"convert-case>=1.2.3",
|
||||
"python-multipart>=0.0.20",
|
||||
"httpx>=0.28.1",
|
||||
"pydash>=8.0.5",
|
||||
"requests>=2.32.4",
|
||||
"itsdangerous>=2.2.0",
|
||||
"cryptography>=45.0.4",
|
||||
"sqids>=0.5.2",
|
||||
"exa-py>=1.14.16",
|
||||
"traceroot>=0.0.7",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
|
|
|||
0
server/utils/__init__.py
Normal file
1353
server/uv.lock
generated
|
|
@ -170,7 +170,6 @@ async function proxyFetchRequest(
|
|||
...customHeaders,
|
||||
}
|
||||
|
||||
console.debug('url', url, token)
|
||||
if (!url.includes('http://') && !url.includes('https://') && token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
6
src/assets/gift.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12V19C19 19.5304 18.7893 20.0391 18.4142 20.4142C18.0391 20.7893 17.5304 21 17 21H7C6.46957 21 5.96086 20.7893 5.58579 20.4142C5.21071 20.0391 5 19.5304 5 19V12M7.5 7.99995C6.83696 7.99995 6.20107 7.73656 5.73223 7.26772C5.26339 6.79887 5 6.16299 5 5.49995C5 4.83691 5.26339 4.20102 5.73223 3.73218C6.20107 3.26334 6.83696 2.99995 7.5 2.99995C8.46469 2.98314 9.41003 3.45121 10.2127 4.34311C11.0154 5.23501 11.6383 6.5094 12 8C12.3617 6.5094 12.9846 5.23501 13.7873 4.34311C14.59 3.45121 15.5353 2.98314 16.5 2.99995C17.163 2.99995 17.7989 3.26334 18.2678 3.73218C18.7366 4.20102 19 4.83691 19 5.49995C19 6.16299 18.7366 6.79887 18.2678 7.26772C17.7989 7.73656 17.163 7.99995 16.5 7.99995M4 8H20C20.5523 8 21 8.44772 21 9V11C21 11.5523 20.5523 12 20 12H4C3.44772 12 3 11.5523 3 11V9C3 8.44772 3.44772 8 4 8Z" stroke="#222222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.37815 17.9679C8.11529 17.9679 7.88095 17.9189 7.67513 17.8208C7.47054 17.7228 7.30812 17.5876 7.18785 17.4154C7.06757 17.2431 7.00496 17.0457 7 16.8232H7.78114C7.78982 16.9728 7.85181 17.0941 7.96713 17.1872C8.08244 17.2802 8.21945 17.3267 8.37815 17.3267C8.50462 17.3267 8.61621 17.2984 8.71293 17.2419C8.81088 17.184 8.88713 17.1042 8.94169 17.0024C8.99749 16.8993 9.02538 16.7811 9.02538 16.6478C9.02538 16.512 8.99687 16.3926 8.93983 16.2895C8.88403 16.1864 8.80654 16.1059 8.70735 16.0481C8.60816 15.9903 8.4947 15.9607 8.36699 15.9595C8.2554 15.9595 8.14691 15.9827 8.04152 16.0292C7.93737 16.0758 7.85615 16.1392 7.79788 16.2197L7.08183 16.0896L7.26224 14.0528H9.59078V14.7204H7.92621L7.82764 15.6879H7.84995C7.91691 15.5923 8.01796 15.5131 8.15311 15.4503C8.28826 15.3874 8.43953 15.356 8.60692 15.356C8.8363 15.356 9.04088 15.4107 9.22067 15.52C9.40045 15.6294 9.54242 15.7797 9.64658 15.9708C9.75073 16.1606 9.80218 16.3794 9.80094 16.6271C9.80218 16.8873 9.74267 17.1187 9.6224 17.3211C9.50337 17.5222 9.3366 17.6807 9.1221 17.7963C8.90883 17.9107 8.66085 17.9679 8.37815 17.9679Z" fill="#222222"/>
|
||||
<path d="M11.8561 18C11.5362 17.9987 11.2609 17.9189 11.0303 17.7605C10.8009 17.6021 10.6242 17.3726 10.5002 17.0721C10.3775 16.7716 10.3167 16.4102 10.318 15.9877C10.318 15.5666 10.3794 15.2076 10.5021 14.9109C10.6261 14.6142 10.8028 14.3885 11.0322 14.2339C11.2628 14.078 11.5374 14 11.8561 14C12.1747 14 12.4488 14.078 12.6781 14.2339C12.9088 14.3898 13.0861 14.6161 13.2101 14.9128C13.3341 15.2082 13.3954 15.5666 13.3942 15.9877C13.3942 16.4114 13.3322 16.7735 13.2082 17.074C13.0854 17.3745 12.9094 17.604 12.68 17.7624C12.4506 17.9208 12.176 18 11.8561 18ZM11.8561 17.323C12.0743 17.323 12.2485 17.2117 12.3787 16.9892C12.5089 16.7666 12.5734 16.4328 12.5721 15.9877C12.5721 15.6948 12.5424 15.4509 12.4829 15.256C12.4246 15.0611 12.3415 14.9147 12.2336 14.8166C12.127 14.7185 12.0012 14.6695 11.8561 14.6695C11.6391 14.6695 11.4655 14.7795 11.3353 14.9995C11.2051 15.2196 11.1394 15.549 11.1382 15.9877C11.1382 16.2845 11.1673 16.5321 11.2256 16.7308C11.2851 16.9282 11.3688 17.0765 11.4767 17.1759C11.5845 17.2739 11.711 17.323 11.8561 17.323Z" fill="#222222"/>
|
||||
<path d="M15.4619 18C15.142 17.9987 14.8667 17.9189 14.6361 17.7605C14.4067 17.6021 14.23 17.3726 14.106 17.0721C13.9833 16.7716 13.9225 16.4102 13.9238 15.9877C13.9238 15.5666 13.9852 15.2076 14.1079 14.9109C14.2319 14.6142 14.4086 14.3885 14.638 14.2339C14.8686 14.078 15.1432 14 15.4619 14C15.7805 14 16.0546 14.078 16.2839 14.2339C16.5146 14.3898 16.6919 14.6161 16.8159 14.9128C16.9398 15.2082 17.0012 15.5666 17 15.9877C17 16.4114 16.938 16.7735 16.814 17.074C16.6912 17.3745 16.5152 17.604 16.2858 17.7624C16.0564 17.9208 15.7818 18 15.4619 18ZM15.4619 17.323C15.6801 17.323 15.8543 17.2117 15.9845 16.9892C16.1147 16.7666 16.1792 16.4328 16.1779 15.9877C16.1779 15.6948 16.1482 15.4509 16.0887 15.256C16.0304 15.0611 15.9473 14.9147 15.8394 14.8166C15.7328 14.7185 15.6069 14.6695 15.4619 14.6695C15.2449 14.6695 15.0713 14.7795 14.9411 14.9995C14.8109 15.2196 14.7452 15.549 14.744 15.9877C14.744 16.2845 14.7731 16.5321 14.8314 16.7308C14.8909 16.9282 14.9746 17.0765 15.0825 17.1759C15.1903 17.2739 15.3168 17.323 15.4619 17.323Z" fill="#222222"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 160 KiB |
|
|
@ -429,10 +429,10 @@ export default function IntegrationList({
|
|||
"Reddit",
|
||||
"Github",
|
||||
].includes(item.name)
|
||||
? t("setting.coming-soon")
|
||||
? t("layout.coming-soon")
|
||||
: isInstalled
|
||||
? t("setting.uninstall")
|
||||
: t("setting.install")}
|
||||
? t("layout.uninstall")
|
||||
: t("layout.install")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { CircleAlert, Store, X } from "lucide-react";
|
||||
import { proxyFetchGet, proxyFetchPost, proxyFetchPut, fetchPost } from "@/api/http";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Button } from "../ui/button";
|
||||
import githubIcon from "@/assets/github.svg";
|
||||
import { TooltipSimple } from "../ui/tooltip";
|
||||
|
|
@ -167,13 +168,13 @@ const ToolSelect = forwardRef<
|
|||
toolkit: value.toolkit,
|
||||
desc:
|
||||
value.env_vars && value.env_vars.length > 0
|
||||
? `Environmental variables required: ${value.env_vars.join(
|
||||
? `${t("layout.environmental-variables-required")} ${value.env_vars.join(
|
||||
", "
|
||||
)}`
|
||||
: key.toLowerCase() === 'notion'
|
||||
? "Notion workspace integration for reading and managing Notion pages"
|
||||
? t("layout.notion-workspace-integration")
|
||||
: key.toLowerCase() === 'google calendar'
|
||||
? "Google Calendar integration for managing events and schedules"
|
||||
? t("layout.google-calendar-integration")
|
||||
: "",
|
||||
onInstall,
|
||||
};
|
||||
|
|
@ -184,7 +185,7 @@ const ToolSelect = forwardRef<
|
|||
};
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -445,10 +446,10 @@ const ToolSelect = forwardRef<
|
|||
};
|
||||
|
||||
const getInstallButtonText = (itemId: number) => {
|
||||
if (installedIds.includes(itemId)) return t("setting.installed");
|
||||
if (installing[itemId]) return t("setting.installing");
|
||||
if (installed[itemId]) return t("setting.installed");
|
||||
return t("setting.install");
|
||||
if (installedIds.includes(itemId)) return t("layout.installed");
|
||||
if (installing[itemId]) return t("layout.installing");
|
||||
if (installed[itemId]) return t("layout.installed");
|
||||
return t("layout.install");
|
||||
};
|
||||
|
||||
// Effects
|
||||
|
|
@ -606,36 +607,43 @@ const ToolSelect = forwardRef<
|
|||
className="leading-17 text-xs font-bold text-button-secondary-text-default h-6 px-sm py-xs bg-button-secondary-fill-default hover:bg-button-tertiery-text-default rounded-md shadow-sm"
|
||||
disabled={true}
|
||||
>
|
||||
{t("setting.installed")}
|
||||
{t("layout.installed")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="w-full relative" ref={containerRef}>
|
||||
<div className="flex flex-wrap gap-1 min-h-[40px] border rounded-lg bg-white">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[40px] border rounded-lg bg-white">
|
||||
<div className="text-text-body text-sm leading-normal font-bold flex items-center gap-1">
|
||||
{t("workforce.agent-tool")}
|
||||
<TooltipSimple content={t("workforce.agent-tool-tooltip")}>
|
||||
<CircleAlert size={16} className="text-icon-primary" />
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
inputRef.current?.focus();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className="flex flex-wrap gap-1 justify-start px-[6px] py-1 min-h-[60px] max-h-[120px] overflow-y-auto w-full rounded-sm border border-solid border-input-border-default bg-input-bg-default !shadow-none text-sm leading-normal"
|
||||
className="flex flex-wrap gap-1 justify-start px-[6px] py-1 min-h-[60px] max-h-[120px] overflow-y-auto w-full rounded-lg border border-solid border-input-border-default bg-input-bg-default"
|
||||
>
|
||||
{renderSelectedItems()}
|
||||
<Input
|
||||
<Textarea
|
||||
variant="none"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
ref={inputRef}
|
||||
className="bg-transparent border-none !shadow-none text-sm leading-normal !ring-0 !ring-offset-0 w-auto !h-[20px] p-0"
|
||||
className="bg-transparent border-none !shadow-none text-sm leading-normal !ring-0 !ring-offset-0 w-auto !h-[20px] p-0 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* floating dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-dropdown-bg">
|
||||
<div className="border rounded-lg shadow-lg bg-white max-h-[192px] overflow-y-auto">
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-dropdown-bg rounded-lg border border-solid border-input-border-default overflow-y-auto">
|
||||
<div className="max-h-[192px] overflow-y-auto">
|
||||
<IntegrationList
|
||||
onShowEnvConfig={onShowEnvConfig}
|
||||
addOption={addOption}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import {
|
|||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogContentSection,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -23,10 +23,10 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { useState, useRef } from "react";
|
||||
import githubIcon from "@/assets/github.svg";
|
||||
import { fetchPost } from "@/api/http";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { useAuthStore, useWorkerList } from "@/store/authStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TooltipSimple } from "../ui/tooltip";
|
||||
import useChatStoreAdapter from "@/hooks/useChatStoreAdapter";
|
||||
|
||||
interface EnvValue {
|
||||
value: string;
|
||||
|
|
@ -58,8 +58,13 @@ export function AddWorker({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const activeTaskId = useChatStore((state) => state.activeTaskId);
|
||||
const tasks = useChatStore((state) => state.tasks);
|
||||
const { chatStore, projectStore } = useChatStoreAdapter();
|
||||
if (!chatStore) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
const activeProjectId = projectStore.activeProjectId;
|
||||
const activeTaskId = chatStore.activeTaskId;
|
||||
const tasks = chatStore.tasks;
|
||||
const [showEnvConfig, setShowEnvConfig] = useState(false);
|
||||
const [activeMcp, setActiveMcp] = useState<McpItem | null>(null);
|
||||
const [envValues, setEnvValues] = useState<{ [key: string]: EnvValue }>({});
|
||||
|
|
@ -254,7 +259,7 @@ export function AddWorker({
|
|||
};
|
||||
setWorkerList([...workerList, worker]);
|
||||
} else {
|
||||
fetchPost(`/task/${activeTaskId}/add-agent`, {
|
||||
fetchPost(`/task/${activeProjectId}/add-agent`, {
|
||||
name: workerName,
|
||||
description: workerDescription,
|
||||
tools: localTool,
|
||||
|
|
@ -314,33 +319,19 @@ export function AddWorker({
|
|||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px] p-0 !bg-popup-surface gap-0 !rounded-xl border border-zinc-300 shadow-sm">
|
||||
<DialogHeader className="!bg-popup-surface !rounded-t-xl p-md">
|
||||
<DialogTitle className="m-0">
|
||||
<div className="flex gap-xs items-center justify-start">
|
||||
{showEnvConfig && (
|
||||
<ChevronLeft
|
||||
onClick={handleCloseMcpEnvSetting}
|
||||
size={16}
|
||||
className="text-icon-primary cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<div className="text-base font-bold leading-10 text-text-action">
|
||||
{showEnvConfig
|
||||
? t("workforce.configure-mcp-server")
|
||||
: t("workforce.add-your-agent")}
|
||||
</div>
|
||||
<TooltipSimple content="Configure your MCP worker node here.">
|
||||
<CircleAlert size={16} />
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent size="sm" className="p-0 gap-0">
|
||||
<DialogHeader
|
||||
title={showEnvConfig ? t("workforce.configure-mcp-server") : t("workforce.add-your-agent")}
|
||||
tooltip={t("layout.configure-your-mcp-worker-node-here")}
|
||||
showTooltip={true}
|
||||
showBackButton={showEnvConfig}
|
||||
onBackClick={handleCloseMcpEnvSetting}
|
||||
/>
|
||||
|
||||
{showEnvConfig ? (
|
||||
// environment configuration interface
|
||||
<>
|
||||
<div className="flex flex-col gap-3 bg-white-100% p-md">
|
||||
<DialogContentSection className="flex flex-col gap-3 bg-white-100% p-md">
|
||||
<div className="flex gap-md items-center">
|
||||
{getCategoryIcon(activeMcp?.category?.name)}
|
||||
<div>
|
||||
|
|
@ -389,19 +380,19 @@ export function AddWorker({
|
|||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="bg-white-100% !rounded-b-xl p-md">
|
||||
<Button
|
||||
onClick={handleCloseMcpEnvSetting}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t("workforce.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfigureMcpEnvSetting}>
|
||||
<span>{t("Connect")}</span>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</DialogContentSection>
|
||||
<DialogFooter
|
||||
className="bg-white-100% !rounded-b-xl p-md"
|
||||
showCancelButton={true}
|
||||
showConfirmButton={true}
|
||||
cancelButtonText={t("workforce.cancel")}
|
||||
confirmButtonText={t("layout.connect")}
|
||||
onCancel={handleCloseMcpEnvSetting}
|
||||
onConfirm={handleConfigureMcpEnvSetting}
|
||||
cancelButtonVariant="ghost"
|
||||
confirmButtonVariant="primary"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</DialogFooter>
|
||||
{/* hidden but keep rendering ToolSelect component */}
|
||||
<div style={{ display: "none" }}>
|
||||
|
|
@ -416,72 +407,64 @@ export function AddWorker({
|
|||
) : (
|
||||
// default add interface
|
||||
<>
|
||||
<div className="flex flex-col gap-3 bg-white-100% p-md">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-sm pb-md border-[0px] border-b border-solid border-border-secondary">
|
||||
<DialogContentSection className="flex flex-col gap-3 bg-white-100% p-md">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-sm">
|
||||
<div className="flex w-16 h-16 items-center justify-center">
|
||||
<Bot size={32} className="text-icon-primary" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Agent Name"
|
||||
size="sm"
|
||||
title={t("layout.name-your-agent")}
|
||||
placeholder={t("layout.add-an-agent-name")}
|
||||
value={workerName}
|
||||
onChange={(e) => {
|
||||
setWorkerName(e.target.value);
|
||||
// when user starts input, clear error
|
||||
if (nameError) setNameError("");
|
||||
}}
|
||||
className={`!border-none !bg-transparent !shadow-none text-xl leading-2xl font-bold !ring-0 !ring-offset-0 ${
|
||||
nameError ? "border-red-500" : ""
|
||||
}`}
|
||||
state={nameError ? "error" : "default"}
|
||||
note={nameError || ""}
|
||||
backIcon={<RefreshCw size={16} className="text-button-transparent-icon-disabled" />}
|
||||
onBackIconClick={() => {
|
||||
// Handle refresh/regenerate logic here
|
||||
console.log("Refresh agent name");
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className="text-button-transparent-icon-disabled"
|
||||
/>
|
||||
</div>
|
||||
{nameError && (
|
||||
<div className="text-red-500 text-sm font-medium">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-sm ">
|
||||
<div className="text-text-body text-sm leading-normal font-bold">
|
||||
{t("workforce.description-optional")}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="I'm an agent specially designed for..."
|
||||
|
||||
<Textarea
|
||||
variant="enhanced"
|
||||
size="sm"
|
||||
title={t("workforce.description-optional")}
|
||||
placeholder={t("layout.im-an-agent-specially-designed-for")}
|
||||
value={workerDescription}
|
||||
onChange={(e) => setWorkerDescription(e.target.value)}
|
||||
className="rounded-sm border border-solid border-input-border-default bg-input-bg-default !shadow-none text-sm leading-normal !ring-0 !ring-offset-0 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-sm ">
|
||||
<div className="text-text-body text-sm leading-normal font-bold">
|
||||
{t("workforce.agent-tool")}
|
||||
</div>
|
||||
<TooltipSimple content="Select MCP tools for your worker node.">
|
||||
<CircleAlert size={16} />
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
|
||||
<ToolSelect
|
||||
onShowEnvConfig={handleShowEnvConfig}
|
||||
onSelectedToolsChange={handleSelectedToolsChange}
|
||||
initialSelectedTools={selectedTools}
|
||||
ref={toolSelectRef}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="bg-white-100% !rounded-b-xl p-md">
|
||||
<DialogClose asChild>
|
||||
<Button onClick={resetForm} variant="ghost" size="sm">
|
||||
{t("workforce.cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button size="sm" onClick={handleAddWorker} type="submit">
|
||||
<span>{t("workforce.save-changes")}</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContentSection>
|
||||
<DialogFooter
|
||||
className="bg-white-100% !rounded-b-xl p-md"
|
||||
showCancelButton={true}
|
||||
showConfirmButton={true}
|
||||
cancelButtonText={t("workforce.cancel")}
|
||||
confirmButtonText={t("workforce.save-changes")}
|
||||
onCancel={() => {
|
||||
resetForm();
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
onConfirm={handleAddWorker}
|
||||
cancelButtonVariant="ghost"
|
||||
confirmButtonVariant="primary"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { WorkSpaceMenu } from "@/components/WorkSpaceMenu";
|
|||
|
||||
function BottomBar() {
|
||||
return (
|
||||
<div className="flex h-12 items-center justify-center px-2 py-1 z-50 relative">
|
||||
<div className="flex h-12 items-center justify-center pb-2 z-50 relative">
|
||||
<WorkSpaceMenu />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
57
src/components/ChatBox/BottomBox/BoxAction.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { CirclePlay, CirclePause, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BoxActionProps {
|
||||
/** Token count to display */
|
||||
tokens: number;
|
||||
/** Whether replay is allowed (e.g., only when task finished) */
|
||||
disabled?: boolean;
|
||||
/** Loading state for replay action */
|
||||
loading?: boolean;
|
||||
/** Callback when replay button is clicked */
|
||||
onReplay?: () => void;
|
||||
/** Optional right-side content to replace replay */
|
||||
rightContent?: React.ReactNode;
|
||||
/** Task status for determining what button to show */
|
||||
status?: 'running' | 'finished' | 'pending' | 'pause';
|
||||
/** Task time display */
|
||||
taskTime?: string;
|
||||
/** Callback for pause/resume */
|
||||
onPauseResume?: () => void;
|
||||
/** Loading state for pause/resume */
|
||||
pauseResumeLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BoxAction({
|
||||
tokens,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onReplay,
|
||||
rightContent,
|
||||
status,
|
||||
taskTime,
|
||||
onPauseResume,
|
||||
pauseResumeLoading = false,
|
||||
className,
|
||||
}: BoxActionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-sm z-50 pl-4 ${className || ""}`}>
|
||||
<div className="text-text-information text-xs font-semibold leading-17">
|
||||
# {t("chat.token")} {tokens || 0}</div>
|
||||
|
||||
<Button
|
||||
onClick={onReplay}
|
||||
disabled={disabled}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{t("chat.replay")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
src/components/ChatBox/BottomBox/BoxHeader.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimateIcon } from "@/components/animate-ui/icons/icon";
|
||||
import { Orbit } from "@/components/animate-ui/icons/orbit";
|
||||
|
||||
/**
|
||||
* Variant: Splitting
|
||||
*/
|
||||
export interface BoxHeaderSplittingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BoxHeaderSplitting = ({ className }: BoxHeaderSplittingProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 items-start justify-center w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="box-border flex gap-1 items-center px-2.5 py-0 relative w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1 focus-visible:outline-none focus:ring-0"
|
||||
>
|
||||
<AnimateIcon animate loop className="justify-center items-center h-4 w-4">
|
||||
<Orbit size={16} className="text-icon-information" />
|
||||
</AnimateIcon>
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 flex gap-0.5 items-center min-h-px min-w-px relative">
|
||||
<span className="font-bold text-text-information text-sm whitespace-nowrap">
|
||||
Splitting Tasks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Variant: Confirm
|
||||
*/
|
||||
export interface BoxHeaderConfirmProps {
|
||||
subtitle?: string;
|
||||
onStartTask?: () => void;
|
||||
onEdit?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BoxHeaderConfirm = ({
|
||||
subtitle,
|
||||
onStartTask,
|
||||
onEdit,
|
||||
className,
|
||||
}: BoxHeaderConfirmProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 items-start justify-center w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="box-border flex gap-1 items-center px-2.5 py-0 relative w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1 focus-visible:outline-none focus:ring-0"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<ChevronLeft size={16} className="text-icon-primary" />
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 flex gap-0.5 items-center min-h-px min-w-px relative">
|
||||
{subtitle && (
|
||||
<div className="flex-1 flex flex-col justify-center min-h-px min-w-px overflow-ellipsis overflow-hidden relative">
|
||||
<span className="font-normal text-text-label text-xs whitespace-nowrap overflow-ellipsis overflow-hidden m-0">
|
||||
{subtitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={onStartTask}
|
||||
>
|
||||
Start Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||