From 67d75785505f37ddefcb4cc2575aa0681918abff Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sat, 23 May 2026 22:02:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P1=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=AE=8C=E6=88=90=20-=20=E8=BF=81=E7=A7=BB/?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AF=B9=E6=8E=A5/=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=99=A8/=E9=85=8D=E7=BD=AE/Agent=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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客户端方法 - 执行历史和日志展示 --- backend/.env.example | 87 +++++ .../810a29804f5a_sync_users_nullable_state.py | 48 +++ backend/app/api/platforms.py | 171 +++++++++ backend/app/main.py | 2 + backend/app/models/__init__.py | 12 + backend/app/models/user.py | 4 +- backend/app/services/llm/rate_limiter.py | 4 + backend/app/workers/scheduler.py | 13 + backend/tests/test_config.py | 75 ++++ backend/tests/test_database_migration.py | 130 +++++++ backend/tests/test_platform_adapters.py | 342 +++++++++++++++++ backend/tests/test_scheduler.py | 142 +++++++ frontend/__tests__/agent-dashboard.test.ts | 283 ++++++++++++++ .../app/(dashboard)/dashboard/agents/page.tsx | 353 +++++++++++++++++- frontend/lib/api/agents.ts | 74 ++++ 15 files changed, 1734 insertions(+), 6 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/alembic/versions/810a29804f5a_sync_users_nullable_state.py create mode 100644 backend/app/api/platforms.py create mode 100644 backend/tests/test_config.py create mode 100644 backend/tests/test_database_migration.py create mode 100644 backend/tests/test_platform_adapters.py create mode 100644 backend/tests/test_scheduler.py create mode 100644 frontend/__tests__/agent-dashboard.test.ts diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..2eee706 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/alembic/versions/810a29804f5a_sync_users_nullable_state.py b/backend/alembic/versions/810a29804f5a_sync_users_nullable_state.py new file mode 100644 index 0000000..7a74540 --- /dev/null +++ b/backend/alembic/versions/810a29804f5a_sync_users_nullable_state.py @@ -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 ### diff --git a/backend/app/api/platforms.py b/backend/app/api/platforms.py new file mode 100644 index 0000000..2be746a --- /dev/null +++ b/backend/app/api/platforms.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 9b0b29f..af8af51 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=["可观测性"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1e1ada3..392fa78 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 233ac3a..129d859 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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"), diff --git a/backend/app/services/llm/rate_limiter.py b/backend/app/services/llm/rate_limiter.py index c335645..fe5b144 100644 --- a/backend/app/services/llm/rate_limiter.py +++ b/backend/app/services/llm/rate_limiter.py @@ -103,3 +103,7 @@ _rate_limiter: TokenBucketRateLimiter | None = None def get_rate_limiter() -> TokenBucketRateLimiter: """获取全局速率限制器实例""" return TokenBucketRateLimiter.get_instance() + + +# 别名以支持测试验收标准 +RateLimiter = TokenBucketRateLimiter diff --git a/backend/app/workers/scheduler.py b/backend/app/workers/scheduler.py index c63ae86..fa03bba 100644 --- a/backend/app/workers/scheduler.py +++ b/backend/app/workers/scheduler.py @@ -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 diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..4ad648e --- /dev/null +++ b/backend/tests/test_config.py @@ -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 diff --git a/backend/tests/test_database_migration.py b/backend/tests/test_database_migration.py new file mode 100644 index 0000000..d412b2f --- /dev/null +++ b/backend/tests/test_database_migration.py @@ -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 为空" diff --git a/backend/tests/test_platform_adapters.py b/backend/tests/test_platform_adapters.py new file mode 100644 index 0000000..85f2c94 --- /dev/null +++ b/backend/tests/test_platform_adapters.py @@ -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"]) diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..30d1a03 --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -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 diff --git a/frontend/__tests__/agent-dashboard.test.ts b/frontend/__tests__/agent-dashboard.test.ts new file mode 100644 index 0000000..03e7f73 --- /dev/null +++ b/frontend/__tests__/agent-dashboard.test.ts @@ -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(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"); + }); +}); diff --git a/frontend/app/(dashboard)/dashboard/agents/page.tsx b/frontend/app/(dashboard)/dashboard/agents/page.tsx index 8011dca..fa792be 100644 --- a/frontend/app/(dashboard)/dashboard/agents/page.tsx +++ b/frontend/app/(dashboard)/dashboard/agents/page.tsx @@ -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 = { + pending: { label: "等待中", icon: , color: "bg-gray-100 text-gray-600" }, + running: { label: "运行中", icon: , color: "bg-blue-100 text-blue-600" }, + completed: { label: "已完成", icon: , color: "bg-emerald-100 text-emerald-600" }, + failed: { label: "失败", icon: , color: "bg-red-100 text-red-600" }, + cancelled: { label: "已取消", icon: , 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterStatus, setFilterStatus] = useState("all"); + const [selectedTask, setSelectedTask] = useState(null); + const [taskLogs, setTaskLogs] = useState([]); + 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(() => { + 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 (
+ {/* 页面标题 */}
-

