feat: P1核心功能验证完成 - 迁移/平台对接/调度器/配置/Agent监控
P1-1 数据库迁移同步: - 修复迁移冲突(重复版本号) - 添加缺失模型导入 - 创建同步迁移文件 - alembic check通过 P1-2 多平台API对接验证: - 创建22个测试用例 - 5种引用提取方式验证 - 平台健康检查API (/api/v1/platforms/health) P1-3 定时调度器验证: - 10个测试用例全部通过 - 添加手动触发支持(run_job_now) - 2个注册任务(check_queries/check_pending_tasks) P1-4 配置API Keys: - .env.example完整配置 - LLM提供商工厂函数验证 - 限流器配置测试 P2-1 Agent监控Dashboard: - 前端页面(统计摘要/筛选/详情) - API客户端方法 - 执行历史和日志展示
This commit is contained in:
parent
9e63915f42
commit
67d7578550
|
|
@ -0,0 +1,87 @@
|
|||
# ============================================================
|
||||
# GEO Platform Environment Configuration
|
||||
# ============================================================
|
||||
# 复制此文件为 .env 并填入实际值
|
||||
# cp .env.example .env
|
||||
|
||||
# ============================================================
|
||||
# AI平台API Keys
|
||||
# ============================================================
|
||||
|
||||
# Kimi (月之暗面) - platform.moonshot.cn
|
||||
MOONSHOT_API_KEY=
|
||||
|
||||
# 百度千帆 (文心一言) - console.bce.baidu.com/qianfan
|
||||
BAIDU_QIANFAN_API_KEY=
|
||||
BAIDU_QIANFAN_SECRET_KEY=
|
||||
|
||||
# 豆包 (字节跳动/火山引擎) - console.volcengine.com/ark
|
||||
DOUBAO_API_KEY=
|
||||
DOUBAO_ENDPOINT_ID=
|
||||
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_MAX_CONTEXT=64000
|
||||
|
||||
# OpenAI (可选)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=qwen3-coder-plus
|
||||
OPENAI_BASE_URL=https://coding.dashscope.aliyuncs.com/v1
|
||||
|
||||
# 智谱清言 (可选)
|
||||
ZHIPU_API_KEY=
|
||||
|
||||
# 通义千问 (可选)
|
||||
TONGYI_API_KEY=
|
||||
|
||||
# ============================================================
|
||||
# LLM Provider 配置
|
||||
# ============================================================
|
||||
# 默认提供商: openai | deepseek
|
||||
DEFAULT_LLM_PROVIDER=deepseek
|
||||
DEFAULT_LLM_MODEL=qwen3-coder-plus
|
||||
|
||||
# 是否启用LLM功能
|
||||
ENABLE_LLM=true
|
||||
|
||||
# ============================================================
|
||||
# 数据库配置
|
||||
# ============================================================
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres123@localhost:5432/geo_platform
|
||||
|
||||
# ============================================================
|
||||
# Redis 配置
|
||||
# ============================================================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# 是否启用Redis缓存
|
||||
ENABLE_REDIS=true
|
||||
|
||||
# ============================================================
|
||||
# JWT 配置
|
||||
# ============================================================
|
||||
# JWT密钥(生产环境必须设置至少32字符的强密钥)
|
||||
JWT_SECRET=your-secret-key-change-in-production-must-be-32-chars
|
||||
JWT_EXPIRE_HOURS=24
|
||||
|
||||
# NextAuth 密钥(可选,用于某些认证功能)
|
||||
SECRET_KEY=
|
||||
|
||||
# ============================================================
|
||||
# CORS 配置
|
||||
# ============================================================
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
|
||||
# ============================================================
|
||||
# 限流配置
|
||||
# ============================================================
|
||||
# AI平台API调用频率限制(每分钟请求数)
|
||||
API_RATE_LIMIT_RPM=10
|
||||
LLM_RATE_LIMIT_RPM=30
|
||||
|
||||
# ============================================================
|
||||
# Playwright 配置
|
||||
# ============================================================
|
||||
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"""sync_users_nullable_state
|
||||
|
||||
Revision ID: 810a29804f5a
|
||||
Revises: e5f7a9b1cd35
|
||||
Create Date: 2026-05-23 21:45:35.491924
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '810a29804f5a'
|
||||
down_revision: Union[str, Sequence[str], None] = 'e5f7a9b1cd35'
|
||||
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.alter_column('users', 'email_verified',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('users', 'is_admin',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.drop_index(op.f('idx_users_organization_id'), table_name='users')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('idx_users_organization_id'), 'users', ['organization_id'], unique=False)
|
||||
op.alter_column('users', 'is_admin',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('users', 'email_verified',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('false'))
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
"""
|
||||
平台健康检查API - 验证各AI平台适配器状态
|
||||
|
||||
端点: GET /api/platforms/health
|
||||
返回: 各平台适配器配置状态和健康信息
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/platforms", tags=["platforms"])
|
||||
|
||||
|
||||
class PlatformHealthStatus:
|
||||
"""平台健康状态"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
configured: bool,
|
||||
url: str = "",
|
||||
api_key_set: bool = False,
|
||||
status: str = "unknown",
|
||||
message: str = "",
|
||||
):
|
||||
self.name = name
|
||||
self.configured = configured
|
||||
self.url = url
|
||||
self.api_key_set = api_key_set
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def check_kimi_health() -> PlatformHealthStatus:
|
||||
"""检查Kimi平台健康状态"""
|
||||
try:
|
||||
from app.workers.platforms.kimi import KimiAdapter
|
||||
|
||||
adapter = KimiAdapter()
|
||||
api_key_set = bool(adapter.api_key and adapter.api_key.strip())
|
||||
configured = adapter.is_configured
|
||||
|
||||
return PlatformHealthStatus(
|
||||
name="kimi",
|
||||
url=adapter.platform_url,
|
||||
configured=configured,
|
||||
api_key_set=api_key_set,
|
||||
status="configured" if configured else "not_configured",
|
||||
message="API Key已配置" if configured else "API Key未配置",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Kimi健康检查失败: {e}")
|
||||
return PlatformHealthStatus(
|
||||
name="kimi",
|
||||
configured=False,
|
||||
status="error",
|
||||
message=str(e),
|
||||
)
|
||||
|
||||
|
||||
def check_wenxin_health() -> PlatformHealthStatus:
|
||||
"""检查文心平台健康状态"""
|
||||
try:
|
||||
from app.workers.platforms.wenxin import WenxinAdapter
|
||||
|
||||
adapter = WenxinAdapter()
|
||||
api_key_set = bool(adapter.api_key and adapter.api_key.strip())
|
||||
secret_key_set = bool(adapter.secret_key and adapter.secret_key.strip())
|
||||
configured = adapter.is_configured
|
||||
|
||||
return PlatformHealthStatus(
|
||||
name="wenxin",
|
||||
url=adapter.platform_url,
|
||||
configured=configured,
|
||||
api_key_set=api_key_set,
|
||||
status="configured" if configured else "not_configured",
|
||||
message="API Key和Secret Key已配置" if configured else "API Key或Secret Key未配置",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"文心健康检查失败: {e}")
|
||||
return PlatformHealthStatus(
|
||||
name="wenxin",
|
||||
configured=False,
|
||||
status="error",
|
||||
message=str(e),
|
||||
)
|
||||
|
||||
|
||||
def check_doubao_health() -> PlatformHealthStatus:
|
||||
"""检查豆包平台健康状态"""
|
||||
try:
|
||||
from app.workers.platforms.doubao import DoubaoAdapter
|
||||
|
||||
adapter = DoubaoAdapter()
|
||||
api_key_set = bool(adapter.api_key and adapter.api_key.strip())
|
||||
configured = adapter.is_configured
|
||||
|
||||
return PlatformHealthStatus(
|
||||
name="doubao",
|
||||
url=adapter.platform_url,
|
||||
configured=configured,
|
||||
api_key_set=api_key_set,
|
||||
status="configured" if configured else "not_configured",
|
||||
message="API Key已配置" if configured else "API Key未配置",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"豆包健康检查失败: {e}")
|
||||
return PlatformHealthStatus(
|
||||
name="doubao",
|
||||
configured=False,
|
||||
status="error",
|
||||
message=str(e),
|
||||
)
|
||||
|
||||
|
||||
def check_all_platforms() -> dict:
|
||||
"""检查所有平台健康状态"""
|
||||
platforms = [
|
||||
check_kimi_health(),
|
||||
check_wenxin_health(),
|
||||
check_doubao_health(),
|
||||
]
|
||||
|
||||
return {
|
||||
"platforms": [vars(p) for p in platforms],
|
||||
"total": len(platforms),
|
||||
"configured_count": sum(1 for p in platforms if p.configured),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def get_platform_health():
|
||||
"""
|
||||
获取所有AI平台适配器的健康状态
|
||||
|
||||
返回每个平台的:
|
||||
- name: 平台名称
|
||||
- configured: 是否已配置
|
||||
- url: 平台URL
|
||||
- api_key_set: API Key是否已设置
|
||||
- status: 健康状态 (configured / not_configured / error)
|
||||
- message: 状态消息
|
||||
"""
|
||||
health_info = check_all_platforms()
|
||||
return health_info
|
||||
|
||||
|
||||
@router.get("/health/{platform_name}")
|
||||
async def get_platform_health_by_name(platform_name: str):
|
||||
"""
|
||||
获取指定平台适配器的健康状态
|
||||
|
||||
Args:
|
||||
platform_name: 平台名称 (kimi / wenxin / doubao)
|
||||
"""
|
||||
if platform_name == "kimi":
|
||||
result = vars(check_kimi_health())
|
||||
elif platform_name == "wenxin":
|
||||
result = vars(check_wenxin_health())
|
||||
elif platform_name == "doubao":
|
||||
result = vars(check_doubao_health())
|
||||
else:
|
||||
return {"error": f"未知平台: {platform_name}"}
|
||||
|
||||
return result
|
||||
|
|
@ -32,6 +32,7 @@ from app.api.alerts import router as alerts_router
|
|||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.brands import router as brands_router
|
||||
from app.api.onboarding import router as onboarding_router
|
||||
from app.api.platforms import router as platforms_router
|
||||
from app.config import settings
|
||||
from app.database import engine, Base
|
||||
from app.schemas.common import ErrorResponse, ErrorCode
|
||||
|
|
@ -149,6 +150,7 @@ app.include_router(alerts_router, prefix="/api/v1/alerts", tags=["告警通知"]
|
|||
app.include_router(dashboard_router, prefix="/api/v1/dashboard", tags=["仪表盘"])
|
||||
app.include_router(brands_router, prefix="/api/v1/brands", tags=["品牌管理"])
|
||||
app.include_router(onboarding_router, prefix="/api/v1")
|
||||
app.include_router(platforms_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health", tags=["可观测性"])
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ from app.models.knowledge import (
|
|||
)
|
||||
from app.models.analytics import PublishRecord, ContentMetrics, OptimizationInsight
|
||||
from app.models.distribution import DistributionSchedule
|
||||
# 缺失的模型导入 - 重构后遗留
|
||||
from app.models.brand import Brand
|
||||
from app.models.competitor import Competitor
|
||||
from app.models.suggestion import Suggestion
|
||||
from app.models.alert import Alert
|
||||
from app.models.alert_setting import AlertSetting
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
|
|
@ -46,4 +52,10 @@ __all__ = [
|
|||
"ContentMetrics",
|
||||
"OptimizationInsight",
|
||||
"DistributionSchedule",
|
||||
# 缺失的模型 - 重构后遗留
|
||||
"Brand",
|
||||
"Competitor",
|
||||
"Suggestion",
|
||||
"Alert",
|
||||
"AlertSetting",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ class User(Base):
|
|||
plan: Mapped[str] = mapped_column(String(20), default="free")
|
||||
max_queries: Mapped[int] = mapped_column(Integer, default=5)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
verification_code: Mapped[str | None] = mapped_column(String(6), nullable=True)
|
||||
verification_code_expires: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
reset_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
reset_token_expires: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("organizations.id", ondelete="SET NULL"),
|
||||
|
|
|
|||
|
|
@ -103,3 +103,7 @@ _rate_limiter: TokenBucketRateLimiter | None = None
|
|||
def get_rate_limiter() -> TokenBucketRateLimiter:
|
||||
"""获取全局速率限制器实例"""
|
||||
return TokenBucketRateLimiter.get_instance()
|
||||
|
||||
|
||||
# 别名以支持测试验收标准
|
||||
RateLimiter = TokenBucketRateLimiter
|
||||
|
|
|
|||
|
|
@ -186,3 +186,16 @@ class QueryScheduler:
|
|||
|
||||
# 全局调度器实例
|
||||
query_scheduler = QueryScheduler()
|
||||
|
||||
# 导出别名以兼容测试
|
||||
scheduler = query_scheduler.scheduler
|
||||
|
||||
|
||||
def run_job_now(job_id: str):
|
||||
"""手动触发指定任务"""
|
||||
job = query_scheduler.scheduler.get_job(job_id)
|
||||
if job:
|
||||
# 获取任务的回调函数并直接调用
|
||||
job.func()
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
"""配置加载测试"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""配置加载测试"""
|
||||
|
||||
def test_all_required_env_vars_are_documented(self):
|
||||
"""所有必需的环境变量都应在.env.example中"""
|
||||
# 读取.env.example
|
||||
env_example_path = Path(__file__).parent.parent / ".env.example"
|
||||
assert env_example_path.exists(), ".env.example文件不存在"
|
||||
|
||||
content = env_example_path.read_text()
|
||||
|
||||
# 检查以下变量是否存在
|
||||
required_vars = [
|
||||
"MOONSHOT_API_KEY", # Kimi
|
||||
"BAIDU_QIANFAN_API_KEY", # 文心
|
||||
"DOUBAO_API_KEY", # 豆包
|
||||
"DEEPSEEK_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"REDIS_URL",
|
||||
"DATABASE_URL",
|
||||
"JWT_SECRET",
|
||||
]
|
||||
|
||||
for var in required_vars:
|
||||
assert var in content, f".env.example中缺少必需的环境变量: {var}"
|
||||
|
||||
def test_env_var_loading_without_errors(self):
|
||||
"""环境变量加载应无错误"""
|
||||
# 设置测试环境变量
|
||||
os.environ["JWT_SECRET"] = "test-secret-key-that-is-long-enough-for-validation"
|
||||
os.environ["ENABLE_LLM"] = "false"
|
||||
|
||||
# 重新加载配置模块
|
||||
import importlib
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
assert settings is not None
|
||||
assert settings.JWT_SECRET == "test-secret-key-that-is-long-enough-for-validation"
|
||||
|
||||
def test_llm_providers_have_factory(self):
|
||||
"""LLM提供商应有工厂函数"""
|
||||
# 设置测试用 API keys
|
||||
os.environ["OPENAI_API_KEY"] = "test-openai-key"
|
||||
os.environ["DEEPSEEK_API_KEY"] = "test-deepseek-key"
|
||||
|
||||
from app.services.llm.factory import LLMFactory
|
||||
|
||||
# 验证已注册的提供商
|
||||
providers = ["openai", "deepseek"]
|
||||
registered = LLMFactory.list_providers()
|
||||
for p in providers:
|
||||
assert p in registered, f"Provider {p} 未注册"
|
||||
|
||||
# 验证能创建提供商实例
|
||||
for p in providers:
|
||||
provider = LLMFactory.create(provider=p)
|
||||
assert provider is not None
|
||||
assert provider.provider_name == p
|
||||
|
||||
def test_rate_limiter_config_exists(self):
|
||||
"""限流器配置应存在"""
|
||||
from app.services.llm.rate_limiter import TokenBucketRateLimiter
|
||||
|
||||
limiter = TokenBucketRateLimiter(max_rpm=30.0)
|
||||
assert limiter is not None
|
||||
assert limiter.max_rpm == 30.0
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import pytest
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT = "/Users/Chiguyong/Code/Fischer/geo/backend"
|
||||
|
||||
|
||||
class TestDatabaseMigration:
|
||||
"""数据库迁移验证测试"""
|
||||
|
||||
def test_alembic_current_shows_no_errors(self):
|
||||
"""alembic current 应无错误输出"""
|
||||
result = subprocess.run(
|
||||
["alembic", "current"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
assert result.returncode == 0, f"alembic current failed: {result.stderr}"
|
||||
|
||||
def test_all_required_tables_exist(self):
|
||||
"""所有必需表应存在"""
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(PROJECT_ROOT, "app"))
|
||||
|
||||
async def check_tables():
|
||||
from app.database import engine
|
||||
from sqlalchemy import inspect
|
||||
|
||||
async with engine.connect() as conn:
|
||||
inspector = inspect(conn)
|
||||
tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
|
||||
return set(tables)
|
||||
|
||||
try:
|
||||
existing_tables = asyncio.run(check_tables())
|
||||
except Exception as e:
|
||||
pytest.skip(f"无法连接数据库: {e}")
|
||||
|
||||
required_tables = [
|
||||
"users", "brands", "competitors", "queries",
|
||||
"citations", "alerts", "alert_settings", "suggestions",
|
||||
"organizations", "subscriptions", "subscription_plans",
|
||||
"agent_configs", "agent_executions", "knowledge_bases",
|
||||
"content_versions", "platform_rules", "analytics_events",
|
||||
"distribution_schedules", "client_brands"
|
||||
]
|
||||
|
||||
missing_tables = [t for t in required_tables if t not in existing_tables]
|
||||
assert not missing_tables, f"缺失表: {missing_tables}"
|
||||
|
||||
def test_migration_head_matches_models(self):
|
||||
"""迁移头应与模型定义一致"""
|
||||
result = subprocess.run(
|
||||
["alembic", "check"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
assert result.returncode == 0, f"alembic check failed: {result.stderr}"
|
||||
|
||||
def test_no_duplicate_migration_versions(self):
|
||||
"""迁移版本号应唯一"""
|
||||
versions_dir = Path(PROJECT_ROOT) / "alembic" / "versions"
|
||||
if not versions_dir.exists():
|
||||
pytest.skip("alembic/versions/ 目录不存在")
|
||||
|
||||
version_files = list(versions_dir.glob("*.py"))
|
||||
version_numbers = []
|
||||
|
||||
for f in version_files:
|
||||
# 版本号通常是目录名中下划线前的部分
|
||||
# 例如: 059724556401_add_missing_sentiment_fields.py
|
||||
name = f.stem
|
||||
# 取第一个下划线前的部分作为版本号
|
||||
if "_" in name:
|
||||
version_num = name.split("_")[0]
|
||||
version_numbers.append((version_num, f.name))
|
||||
|
||||
# 检查重复
|
||||
seen = {}
|
||||
duplicates = []
|
||||
for version_num, filename in version_numbers:
|
||||
if version_num in seen:
|
||||
duplicates.append((version_num, filename, seen[version_num]))
|
||||
else:
|
||||
seen[version_num] = filename
|
||||
|
||||
assert not duplicates, f"发现重复版本号: {duplicates}"
|
||||
|
||||
def test_foreign_keys_integrity(self):
|
||||
"""关键外键约束应存在"""
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, os.path.join(PROJECT_ROOT, "app"))
|
||||
|
||||
async def check_fks():
|
||||
from app.database import engine
|
||||
from sqlalchemy import inspect
|
||||
|
||||
async with engine.connect() as conn:
|
||||
fks = await conn.run_sync(
|
||||
lambda sync_conn: inspect(sync_conn).get_foreign_keys("brands")
|
||||
)
|
||||
return fks
|
||||
|
||||
try:
|
||||
foreign_keys = asyncio.run(check_fks())
|
||||
except Exception as e:
|
||||
pytest.skip(f"无法连接数据库: {e}")
|
||||
|
||||
fk_columns = [fk['constrained_columns'] for fk in foreign_keys]
|
||||
|
||||
# 检查 brands.user_id 外键存在
|
||||
has_user_fk = any('user_id' in cols for cols in fk_columns)
|
||||
assert has_user_fk, "brands 表缺少 user_id 外键"
|
||||
|
||||
def test_alembic_history_shows_migrations(self):
|
||||
"""alembic history 应显示迁移历史"""
|
||||
result = subprocess.run(
|
||||
["alembic", "history"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
assert result.returncode == 0, f"alembic history failed: {result.stderr}"
|
||||
assert result.stdout.strip(), "alembic history 为空"
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
"""
|
||||
AI平台适配器测试 - 验证各平台适配器是否正常工作
|
||||
|
||||
测试内容:
|
||||
1. Kimi适配器返回有效响应结构
|
||||
2. 适配器限流处理
|
||||
3. 引用提取器 - 5种提取方式
|
||||
4. 适配器错误降级
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/Users/Chiguyong/Code/Fischer/geo/backend')
|
||||
|
||||
from app.workers.platforms.kimi import KimiAdapter
|
||||
from app.workers.platforms.wenxin import WenxinAdapter
|
||||
from app.workers.platforms.doubao import DoubaoAdapter
|
||||
from app.workers.citation_extractor import (
|
||||
extract_markdown_links,
|
||||
extract_urls_with_context,
|
||||
extract_footnotes,
|
||||
extract_source_annotations,
|
||||
extract_data_source,
|
||||
analyze_citations,
|
||||
CitationAnalysisResult,
|
||||
ExtractedCitation,
|
||||
)
|
||||
|
||||
|
||||
class TestPlatformAdapters:
|
||||
"""AI平台适配器测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kimi_adapter_returns_valid_response(self):
|
||||
"""Kimi适配器应返回有效响应结构"""
|
||||
adapter = KimiAdapter()
|
||||
|
||||
# Mock API响应
|
||||
mock_response_data = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "根据搜索结果,Apple是一家科技公司...来源: https://example.com"
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
with patch.object(adapter, '_get_client') as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_response_data
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
result = await adapter.query("Apple公司")
|
||||
|
||||
# 验证返回结构包含data_source标记或正常文本
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kimi_adapter_handles_rate_limit(self):
|
||||
"""Kimi适配器应处理限流(429状态码)"""
|
||||
adapter = KimiAdapter()
|
||||
|
||||
with patch.object(adapter, '_get_client') as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 429
|
||||
mock_response.headers = {"Retry-After": "1"}
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
# 应该抛出RuntimeError并触发重试,最终回退到搜索引擎
|
||||
result = await adapter.query("test")
|
||||
|
||||
# 验证最终有回退结果
|
||||
assert result is not None
|
||||
assert "search_engine" in result or "ai_platform" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kimi_fallback_to_search_engine(self):
|
||||
"""Kimi未配置时应回退到搜索引擎"""
|
||||
adapter = KimiAdapter()
|
||||
|
||||
# 模拟未配置API Key的情况 - patch api_key属性
|
||||
with patch.object(adapter, '_api_key', ''):
|
||||
result = await adapter.query("test keyword")
|
||||
|
||||
assert result is not None
|
||||
assert "search_engine" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wenxin_adapter_response_structure(self):
|
||||
"""文心适配器应返回有效响应"""
|
||||
adapter = WenxinAdapter()
|
||||
|
||||
mock_response_data = {
|
||||
"result": "文心一言回答内容,来源: https://example.com"
|
||||
}
|
||||
|
||||
with patch.object(adapter, '_get_client') as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
|
||||
# Mock token请求
|
||||
token_response = Mock()
|
||||
token_response.status_code = 200
|
||||
token_response.json.return_value = {"access_token": "test_token"}
|
||||
|
||||
# Mock chat请求
|
||||
chat_response = Mock()
|
||||
chat_response.status_code = 200
|
||||
chat_response.json.return_value = mock_response_data
|
||||
|
||||
mock_client.post.side_effect = [token_response, chat_response]
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
result = await adapter.query("测试问题")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doubao_adapter_response_structure(self):
|
||||
"""豆包适配器应返回有效响应"""
|
||||
adapter = DoubaoAdapter()
|
||||
|
||||
mock_response_data = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "豆包回答内容,参考 https://example.com"
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
with patch.object(adapter, '_get_client') as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = mock_response_data
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
result = await adapter.query("测试")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_error_returns_fallback(self):
|
||||
"""适配器错误时应返回降级结果而非抛出异常"""
|
||||
adapter = KimiAdapter()
|
||||
|
||||
with patch.object(adapter, '_get_client') as mock_get_client:
|
||||
mock_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
# 应该捕获异常并返回降级结果
|
||||
result = await adapter.query("test")
|
||||
|
||||
# 验证最终有回退结果而不是抛出异常
|
||||
assert result is not None
|
||||
assert "search_engine" in result
|
||||
|
||||
|
||||
class TestCitationExtractor:
|
||||
"""引用提取器测试 - 验证5种提取方式"""
|
||||
|
||||
def test_citation_extraction_from_markdown_link(self):
|
||||
"""1. Markdown链接格式 [text](url)"""
|
||||
text = "Apple是一家伟大的公司 [参考](https://example.com)"
|
||||
result = analyze_citations(text)
|
||||
|
||||
assert len(result.citations) > 0, "应该提取到至少一个引用"
|
||||
urls = [c.source_url for c in result.citations if c.source_url]
|
||||
assert "https://example.com" in urls, "应包含预期URL"
|
||||
|
||||
def test_citation_extraction_from_bare_url(self):
|
||||
"""2. 裸URL格式"""
|
||||
text = "更多信息请访问 https://example.com 还有 https://test.com"
|
||||
result = analyze_citations(text)
|
||||
|
||||
assert len(result.citations) > 0, "应该提取到至少一个裸URL"
|
||||
urls = [c.source_url for c in result.citations if c.source_url]
|
||||
assert "https://example.com" in urls
|
||||
assert "https://test.com" in urls
|
||||
|
||||
def test_citation_extraction_from_footnote(self):
|
||||
"""3. 脚注格式 [^n]"""
|
||||
text = """
|
||||
Apple是一家伟大的公司[1]
|
||||
微软也是知名公司[2]
|
||||
|
||||
[1]: https://apple.com
|
||||
[2]: https://microsoft.com
|
||||
"""
|
||||
citations = extract_footnotes(text)
|
||||
|
||||
assert len(citations) > 0, "应该提取到脚注引用"
|
||||
urls = [c.source_url for c in citations if c.source_url]
|
||||
assert "https://apple.com" in urls
|
||||
assert "https://microsoft.com" in urls
|
||||
|
||||
def test_citation_extraction_from_source_annotation(self):
|
||||
"""4. 来源标注格式 (来源: / 据...报道 / 参考: / 引用: / 出处:)"""
|
||||
text = "Apple是一家伟大的公司。来源: https://source1.com。据新浪报道,..."
|
||||
|
||||
citations = extract_source_annotations(text)
|
||||
|
||||
# 来源标注可能没有URL但有标题
|
||||
assert len(citations) > 0, "应该提取到来源标注"
|
||||
|
||||
def test_citation_extraction_from_data_source_marker(self):
|
||||
"""5. data_source标记"""
|
||||
text = "[data_source: ai_platform]\n这是AI生成的回答"
|
||||
|
||||
source, clean_text = extract_data_source(text)
|
||||
|
||||
assert source == "ai_platform", "应正确识别data_source"
|
||||
assert "AI生成" in clean_text, "应正确清理文本"
|
||||
|
||||
def test_analyze_citations_complete_flow(self):
|
||||
"""完整引用分析流程测试"""
|
||||
text = "[data_source: ai_platform]\n\nApple是一家伟大的公司[1]。\n\n更多详情请访问 https://apple.com\n\n[1]: https://reference.com"
|
||||
|
||||
result = analyze_citations(text)
|
||||
|
||||
assert isinstance(result, CitationAnalysisResult)
|
||||
assert result.data_source == "ai_platform"
|
||||
assert len(result.citations) > 0, "应提取到引用"
|
||||
assert "Apple是一家伟大的公司" in result.clean_response
|
||||
|
||||
def test_analyze_citations_empty_text(self):
|
||||
"""空文本处理"""
|
||||
result = analyze_citations("")
|
||||
assert isinstance(result, CitationAnalysisResult)
|
||||
assert result.data_source == "unknown"
|
||||
assert len(result.citations) == 0
|
||||
|
||||
def test_citation_analyzer_complete_flow(self):
|
||||
"""完整引用分析流程测试"""
|
||||
text = "这是一个测试 [链接](https://test.com) 和裸URL https://bare.com"
|
||||
|
||||
result = analyze_citations(text)
|
||||
|
||||
assert isinstance(result, CitationAnalysisResult)
|
||||
assert len(result.citations) > 0
|
||||
|
||||
def test_duplicate_url_deduplication(self):
|
||||
"""重复URL应该去重"""
|
||||
text = """
|
||||
访问 https://example.com
|
||||
再访问 https://example.com
|
||||
[链接](https://example.com)
|
||||
"""
|
||||
|
||||
citations = extract_urls_with_context(text)
|
||||
urls = [c.source_url for c in citations if c.source_url]
|
||||
|
||||
# 去重后应该只有一个
|
||||
assert urls.count("https://example.com") == 1
|
||||
|
||||
def test_markdown_link_priority(self):
|
||||
"""Markdown链接应优先于裸URL(标题更丰富)"""
|
||||
text = "[Apple官网](https://apple.com) 和裸URL https://microsoft.com"
|
||||
|
||||
md_links = extract_markdown_links(text)
|
||||
bare_urls = extract_urls_with_context(text)
|
||||
|
||||
# Markdown链接应该有标题
|
||||
assert len(md_links) > 0
|
||||
assert md_links[0].source_title is not None
|
||||
|
||||
def test_url_cleanup_punctuation(self):
|
||||
"""URL末尾标点符号应该被清理"""
|
||||
text = "访问 https://example.com, 和 https://test.com; 结束"
|
||||
|
||||
citations = extract_urls_with_context(text)
|
||||
urls = [c.source_url for c in citations if c.source_url]
|
||||
|
||||
# URL不应该以逗号或分号结尾
|
||||
for url in urls:
|
||||
assert not url.endswith(',')
|
||||
assert not url.endswith(';')
|
||||
|
||||
|
||||
class TestAdapterIntegration:
|
||||
"""适配器集成测试 - 验证所有平台适配器"""
|
||||
|
||||
def test_all_adapters_inherit_base(self):
|
||||
"""所有适配器应继承BasePlatformAdapter"""
|
||||
adapters = [KimiAdapter, WenxinAdapter, DoubaoAdapter]
|
||||
|
||||
for adapter_cls in adapters:
|
||||
instance = adapter_cls()
|
||||
assert hasattr(instance, 'query')
|
||||
assert hasattr(instance, 'platform_name')
|
||||
assert hasattr(instance, 'platform_url')
|
||||
assert callable(instance.query)
|
||||
|
||||
def test_adapter_has_required_properties(self):
|
||||
"""适配器应具有必需的属性"""
|
||||
adapter = KimiAdapter()
|
||||
|
||||
assert adapter.platform_name == "kimi"
|
||||
assert adapter.platform_url == "https://kimi.moonshot.cn"
|
||||
assert hasattr(adapter, 'is_configured')
|
||||
assert hasattr(adapter, 'close')
|
||||
|
||||
def test_kimi_adapter_properties(self):
|
||||
"""Kimi适配器特定属性"""
|
||||
adapter = KimiAdapter()
|
||||
|
||||
assert adapter.platform_name == "kimi"
|
||||
# is_configured取决于API Key是否设置
|
||||
assert isinstance(adapter.is_configured, bool)
|
||||
|
||||
def test_wenxin_adapter_properties(self):
|
||||
"""文心适配器特定属性"""
|
||||
adapter = WenxinAdapter()
|
||||
|
||||
assert adapter.platform_name == "wenxin"
|
||||
assert adapter.platform_url == "https://yiyan.baidu.com"
|
||||
assert hasattr(adapter, 'secret_key')
|
||||
|
||||
def test_doubao_adapter_properties(self):
|
||||
"""豆包适配器特定属性"""
|
||||
adapter = DoubaoAdapter()
|
||||
|
||||
assert adapter.platform_name == "doubao"
|
||||
assert hasattr(adapter, 'endpoint_id')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import pytest
|
||||
import asyncio
|
||||
import threading
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/Chiguyong/Code/Fischer/geo/backend')
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# 模块级别事件循环,用于在测试运行时保持调度器活跃
|
||||
_loop = None
|
||||
_loop_thread = None
|
||||
|
||||
|
||||
def start_scheduler_in_background():
|
||||
"""在后台线程中启动调度器的事件循环"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
global _loop, _loop_thread
|
||||
|
||||
if query_scheduler.scheduler.running:
|
||||
return
|
||||
|
||||
def run_loop():
|
||||
global _loop
|
||||
_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_loop)
|
||||
_loop.run_until_complete(start_scheduler_async())
|
||||
|
||||
async def start_scheduler_async():
|
||||
from app.workers.scheduler import query_scheduler
|
||||
query_scheduler.start()
|
||||
|
||||
_loop_thread = threading.Thread(target=run_loop, daemon=True)
|
||||
_loop_thread.start()
|
||||
_loop_thread.join(timeout=2) # 等待启动
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def scheduler_running():
|
||||
"""启动调度器"""
|
||||
start_scheduler_in_background()
|
||||
yield
|
||||
|
||||
|
||||
class TestScheduler:
|
||||
"""定时调度器测试"""
|
||||
|
||||
def test_scheduler_job_registered(self):
|
||||
"""调度器应注册所需任务"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
# 获取所有注册的任务
|
||||
jobs = query_scheduler.scheduler.get_jobs()
|
||||
job_ids = [job.id for job in jobs]
|
||||
|
||||
# 应包含检查查询任务
|
||||
assert "check_queries" in job_ids
|
||||
# 应包含检查pending任务
|
||||
assert "check_pending_tasks" in job_ids
|
||||
|
||||
def test_job_can_be_triggered_manually(self):
|
||||
"""任务应支持手动触发"""
|
||||
from app.workers.scheduler import run_job_now, query_scheduler
|
||||
|
||||
# 验证任务可以被获取
|
||||
job = query_scheduler.scheduler.get_job("check_queries")
|
||||
assert job is not None
|
||||
assert callable(job.func)
|
||||
|
||||
# 验证run_job_now返回True表示成功
|
||||
result = run_job_now("check_queries")
|
||||
assert result is True
|
||||
|
||||
def test_run_job_now_returns_false_for_unknown_job(self):
|
||||
"""不存在的任务应返回False"""
|
||||
from app.workers.scheduler import run_job_now
|
||||
|
||||
result = run_job_now("nonexistent_job")
|
||||
assert result is False
|
||||
|
||||
def test_job_id_is_unique(self):
|
||||
"""任务ID应唯一"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
jobs = query_scheduler.scheduler.get_jobs()
|
||||
job_ids = [job.id for job in jobs]
|
||||
assert len(job_ids) == len(set(job_ids))
|
||||
|
||||
def test_job_next_run_time_is_valid(self):
|
||||
"""任务的下次运行时间应有效"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
jobs = query_scheduler.scheduler.get_jobs()
|
||||
now = datetime.now(timezone.utc)
|
||||
for job in jobs:
|
||||
assert job.next_run_time is not None
|
||||
# 应该是未来时间
|
||||
job_time = job.next_run_time
|
||||
if job_time.tzinfo is None:
|
||||
job_time = job_time.replace(tzinfo=timezone.utc)
|
||||
assert job_time > now
|
||||
|
||||
def test_job_stores_metadata(self):
|
||||
"""任务应存储元数据"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
jobs = query_scheduler.scheduler.get_jobs()
|
||||
for job in jobs:
|
||||
# 任务应有ID和名称
|
||||
assert hasattr(job, 'id')
|
||||
assert hasattr(job, 'name')
|
||||
assert job.id is not None
|
||||
assert job.name is not None
|
||||
|
||||
def test_scheduler_has_two_jobs(self):
|
||||
"""调度器应有两个注册的任务"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
jobs = query_scheduler.scheduler.get_jobs()
|
||||
assert len(jobs) == 2
|
||||
|
||||
def test_check_queries_job_config(self):
|
||||
"""检查check_queries任务配置"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
job = query_scheduler.scheduler.get_job("check_queries")
|
||||
assert job is not None
|
||||
assert job.name == "检查并执行到期的查询任务"
|
||||
|
||||
def test_check_pending_tasks_job_config(self):
|
||||
"""检查check_pending_tasks任务配置"""
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
job = query_scheduler.scheduler.get_job("check_pending_tasks")
|
||||
assert job is not None
|
||||
assert job.name == "检查并执行遗留的pending查询任务"
|
||||
|
||||
def test_scheduler_instance_is_singleton(self):
|
||||
"""query_scheduler应是单例"""
|
||||
from app.workers.scheduler import query_scheduler, query_scheduler as qs2
|
||||
assert query_scheduler is qs2
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
/**
|
||||
* Agent监控Dashboard 单元测试
|
||||
*
|
||||
* 覆盖:执行记录列表、状态筛选、统计摘要、执行详情
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { agentsApi, type AgentTask, type TaskLog, type ExecutionStats } from "@/lib/api/agents";
|
||||
|
||||
// ── Mock next-auth/react ──────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
getSession: vi.fn(() =>
|
||||
Promise.resolve({ accessToken: "test-token-123" })
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Mock global fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchResponse<T>(data: T, ok = true, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 模拟数据 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockTasks: AgentTask[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
task_type: "geo_optimizer",
|
||||
status: "completed",
|
||||
priority: 1,
|
||||
input_data: { query: "test" },
|
||||
output_data: { result: "optimized" },
|
||||
error_message: null,
|
||||
started_at: "2024-01-15T10:00:00Z",
|
||||
completed_at: "2024-01-15T10:00:12Z",
|
||||
created_at: "2024-01-15T09:59:50Z",
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
agent_id: "agent-2",
|
||||
task_type: "citation_detector",
|
||||
status: "completed",
|
||||
priority: 0,
|
||||
input_data: { url: "https://example.com" },
|
||||
output_data: { citations: [] },
|
||||
error_message: null,
|
||||
started_at: "2024-01-15T11:00:00Z",
|
||||
completed_at: "2024-01-15T11:00:08Z",
|
||||
created_at: "2024-01-15T10:59:55Z",
|
||||
},
|
||||
{
|
||||
id: "task-3",
|
||||
agent_id: "agent-3",
|
||||
task_type: "content_gen",
|
||||
status: "running",
|
||||
priority: 2,
|
||||
input_data: { topic: "AI trends" },
|
||||
output_data: null,
|
||||
error_message: null,
|
||||
started_at: "2024-01-15T12:00:00Z",
|
||||
completed_at: null,
|
||||
created_at: "2024-01-15T11:59:50Z",
|
||||
},
|
||||
{
|
||||
id: "task-4",
|
||||
agent_id: "agent-1",
|
||||
task_type: "geo_optimizer",
|
||||
status: "failed",
|
||||
priority: 1,
|
||||
input_data: { query: "error test" },
|
||||
output_data: null,
|
||||
error_message: "Connection timeout",
|
||||
started_at: "2024-01-15T13:00:00Z",
|
||||
completed_at: "2024-01-15T13:00:05Z",
|
||||
created_at: "2024-01-15T12:59:50Z",
|
||||
},
|
||||
];
|
||||
|
||||
const mockLogs: TaskLog[] = [
|
||||
{
|
||||
id: "log-1",
|
||||
task_id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
log_level: "INFO",
|
||||
message: "Task started",
|
||||
metadata: { step: 1 },
|
||||
created_at: "2024-01-15T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "log-2",
|
||||
task_id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
log_level: "INFO",
|
||||
message: "Processing query: test",
|
||||
metadata: { step: 2 },
|
||||
created_at: "2024-01-15T10:00:05Z",
|
||||
},
|
||||
{
|
||||
id: "log-3",
|
||||
task_id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
log_level: "DEBUG",
|
||||
message: "Result: optimized",
|
||||
metadata: { step: 3 },
|
||||
created_at: "2024-01-15T10:00:10Z",
|
||||
},
|
||||
];
|
||||
|
||||
// ── listTasks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent监控Dashboard - 执行记录列表", () => {
|
||||
it("应返回执行记录列表", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: mockTasks, total: 4 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.listTasks("test-token");
|
||||
|
||||
expect(result.items).toHaveLength(4);
|
||||
expect(result.total).toBe(4);
|
||||
expect(result.items[0].task_type).toBe("geo_optimizer");
|
||||
});
|
||||
|
||||
it("应包含执行时间、状态、耗时信息", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: mockTasks, total: 4 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.listTasks("test-token");
|
||||
const task = result.items[0];
|
||||
|
||||
expect(task.started_at).toBeDefined();
|
||||
expect(task.completed_at).toBeDefined();
|
||||
expect(task.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 状态筛选 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent监控Dashboard - 状态筛选", () => {
|
||||
it("应支持按 running 状态筛选", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: [mockTasks[2]], total: 1 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.listTasks("test-token", { status: "running" });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].status).toBe("running");
|
||||
});
|
||||
|
||||
it("应支持按 completed 状态筛选", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: mockTasks.filter(t => t.status === "completed"), total: 2 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.listTasks("test-token", { status: "completed" });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items.every(t => t.status === "completed")).toBe(true);
|
||||
});
|
||||
|
||||
it("应支持按 failed 状态筛选", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: [mockTasks[3]], total: 1 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.listTasks("test-token", { status: "failed" });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].error_message).toBe("Connection timeout");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 执行详情和日志 ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent监控Dashboard - 执行详情和日志", () => {
|
||||
it("应获取单个执行详情", async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockFetchResponse(mockTasks[0]));
|
||||
|
||||
const result = await agentsApi.getTask("test-token", "task-1");
|
||||
|
||||
expect(result.id).toBe("task-1");
|
||||
expect(result.task_type).toBe("geo_optimizer");
|
||||
});
|
||||
|
||||
it("应获取执行日志列表", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: mockLogs, total: 3 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.getTaskLogs("test-token", "task-1");
|
||||
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items[0].message).toBe("Task started");
|
||||
});
|
||||
|
||||
it("错误信息应清晰展示", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: mockTasks.filter(t => t.status === "failed"), total: 1 })
|
||||
);
|
||||
|
||||
const result = await agentsApi.listTasks("test-token", { status: "failed" });
|
||||
const failedTask = result.items[0];
|
||||
|
||||
expect(failedTask.status).toBe("failed");
|
||||
expect(failedTask.error_message).toBe("Connection timeout");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 统计摘要 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent监控Dashboard - 统计摘要", () => {
|
||||
it("应计算总执行次数", () => {
|
||||
const tasks = mockTasks;
|
||||
const total = tasks.length;
|
||||
expect(total).toBe(4);
|
||||
});
|
||||
|
||||
it("应计算成功率统计", () => {
|
||||
const tasks = mockTasks;
|
||||
const successCount = tasks.filter(t => t.status === "completed").length;
|
||||
const total = tasks.length;
|
||||
const successRate = (successCount / total) * 100;
|
||||
|
||||
expect(successCount).toBe(2);
|
||||
expect(successRate).toBe(50);
|
||||
});
|
||||
|
||||
it("应计算平均耗时", () => {
|
||||
const completedTasks = mockTasks.filter(t => t.status === "completed" && t.started_at && t.completed_at);
|
||||
const durations = completedTasks.map(t => {
|
||||
const start = new Date(t.started_at!).getTime();
|
||||
const end = new Date(t.completed_at!).getTime();
|
||||
return (end - start) / 1000; // 转换为秒
|
||||
});
|
||||
const avgDuration = durations.reduce((sum, d) => sum + d, 0) / durations.length;
|
||||
|
||||
expect(avgDuration).toBe(10.5); // (12 + 8) / 2 = 10.5秒
|
||||
});
|
||||
|
||||
it("应显示运行中的任务数量", () => {
|
||||
const runningCount = mockTasks.filter(t => t.status === "running").length;
|
||||
expect(runningCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getTaskLogs 参数 ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("Agent监控Dashboard - getTaskLogs 参数", () => {
|
||||
it("应正确传递 skip 和 limit 参数", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ items: [], total: 0 })
|
||||
);
|
||||
|
||||
await agentsApi.getTaskLogs("test-token", "task-1", { skip: 10, limit: 20 });
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain("/api/v1/agents/tasks/task-1/logs");
|
||||
expect(url).toContain("skip=10");
|
||||
expect(url).toContain("limit=20");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,22 +1,367 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { agentsApi, type AgentTask, type TaskLog } from "@/lib/api/agents";
|
||||
import { MetricCard } from "@/components/business/metric-card";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle } from "lucide-react";
|
||||
|
||||
type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
type FilterStatus = "all" | TaskStatus;
|
||||
|
||||
const STATUS_CONFIG: Record<TaskStatus, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
pending: { label: "等待中", icon: <Clock className="h-3 w-3" />, color: "bg-gray-100 text-gray-600" },
|
||||
running: { label: "运行中", icon: <Loader2 className="h-3 w-3 animate-spin" />, color: "bg-blue-100 text-blue-600" },
|
||||
completed: { label: "已完成", icon: <CheckCircle2 className="h-3 w-3" />, color: "bg-emerald-100 text-emerald-600" },
|
||||
failed: { label: "失败", icon: <XCircle className="h-3 w-3" />, color: "bg-red-100 text-red-600" },
|
||||
cancelled: { label: "已取消", icon: <AlertCircle className="h-3 w-3" />, color: "bg-yellow-100 text-yellow-600" },
|
||||
};
|
||||
|
||||
function formatDuration(startedAt?: string, completedAt?: string): string {
|
||||
if (!startedAt) return "-";
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const seconds = Math.round((end - start) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
interface ExecutionStats {
|
||||
total: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
runningCount: number;
|
||||
successRate: number;
|
||||
avgDurationSeconds: number;
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { data: session } = useSession();
|
||||
const token = (session as { accessToken?: string })?.accessToken;
|
||||
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>("all");
|
||||
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
|
||||
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
|
||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||
|
||||
// 获取执行记录
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
|
||||
async function fetchTasks() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = filterStatus !== "all" ? { status: filterStatus, limit: 50 } : { limit: 50 };
|
||||
const result = await agentsApi.listTasks(token, params);
|
||||
setTasks(result.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "获取执行记录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchTasks();
|
||||
}, [token, filterStatus]);
|
||||
|
||||
// 获取任务日志
|
||||
const fetchTaskLogs = async (taskId: string) => {
|
||||
if (!token) return;
|
||||
|
||||
setLoadingLogs(true);
|
||||
try {
|
||||
const result = await agentsApi.getTaskLogs(token, taskId);
|
||||
setTaskLogs(result.items);
|
||||
} catch (err) {
|
||||
console.error("获取日志失败:", err);
|
||||
} finally {
|
||||
setLoadingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算统计摘要
|
||||
const stats = useMemo<ExecutionStats>(() => {
|
||||
const allTasks = tasks;
|
||||
const successCount = allTasks.filter(t => t.status === "completed").length;
|
||||
const failedCount = allTasks.filter(t => t.status === "failed").length;
|
||||
const runningCount = allTasks.filter(t => t.status === "running" || t.status === "pending").length;
|
||||
const total = allTasks.length;
|
||||
|
||||
const completedTasks = allTasks.filter(
|
||||
t => t.status === "completed" && t.started_at && t.completed_at
|
||||
);
|
||||
const totalDuration = completedTasks.reduce((sum, t) => {
|
||||
const start = new Date(t.started_at!).getTime();
|
||||
const end = new Date(t.completed_at!).getTime();
|
||||
return sum + (end - start) / 1000;
|
||||
}, 0);
|
||||
const avgDurationSeconds = completedTasks.length > 0 ? totalDuration / completedTasks.length : 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
successCount,
|
||||
failedCount,
|
||||
runningCount,
|
||||
successRate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
avgDurationSeconds,
|
||||
};
|
||||
}, [tasks]);
|
||||
|
||||
// 打开详情弹窗
|
||||
const handleRowClick = async (task: AgentTask) => {
|
||||
setSelectedTask(task);
|
||||
await fetchTaskLogs(task.id);
|
||||
};
|
||||
|
||||
const filterButtons: { status: FilterStatus; label: string }[] = [
|
||||
{ status: "all", label: "全部" },
|
||||
{ status: "running", label: "运行中" },
|
||||
{ status: "completed", label: "已完成" },
|
||||
{ status: "failed", label: "失败" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">AI Agent 管理</h1>
|
||||
<p className="text-muted-foreground">管理和监控AI Agent的运行状态与配置</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Bot className="h-6 w-6" />
|
||||
Agent监控
|
||||
</h1>
|
||||
<p className="text-muted-foreground">监控Agent执行状态和历史记录</p>
|
||||
</div>
|
||||
|
||||
{/* 统计摘要卡片 */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<MetricCard
|
||||
label="总执行次数"
|
||||
value={stats.total}
|
||||
icon={<Bot className="h-5 w-5 text-blue-500" />}
|
||||
trend="neutral"
|
||||
/>
|
||||
<MetricCard
|
||||
label="成功率"
|
||||
value={`${stats.successRate.toFixed(1)}%`}
|
||||
icon={<CheckCircle2 className="h-5 w-5 text-emerald-500" />}
|
||||
trend={stats.successRate >= 80 ? "up" : "down"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="平均耗时"
|
||||
value={stats.avgDurationSeconds > 0 ? `${stats.avgDurationSeconds.toFixed(1)}s` : "-"}
|
||||
icon={<Clock className="h-5 w-5 text-purple-500" />}
|
||||
trend="neutral"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex gap-2">
|
||||
{filterButtons.map(({ status, label }) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
filterStatus === status
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{status === "running" && stats.runningCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-blue-200 text-blue-800">
|
||||
{stats.runningCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 执行历史列表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>功能开发中</CardTitle>
|
||||
<CardTitle>执行历史</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>此功能正在开发中,敬请期待。</p>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500">
|
||||
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无执行记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任务类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>耗时</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>错误信息</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map(task => {
|
||||
const config = STATUS_CONFIG[task.status];
|
||||
return (
|
||||
<TableRow
|
||||
key={task.id}
|
||||
onClick={() => handleRowClick(task)}
|
||||
className="cursor-pointer hover:bg-muted/40"
|
||||
>
|
||||
<TableCell className="font-medium">{task.task_type}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={cn("gap-1", config.color)} variant="secondary">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(task.started_at, task.completed_at)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(task.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-red-500 max-w-xs truncate">
|
||||
{task.error_message || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 执行详情弹窗 */}
|
||||
<Dialog open={!!selectedTask} onOpenChange={() => setSelectedTask(null)}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>执行详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTask && (
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">任务ID</p>
|
||||
<p className="font-mono text-sm">{selectedTask.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">任务类型</p>
|
||||
<p className="font-medium">{selectedTask.task_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">状态</p>
|
||||
<Badge
|
||||
className={cn("gap-1", STATUS_CONFIG[selectedTask.status].color)}
|
||||
variant="secondary"
|
||||
>
|
||||
{STATUS_CONFIG[selectedTask.status].icon}
|
||||
{STATUS_CONFIG[selectedTask.status].label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">优先级</p>
|
||||
<p>{selectedTask.priority}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">开始时间</p>
|
||||
<p>{selectedTask.started_at ? formatDate(selectedTask.started_at) : "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">完成时间</p>
|
||||
<p>{selectedTask.completed_at ? formatDate(selectedTask.completed_at) : "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{selectedTask.error_message && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-600">错误信息</p>
|
||||
<p className="text-sm text-red-500 mt-1">{selectedTask.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 执行日志 */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">执行日志</p>
|
||||
{loadingLogs ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : taskLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无日志</p>
|
||||
) : (
|
||||
<div className="bg-gray-900 text-gray-100 rounded-lg p-3 font-mono text-xs max-h-64 overflow-y-auto">
|
||||
{taskLogs.map(log => (
|
||||
<div key={log.id} className="py-1">
|
||||
<span className="text-gray-500">[{formatDate(log.created_at)}]</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2",
|
||||
log.log_level === "ERROR" && "text-red-400",
|
||||
log.log_level === "WARNING" && "text-yellow-400",
|
||||
log.log_level === "INFO" && "text-blue-400",
|
||||
log.log_level === "DEBUG" && "text-gray-500"
|
||||
)}
|
||||
>
|
||||
[{log.log_level}]
|
||||
</span>
|
||||
<span className="ml-2">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,40 @@ export interface AgentRunLog {
|
|||
message?: string;
|
||||
}
|
||||
|
||||
// 执行记录相关类型
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
task_type: string;
|
||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
priority: number;
|
||||
input_data: Record<string, unknown>;
|
||||
output_data?: Record<string, unknown>;
|
||||
error_message?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TaskLog {
|
||||
id: string;
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
log_level: "DEBUG" | "INFO" | "WARNING" | "ERROR";
|
||||
message: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionStats {
|
||||
total: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
running_count: number;
|
||||
success_rate: number;
|
||||
avg_duration_seconds: number;
|
||||
}
|
||||
|
||||
// ── API ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const agentsApi = {
|
||||
|
|
@ -53,4 +87,44 @@ export const agentsApi = {
|
|||
{},
|
||||
token
|
||||
) as Promise<AgentRunLog[]>,
|
||||
// 执行记录相关
|
||||
listTasks: (token: string, params?: {
|
||||
status?: string;
|
||||
task_type?: string;
|
||||
agent_name?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.task_type) searchParams.set("task_type", params.task_type);
|
||||
if (params?.agent_name) searchParams.set("agent_name", params.agent_name);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return fetchWithAuth(
|
||||
`/api/v1/agents/tasks/${query ? `?${query}` : ""}`,
|
||||
{},
|
||||
token
|
||||
) as Promise<{ items: AgentTask[]; total: number }>;
|
||||
},
|
||||
getTask: (token: string, taskId: string) =>
|
||||
fetchWithAuth(`/api/v1/agents/tasks/${taskId}`, {}, token) as Promise<AgentTask>,
|
||||
getTaskLogs: (token: string, taskId: string, params?: { skip?: number; limit?: number }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit));
|
||||
const query = searchParams.toString();
|
||||
return fetchWithAuth(
|
||||
`/api/v1/agents/tasks/${taskId}/logs${query ? `?${query}` : ""}`,
|
||||
{},
|
||||
token
|
||||
) as Promise<{ items: TaskLog[]; total: number }>;
|
||||
},
|
||||
cancelTask: (token: string, taskId: string) =>
|
||||
fetchWithAuth(
|
||||
`/api/v1/agents/tasks/${taskId}/cancel`,
|
||||
{ method: "POST" },
|
||||
token
|
||||
) as Promise<{ task_id: string; status: string; message: string }>,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue