From 0606137d572b4c40643f0c3ff290675aae6c6187 Mon Sep 17 00:00:00 2001 From: renjianbo <18691577328@163.com> Date: Tue, 5 May 2026 08:57:00 +0800 Subject: [PATCH] fix: schedule timezone bug + missing notifications + celery beat startup - Timezone: compute_next_run now correctly interprets cron in the schedule's configured timezone (e.g., "0 8 * * *" with Asia/Shanghai = 8AM Beijing, not UTC) - Notifications: agent_tasks now reuses pre-created execution records and calls notify_schedule_result on completion, so non-workflow agent schedules get DB notifications + Feishu webhook + Feishu app messages - Duplicate execution: execute_agent_task accepts optional execution_id to reuse the record created by schedule_service instead of creating a second one - Celery Beat: added to restart_backend_celery.ps1, stop_aiagent.ps1, and docker-compose.dev.yml; fixed repo-root path resolution in all PS1 scripts Co-Authored-By: Claude Opus 4.6 --- backend/app/api/agent_schedules.py | 4 +- .../app/services/agent_schedule_service.py | 119 ++++++++++++++++-- backend/app/tasks/agent_tasks.py | 43 +++++-- backend/app/tasks/workflow_tasks.py | 92 +------------- docs/startup-deploy/docker-compose.dev.yml | 89 +++++++++++++ scripts/startup/restart_backend_celery.ps1 | 73 +++++++++++ scripts/startup/start_aiagent.ps1 | 91 ++++++++++++++ scripts/startup/start_aiagent_background.ps1 | 112 +++++++++++++++++ scripts/startup/stop_aiagent.ps1 | 87 +++++++++++++ 9 files changed, 600 insertions(+), 110 deletions(-) create mode 100644 docs/startup-deploy/docker-compose.dev.yml create mode 100644 scripts/startup/restart_backend_celery.ps1 create mode 100644 scripts/startup/start_aiagent.ps1 create mode 100644 scripts/startup/start_aiagent_background.ps1 create mode 100644 scripts/startup/stop_aiagent.ps1 diff --git a/backend/app/api/agent_schedules.py b/backend/app/api/agent_schedules.py index 5df7031..228931f 100644 --- a/backend/app/api/agent_schedules.py +++ b/backend/app/api/agent_schedules.py @@ -95,7 +95,7 @@ async def create_schedule( # 验证 cron 表达式 try: - next_run = compute_next_run(data.cron_expression) + next_run = compute_next_run(data.cron_expression, tz=data.timezone or "Asia/Shanghai") except (ValueError, KeyError) as e: raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}") @@ -138,7 +138,7 @@ async def update_schedule( schedule.name = data.name if data.cron_expression is not None: try: - schedule.next_run_at = compute_next_run(data.cron_expression, after=datetime.utcnow()) + schedule.next_run_at = compute_next_run(data.cron_expression, after=datetime.utcnow(), tz=data.timezone or schedule.timezone or "Asia/Shanghai") schedule.cron_expression = data.cron_expression except (ValueError, KeyError) as e: raise HTTPException(status_code=400, detail=f"cron 表达式无效: {e}") diff --git a/backend/app/services/agent_schedule_service.py b/backend/app/services/agent_schedule_service.py index 042d2c6..a842971 100644 --- a/backend/app/services/agent_schedule_service.py +++ b/backend/app/services/agent_schedule_service.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from datetime import datetime, timezone, timedelta from typing import Optional +import pytz from croniter import croniter from sqlalchemy.orm import Session @@ -12,22 +13,33 @@ from app.core.database import SessionLocal logger = logging.getLogger(__name__) -def compute_next_run(cron_expression: str, after: Optional[datetime] = None) -> datetime: +def compute_next_run(cron_expression: str, after: Optional[datetime] = None, tz: str = "UTC") -> datetime: """根据 cron 表达式计算下一次执行时间。 Args: cron_expression: 标准 5 位 cron 表达式,如 "0 9 * * *" after: 从哪个时间点开始计算,默认为当前 UTC 时间 + tz: 时区名称,如 "Asia/Shanghai",cron 表达式在该时区下解释 Returns: - 下次执行的 UTC datetime + 下次执行的 UTC datetime(naive,存储时与 datetime.utcnow() 对齐) """ base = after or datetime.now(timezone.utc) - # croniter 需要以 datetime 对象作为基准 - base_naive = base.replace(tzinfo=None) if base.tzinfo else base + # 将 UTC 时间转换为目标时区,cron 表达式在目标时区下解释 + try: + target_tz = pytz.timezone(tz) + except pytz.UnknownTimeZoneError: + target_tz = pytz.UTC + base_in_tz = base.astimezone(target_tz) + # croniter 需要 naive datetime,在目标时区下计算 + base_naive = base_in_tz.replace(tzinfo=None) cron = croniter(cron_expression, base_naive) - next_dt = cron.get_next(datetime) - return next_dt + next_naive = cron.get_next(datetime) + # 将结果 localize 回目标时区,再转 UTC + next_in_tz = target_tz.localize(next_naive) + next_utc = next_in_tz.astimezone(pytz.UTC) + # 返回 naive UTC(与 datetime.utcnow() 对齐) + return next_utc.replace(tzinfo=None) def create_execution_for_schedule(db: Session, schedule) -> Optional[str]: @@ -69,11 +81,12 @@ def create_execution_for_schedule(db: Session, schedule) -> Optional[str]: {"message": schedule.input_message}, ) else: - # 无工作流配置:走简单 Agent 异步执行 + # 无工作流配置:走简单 Agent 异步执行(传入已创建的 execution_id) from app.tasks.agent_tasks import execute_agent_task task = execute_agent_task.delay( str(schedule.agent_id), {"message": schedule.input_message}, + execution_id=str(execution.id), ) execution.task_id = task.id execution.status = "running" @@ -124,8 +137,8 @@ def check_and_run_due_schedules() -> int: # 更新定时任务状态 sched.last_run_at = now sched.last_run_status = "success" - # 计算下次执行时间 - sched.next_run_at = compute_next_run(sched.cron_expression, after=now) + # 计算下次执行时间(使用定时任务配置的时区) + sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC") db.commit() triggered += 1 logger.info( @@ -145,3 +158,91 @@ def check_and_run_due_schedules() -> int: finally: if db: db.close() + + +def notify_schedule_result(db: Session, execution, status: str, error_message: Optional[str] = None) -> None: + """如果 execution 关联了定时任务,创建通知并推送飞书消息。 + + Args: + db: 数据库会话 + execution: Execution ORM 对象 + status: "completed" 或 "failed" + error_message: 失败时的错误信息 + """ + if not execution or not execution.schedule_id: + return + try: + from app.models.agent_schedule import AgentSchedule + from app.services.notification_service import create_notification + + schedule = db.query(AgentSchedule).filter(AgentSchedule.id == execution.schedule_id).first() + if not schedule: + return + + if status == "completed": + title = f"定时任务「{schedule.name}」执行成功" + content = f"Agent 已按计划执行完成。" + else: + title = f"定时任务「{schedule.name}」执行失败" + content = f"错误信息: {error_message or '未知错误'}" + + create_notification( + db, + user_id=schedule.user_id, + title=title, + content=content, + category="schedule", + ref_type="execution", + ref_id=str(execution.id), + ) + db.commit() + + # 如果配置了飞书 webhook,发送飞书通知(非阻塞,失败不影响主流程) + if schedule.webhook_url: + try: + from app.services.feishu_notifier import send_feishu_card + + detail_link = None + try: + from app.core.config import settings + if settings.EXTERNAL_URL: + detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}" + except Exception: + pass + + send_feishu_card( + webhook_url=schedule.webhook_url, + title=title, + body=content, + status=status, + detail_link=detail_link, + ) + except Exception as e: + logger.warning("飞书 webhook 通知发送失败: %s", e) + + # 如果用户绑定了飞书账号,通过飞书应用发送通知 + try: + from app.models.user import User + from app.services.feishu_app_service import send_message_to_user + + schedule_user = db.query(User).filter(User.id == schedule.user_id).first() + if schedule_user and schedule_user.feishu_open_id: + detail_link = None + try: + from app.core.config import settings + if settings.EXTERNAL_URL: + detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}" + except Exception: + pass + + send_message_to_user( + open_id=schedule_user.feishu_open_id, + title=title, + content=content, + status=status, + detail_link=detail_link, + ) + except Exception as e: + logger.warning("飞书应用通知发送失败: %s", e) + except Exception as e: + logger.warning("创建定时任务通知失败: %s", e) diff --git a/backend/app/tasks/agent_tasks.py b/backend/app/tasks/agent_tasks.py index be6b1af..3b60d80 100644 --- a/backend/app/tasks/agent_tasks.py +++ b/backend/app/tasks/agent_tasks.py @@ -21,6 +21,7 @@ from app.models.execution import Execution import asyncio import logging import time +from typing import Optional logger = logging.getLogger(__name__) @@ -77,16 +78,20 @@ def _build_agent_config_from_db(agent: Agent) -> AgentConfig: @celery_app.task(bind=True) -def execute_agent_task(self, agent_id: str, input_data: dict): +def execute_agent_task(self, agent_id: str, input_data: dict, execution_id: Optional[str] = None): """异步执行 Agent 任务。 由定时调度 (check_agent_schedules_task) 或手动 API 触发。 - 创建 Execution 记录,运行 Agent,更新结果。 + 如果传入 execution_id(由 schedule_service 预创建),则复用该记录; + 否则创建新的 Execution 记录。运行 Agent 后更新结果并发送通知。 Args: agent_id: Agent ID input_data: 输入数据,至少包含 "message" 字段 + execution_id: 可选,预创建的 execution 记录 ID(定时任务场景) """ + from app.services.agent_schedule_service import notify_schedule_result + db = SessionLocal() start_time = time.time() @@ -99,14 +104,21 @@ def execute_agent_task(self, agent_id: str, input_data: dict): if not user_message: return {"status": "error", "detail": "缺少 message 输入"} - # 创建执行记录 - execution = Execution( - agent_id=agent_id, - input_data=input_data, - status="running", - ) - db.add(execution) - db.flush() + # 复用预创建的 execution 记录,或新建 + if execution_id: + execution = db.query(Execution).filter(Execution.id == execution_id).first() + if not execution: + return {"status": "error", "detail": f"Execution {execution_id} 不存在"} + execution.status = "running" + db.commit() + else: + execution = Execution( + agent_id=agent_id, + input_data=input_data, + status="running", + ) + db.add(execution) + db.flush() # 更新 Celery 任务状态 self.update_state( @@ -137,6 +149,9 @@ def execute_agent_task(self, agent_id: str, input_data: dict): execution.execution_time = execution_time db.commit() + # 定时任务结果通知 + notify_schedule_result(db, execution, "completed") + logger.info( "Agent 异步执行完成: agent=%s execution=%s time=%dms", agent_id, execution.id, execution_time, @@ -154,6 +169,10 @@ def execute_agent_task(self, agent_id: str, input_data: dict): execution.error_message = result.error or "Agent 执行返回失败" execution.execution_time = int((time.time() - start_time) * 1000) db.commit() + + # 定时任务失败通知 + notify_schedule_result(db, execution, "failed", error_message=execution.error_message) + return { "status": "failed", "execution_id": str(execution.id), @@ -165,6 +184,10 @@ def execute_agent_task(self, agent_id: str, input_data: dict): execution.error_message = f"Agent 执行异常: {run_e!s}" execution.execution_time = execution_time db.commit() + + # 定时任务失败通知 + notify_schedule_result(db, execution, "failed", error_message=execution.error_message) + logger.error("Agent 异步执行异常: agent=%s error=%s", agent_id, run_e) raise diff --git a/backend/app/tasks/workflow_tasks.py b/backend/app/tasks/workflow_tasks.py index 6cdb9b6..895895a 100644 --- a/backend/app/tasks/workflow_tasks.py +++ b/backend/app/tasks/workflow_tasks.py @@ -19,7 +19,7 @@ from app.models.agent import Agent from app.models.workflow import Workflow from app.services.execution_budget import merge_budget_for_execution from app.services.agent_workspace_chat_log import try_append_agent_dialogue_after_success -from app.services.notification_service import create_notification +from app.services.agent_schedule_service import notify_schedule_result from app.websocket.manager import websocket_manager import asyncio import json @@ -80,92 +80,6 @@ def _trusted_user_for_execution(db, execution: Optional[Execution]) -> Optional[ return None -def _notify_schedule_result( - db, - execution, - status: str, - error_message: Optional[str] = None, -): - """如果 execution 关联了定时任务,创建通知推送结果给用户。""" - if not execution or not execution.schedule_id: - return - try: - from app.models.agent_schedule import AgentSchedule - - schedule = db.query(AgentSchedule).filter(AgentSchedule.id == execution.schedule_id).first() - if not schedule: - return - - if status == "completed": - title = f"定时任务「{schedule.name}」执行成功" - content = f"Agent 已按计划执行完成。" - else: - title = f"定时任务「{schedule.name}」执行失败" - content = f"错误信息: {error_message or '未知错误'}" - - create_notification( - db, - user_id=schedule.user_id, - title=title, - content=content, - category="schedule", - ref_type="execution", - ref_id=str(execution.id), - ) - db.commit() - - # 如果配置了飞书 webhook,发送飞书通知(非阻塞,失败不影响主流程) - if schedule.webhook_url: - try: - from app.services.feishu_notifier import send_feishu_card - - detail_link = None - # 如果系统配置了外部访问地址,拼接 execution 详情链接 - try: - from app.core.config import settings - if settings.EXTERNAL_URL: - detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}" - except Exception: - pass - - send_feishu_card( - webhook_url=schedule.webhook_url, - title=title, - body=content, - status=status, - detail_link=detail_link, - ) - except Exception as e: - logger.warning("飞书 webhook 通知发送失败: %s", e) - - # 如果用户绑定了飞书账号,通过飞书应用发送通知 - try: - from app.models.user import User - from app.services.feishu_app_service import send_message_to_user - - schedule_user = db.query(User).filter(User.id == schedule.user_id).first() - if schedule_user and schedule_user.feishu_open_id: - detail_link = None - try: - from app.core.config import settings - if settings.EXTERNAL_URL: - detail_link = f"{settings.EXTERNAL_URL}/executions/{execution.id}" - except Exception: - pass - - send_message_to_user( - open_id=schedule_user.feishu_open_id, - title=title, - content=content, - status=status, - detail_link=detail_link, - ) - except Exception as e: - logger.warning("飞书应用通知发送失败: %s", e) - except Exception as e: - logger.warning("创建定时任务通知失败: %s", e) - - @celery_app.task(bind=True) def execute_workflow_task( self, @@ -287,7 +201,7 @@ def execute_workflow_task( execution_logger.warn(f"告警检测失败: {str(e)}") # 定时任务结果通知 - _notify_schedule_result(db, execution, "completed") + notify_schedule_result(db, execution, "completed") return { 'status': 'completed', @@ -331,7 +245,7 @@ def execute_workflow_task( execution_logger.warn(f"告警检测失败: {str(e2)}") # 定时任务失败通知 - _notify_schedule_result(db, execution, "failed", error_message=err_text) + notify_schedule_result(db, execution, "failed", error_message=err_text) raise diff --git a/docs/startup-deploy/docker-compose.dev.yml b/docs/startup-deploy/docker-compose.dev.yml new file mode 100644 index 0000000..3896953 --- /dev/null +++ b/docs/startup-deploy/docker-compose.dev.yml @@ -0,0 +1,89 @@ +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + ports: + - "8038:3000" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_URL=http://101.43.95.130:8037 + # 注意:Vite环境变量需要在构建时设置,运行时修改需要重启容器 + depends_on: + - backend + networks: + - aiagent-network + + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + ports: + - "8037:8000" + volumes: + - ./backend:/app + environment: + - DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4 + - REDIS_URL=redis://redis:6379/0 + - SECRET_KEY=dev-secret-key-change-in-production + - CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038 + - DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc + - DEEPSEEK_BASE_URL=https://api.deepseek.com + depends_on: + - redis + networks: + - aiagent-network + + celery: + build: + context: ./backend + dockerfile: Dockerfile.dev + command: celery -A app.core.celery_app worker --loglevel=info + volumes: + - ./backend:/app + environment: + - DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4 + - REDIS_URL=redis://redis:6379/0 + - DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc + - DEEPSEEK_BASE_URL=https://api.deepseek.com + depends_on: + - redis + - backend + networks: + - aiagent-network + + celery-beat: + build: + context: ./backend + dockerfile: Dockerfile.dev + command: celery -A app.core.celery_app beat --loglevel=info + volumes: + - ./backend:/app + environment: + - DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4 + - REDIS_URL=redis://redis:6379/0 + - DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc + - DEEPSEEK_BASE_URL=https://api.deepseek.com + depends_on: + - redis + - backend + networks: + - aiagent-network + + redis: + image: redis:7-alpine + ports: + - "6380:6379" # 主机 6380 映射到容器 6379,避免与宿主机 6379 冲突 + volumes: + - redis_data:/data + networks: + - aiagent-network + +volumes: + redis_data: + +networks: + aiagent-network: + driver: bridge diff --git a/scripts/startup/restart_backend_celery.ps1 b/scripts/startup/restart_backend_celery.ps1 new file mode 100644 index 0000000..d89252d --- /dev/null +++ b/scripts/startup/restart_backend_celery.ps1 @@ -0,0 +1,73 @@ +# Restart backend API (uvicorn) + Celery worker + Celery Beat (scheduler) +# Does not stop frontend/Redis. +$ErrorActionPreference = "Continue" +# Repo root = two levels up from scripts/startup/ +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$Backend = Join-Path $RepoRoot "backend" +$ApiPort = 8037 + +Write-Host "== Stop API + Celery worker + Celery Beat ==" -ForegroundColor Cyan + +function Stop-ByCommandLine([string]$pattern, [string]$name) { + $targets = Get-CimInstance Win32_Process | Where-Object { + $_.CommandLine -and $_.CommandLine -match $pattern + } + if (-not $targets) { + Write-Host "[SKIP] ${name}: no matching process" -ForegroundColor DarkGray + return + } + foreach ($p in $targets) { + try { + Stop-Process -Id $p.ProcessId -Force + Write-Host "[OK] stopped ${name} PID=$($p.ProcessId)" -ForegroundColor Green + } catch { + Write-Host "[WARN] failed to stop ${name} PID=$($p.ProcessId)" -ForegroundColor Yellow + } + } +} + +Stop-ByCommandLine "uvicorn\s+app\.main:app" "backend-api" +Stop-ByCommandLine "celery\s+-A\s+app\.core\.celery_app\s+worker" "celery-worker" +Stop-ByCommandLine "celery\s+-A\s+app\.core\.celery_app\s+beat" "celery-beat" + +Start-Sleep -Seconds 2 + +Write-Host "== Start API on $ApiPort + Celery worker + Celery Beat ==" -ForegroundColor Cyan + +# 1. API (uvicorn) +Start-Process powershell -ArgumentList @( + "-NoExit", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $ApiPort" +) + +# 2. Celery worker +Start-Process powershell -ArgumentList @( + "-NoExit", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8" +) + +# 3. Celery Beat (scheduler — 每分钟检查到期的定时任务) +Start-Process powershell -ArgumentList @( + "-NoExit", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info" +) + +Write-Host "" +Write-Host "[DONE] 已在新窗口启动 API + Celery Worker + Celery Beat" -ForegroundColor Green +Write-Host "API: http://127.0.0.1:$ApiPort/docs" -ForegroundColor Cyan +Write-Host "Celery Beat 负责每分钟检查定时任务,如果定时推送没触发,请确认 Beat 窗口正在运行" -ForegroundColor Yellow + +Start-Sleep -Seconds 2 +Write-Host "" +Write-Host "Port $ApiPort :" -ForegroundColor Cyan +netstat -ano | findstr ":$ApiPort" diff --git a/scripts/startup/start_aiagent.ps1 b/scripts/startup/start_aiagent.ps1 new file mode 100644 index 0000000..f9fd3ec --- /dev/null +++ b/scripts/startup/start_aiagent.ps1 @@ -0,0 +1,91 @@ +param( + [int]$ApiPort = 8037, + [int]$FallbackApiPort = 8041, + [int]$FrontendPort = 3001 +) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$Backend = Join-Path $RepoRoot "backend" +$Frontend = Join-Path $RepoRoot "frontend" +$RedisDir = Join-Path $Backend "redis" +$RedisExe = Join-Path $RedisDir "redis-server.exe" +$RedisCli = Join-Path $RedisDir "redis-cli.exe" + +function Test-PortListening([int]$Port) { + $line = netstat -ano | Select-String ":$Port\s+.*LISTENING" | Select-Object -First 1 + return [bool]$line +} + +function Ensure-Redis { + if (Test-PortListening 6379) { + Write-Host '[OK] Redis already listening on 6379' -ForegroundColor Green + return + } + if (-not (Test-Path $RedisExe)) { + throw "Redis executable not found: $RedisExe" + } + Write-Host '[RUN] Starting Redis on 6379 ...' -ForegroundColor Yellow + Start-Process -FilePath $RedisExe -ArgumentList "--port 6379" -WorkingDirectory $RedisDir | Out-Null + Start-Sleep -Seconds 2 + if (-not (Test-PortListening 6379)) { + throw "Redis failed: port 6379 not listening" + } + if (Test-Path $RedisCli) { + & $RedisCli -p 6379 ping | Out-Null + } + Write-Host '[OK] Redis started' -ForegroundColor Green +} + +function Resolve-ApiPort { + if (-not (Test-PortListening $ApiPort)) { + return $ApiPort + } + Write-Host ('[WARN] Port {0} is occupied, switching to {1}' -f $ApiPort, $FallbackApiPort) -ForegroundColor Yellow + if (Test-PortListening $FallbackApiPort) { + throw "Ports $ApiPort and $FallbackApiPort are in use; free one first" + } + return $FallbackApiPort +} + +Write-Host '== AIAgent one-click start ==' -ForegroundColor Cyan +Write-Host "Repo: $RepoRoot" + +Ensure-Redis +$RealApiPort = Resolve-ApiPort +$ApiBase = "http://127.0.0.1:$RealApiPort" + +Write-Host ('[RUN] Starting backend API on {0} ...' -f $RealApiPort) -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $RealApiPort" +) + +Write-Host '[RUN] Starting Celery worker ...' -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8" +) + +Write-Host '[RUN] Starting Celery Beat (scheduler) ...' -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info" +) + +Write-Host ('[RUN] Starting frontend on {0} (proxy -> {1}) ...' -f $FrontendPort, $ApiBase) -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "`$env:AIAGENT_API_PROXY='$ApiBase'; cd '$Frontend'; pnpm dev --port $FrontendPort" +) + +Write-Host "" +Write-Host '[DONE] Start commands issued (check new PowerShell windows)' -ForegroundColor Green +Write-Host "Frontend: http://localhost:$FrontendPort" -ForegroundColor Cyan +Write-Host "API docs: $ApiBase/docs" -ForegroundColor Cyan +Write-Host "Redis: 127.0.0.1:6379" -ForegroundColor Cyan diff --git a/scripts/startup/start_aiagent_background.ps1 b/scripts/startup/start_aiagent_background.ps1 new file mode 100644 index 0000000..83c5889 --- /dev/null +++ b/scripts/startup/start_aiagent_background.ps1 @@ -0,0 +1,112 @@ +# 静默后台启动脚本(无弹窗,日志写文件) +# 用于 Windows 任务计划程序开机自启 + +$ErrorActionPreference = "Continue" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$Backend = Join-Path $RepoRoot "backend" +$Frontend = Join-Path $RepoRoot "frontend" +$RedisDir = Join-Path $Backend "redis" +$RedisExe = Join-Path $RedisDir "redis-server.exe" +$RedisCli = Join-Path $RedisDir "redis-cli.exe" +$LogDir = Join-Path $RepoRoot "logs" +$LogFile = Join-Path $LogDir "autostart_$(Get-Date -Format 'yyyyMMdd_HHmmss').log" + +if (-not (Test-Path $LogDir)) { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null +} + +function Write-Log($msg) { + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + "$ts $msg" | Out-File -Append -FilePath $LogFile -Encoding UTF8 +} + +Write-Log "========== AIAgent background start ==========" +Write-Log "Repo: $RepoRoot" + +# ── 1) Redis ────────────────────────────────── +$redisPort = 6379 +$redisListening = netstat -ano | Select-String ":$redisPort\s+.*LISTENING" +if ($redisListening) { + Write-Log "Redis already listening on $redisPort" +} else { + if (-not (Test-Path $RedisExe)) { + Write-Log "ERROR: Redis not found at $RedisExe" + } else { + Write-Log "Starting Redis on $redisPort ..." + $redisProc = Start-Process -FilePath $RedisExe ` + -ArgumentList "--port $redisPort" ` + -WorkingDirectory $RedisDir ` + -WindowStyle Hidden ` + -PassThru + Start-Sleep -Seconds 2 + if (Test-Path $RedisCli) { + & $RedisCli -p $redisPort ping 2>&1 | Out-File -Append $LogFile -Encoding UTF8 + } + Write-Log "Redis PID=$($redisProc.Id) started" + } +} + +# ── 2) Backend API ──────────────────────────── +$apiPort = 8037 +$apiListening = netstat -ano | Select-String ":$apiPort\s+.*LISTENING" +if ($apiListening) { + Write-Log "Backend API already listening on $apiPort" +} else { + Write-Log "Starting backend API on $apiPort ..." + $apiLog = Join-Path $LogDir "api.log" + Start-Process powershell ` + -WindowStyle Hidden ` + -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", + "Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $apiPort 2>&1 | Out-File -Append '$apiLog' -Encoding UTF8" + ) + Write-Log "Backend API starting (log: $apiLog)" +} + +# ── 3) Celery Worker ────────────────────────── +Write-Log "Starting Celery worker ..." +$celeryLog = Join-Path $LogDir "celery.log" +Start-Process powershell ` + -WindowStyle Hidden ` + -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", + "Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8 2>&1 | Out-File -Append '$celeryLog' -Encoding UTF8" + ) +Write-Log "Celery worker starting (log: $celeryLog)" + +# ── 3.5) Celery Beat (scheduler) ────────────── +Write-Log "Starting Celery Beat ..." +$beatLog = Join-Path $LogDir "celery_beat.log" +Start-Process powershell ` + -WindowStyle Hidden ` + -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", + "Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info 2>&1 | Out-File -Append '$beatLog' -Encoding UTF8" + ) +Write-Log "Celery Beat starting (log: $beatLog)" + +# ── 4) Frontend (pnpm dev) ──────────────────── +$frontendPort = 3001 +$frontendListening = netstat -ano | Select-String ":$frontendPort\s+.*LISTENING" +if ($frontendListening) { + Write-Log "Frontend already listening on $frontendPort" +} else { + Write-Log "Starting frontend on $frontendPort ..." + $frontendLog = Join-Path $LogDir "frontend.log" + Start-Process powershell ` + -WindowStyle Hidden ` + -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", + "`$env:AIAGENT_API_PROXY='http://127.0.0.1:$apiPort'; Set-Location '$Frontend'; pnpm dev --port $frontendPort 2>&1 | Out-File -Append '$frontendLog' -Encoding UTF8" + ) + Write-Log "Frontend starting (log: $frontendLog)" +} + +Write-Log "========== All services issued ==========" +Write-Log "Frontend: http://localhost:$frontendPort" +Write-Log "API docs: http://127.0.0.1:$apiPort/docs" diff --git a/scripts/startup/stop_aiagent.ps1 b/scripts/startup/stop_aiagent.ps1 new file mode 100644 index 0000000..c5ed1d2 --- /dev/null +++ b/scripts/startup/stop_aiagent.ps1 @@ -0,0 +1,87 @@ +$ErrorActionPreference = "SilentlyContinue" + +Write-Host "== AIAgent stop ==" -ForegroundColor Cyan + +function Get-PidsListeningOnPort([int]$Port) { + $pids = New-Object System.Collections.Generic.HashSet[int] + try { + netstat -ano | ForEach-Object { + $ln = $_.Trim() + if ($ln -notmatch "LISTENING") { return } + # 匹配 :8037 或 :3001 等端口后的 LISTENING 行末 PID + if ($ln -match ":$Port\s+.*LISTENING\s+(\d+)\s*$") { + [void]$pids.Add([int]$Matches[1]) + } + } + } catch { } + return @($pids) +} + +function Stop-OnPorts([int[]]$ports, [string]$name) { + $all = New-Object System.Collections.Generic.HashSet[int] + foreach ($p in $ports) { + foreach ($pid in (Get-PidsListeningOnPort $p)) { + [void]$all.Add($pid) + } + } + if ($all.Count -eq 0) { + Write-Host "[SKIP] ${name}: no listener on ports $($ports -join ',')" -ForegroundColor DarkGray + return + } + foreach ($pid in $all) { + if ($pid -le 4) { continue } + try { + Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue + Write-Host "[OK] stopped ${name} PID=$pid" -ForegroundColor Green + } catch { + Write-Host "[WARN] failed to stop ${name} PID=$pid" -ForegroundColor Yellow + } + } +} + +# 后端 API(8037 / 8041 备用) +Stop-OnPorts @(8037, 8041) "backend-api" + +# 前端 Vite(3001) +Stop-OnPorts @(3001) "frontend-dev" + +# Redis(6379,仅当监听在本地开发端口时结束;若与其它项目共用请谨慎) +Stop-OnPorts @(6379) "redis" + +# Celery Worker / Beat(不监听端口,需要按命令行匹配) +$celeryPatterns = @( + @{Pattern='celery\s+-A\s+app\.core\.celery_app\s+worker'; Name='celery-worker'}, + @{Pattern='celery\s+-A\s+app\.core\.celery_app\s+beat'; Name='celery-beat'} +) +foreach ($cp in $celeryPatterns) { + $targets = Get-CimInstance Win32_Process | Where-Object { + $_.CommandLine -and $_.CommandLine -match $cp.Pattern + } + if (-not $targets) { + Write-Host "[SKIP] $($cp.Name): no matching process" -ForegroundColor DarkGray + } + foreach ($p in $targets) { + try { + Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue + Write-Host "[OK] stopped $($cp.Name) PID=$($p.ProcessId)" -ForegroundColor Green + } catch { + Write-Host "[WARN] failed to stop $($cp.Name) PID=$($p.ProcessId)" -ForegroundColor Yellow + } + } +} + +Start-Sleep -Milliseconds 600 + +Write-Host "" +Write-Host "Port check:" -ForegroundColor Cyan +foreach ($port in 3001, 8037, 8041, 6379) { + $line = netstat -ano | Select-String ":$port\s+.*LISTENING" | Select-Object -First 1 + if ($line) { + Write-Host " - ${port}: LISTEN" -ForegroundColor Yellow + } else { + Write-Host " - ${port}: free" -ForegroundColor Green + } +} + +Write-Host "" +Write-Host "DONE: stop script finished" -ForegroundColor Green