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:
chiguyong 2026-05-23 22:02:49 +08:00
parent 9e63915f42
commit 67d7578550
15 changed files with 1734 additions and 6 deletions

87
backend/.env.example Normal file
View File

@ -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

View File

@ -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 ###

View File

@ -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

View File

@ -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=["可观测性"])

View File

@ -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",
]

View File

@ -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"),

View File

@ -103,3 +103,7 @@ _rate_limiter: TokenBucketRateLimiter | None = None
def get_rate_limiter() -> TokenBucketRateLimiter:
"""获取全局速率限制器实例"""
return TokenBucketRateLimiter.get_instance()
# 别名以支持测试验收标准
RateLimiter = TokenBucketRateLimiter

View File

@ -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

View File

@ -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

View File

@ -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 为空"

View File

@ -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"])

View File

@ -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

View File

@ -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");
});
});

View File

@ -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>
);
}

View File

@ -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 }>,
};