AI Agent 管理

-

管理和监控AI Agent的运行状态与配置

+

+ + Agent监控 +

+

监控Agent执行状态和历史记录

+ + {/* 统计摘要卡片 */} +
+ } + trend="neutral" + /> + } + trend={stats.successRate >= 80 ? "up" : "down"} + /> + 0 ? `${stats.avgDurationSeconds.toFixed(1)}s` : "-"} + icon={} + trend="neutral" + /> +
+ + {/* 状态筛选 */} +
+ {filterButtons.map(({ status, label }) => ( + + ))} +
+ + {/* 执行历史列表 */} - 功能开发中 + 执行历史 -

此功能正在开发中,敬请期待。

+ {loading ? ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ) : error ? ( +
+ +

{error}

+
+ ) : tasks.length === 0 ? ( +
+ +

暂无执行记录

+
+ ) : ( + + + + 任务类型 + 状态 + 耗时 + 创建时间 + 错误信息 + + + + {tasks.map(task => { + const config = STATUS_CONFIG[task.status]; + return ( + handleRowClick(task)} + className="cursor-pointer hover:bg-muted/40" + > + {task.task_type} + + + {config.icon} + {config.label} + + + {formatDuration(task.started_at, task.completed_at)} + + {formatDate(task.created_at)} + + + {task.error_message || "-"} + + + ); + })} + +
+ )}
+ + {/* 执行详情弹窗 */} + setSelectedTask(null)}> + + + 执行详情 + + {selectedTask && ( +
+ {/* 基本信息 */} +
+
+

任务ID

+

{selectedTask.id}

+
+
+

任务类型

+

{selectedTask.task_type}

+
+
+

状态

+ + {STATUS_CONFIG[selectedTask.status].icon} + {STATUS_CONFIG[selectedTask.status].label} + +
+
+

优先级

+

{selectedTask.priority}

+
+
+

开始时间

+

{selectedTask.started_at ? formatDate(selectedTask.started_at) : "-"}

+
+
+

完成时间

+

{selectedTask.completed_at ? formatDate(selectedTask.completed_at) : "-"}

+
+
+ + {/* 错误信息 */} + {selectedTask.error_message && ( +
+

错误信息

+

{selectedTask.error_message}

+
+ )} + + {/* 执行日志 */} +
+

执行日志

+ {loadingLogs ? ( +
+ {[1, 2, 3].map(i => ( + + ))} +
+ ) : taskLogs.length === 0 ? ( +

暂无日志

+ ) : ( +
+ {taskLogs.map(log => ( +
+ [{formatDate(log.created_at)}] + + [{log.log_level}] + + {log.message} +
+ ))} +
+ )} +
+
+ )} +
+
); } diff --git a/frontend/lib/api/agents.ts b/frontend/lib/api/agents.ts index c5c869e..ef4c34a 100644 --- a/frontend/lib/api/agents.ts +++ b/frontend/lib/api/agents.ts @@ -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; + output_data?: Record; + 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; + 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, + // 执行记录相关 + 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, + 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 }>, };