Compare commits

...

1 Commits

Author SHA1 Message Date
wlt 7317fbe012 feat: add AI Digital Employee agent orchestrator with pipeline tracking
- New AgentPipeline model with JSONB pipeline_data for stages/leads/summary
- AgentOrchestrator service chains DiscoveryService search→analyze→outreach→auto-save
- 3 new API endpoints: POST /agent/start, GET /agent/pipelines, GET /agent/{id}
- Full Agent dashboard Vue component with stats, pipeline grid, leads table, outreach preview
- Sidebar redesigned with AI Agent as primary entry point
- Updated PROGRESS.md, AGENTS.md, DATABASE_SCHEMA.md with latest state
2026-06-16 18:30:56 +08:00
15 changed files with 1052 additions and 83 deletions
+1 -1
View File
@@ -57,4 +57,4 @@ docker-compose.override.yml
backend/app/services/_bing_search.js backend/app/services/_bing_search.js
# WeChat mini-program private key # WeChat mini-program private key
uni-app/private.key uni-app/private.key.omo/
+21 -7
View File
@@ -10,6 +10,23 @@
- **Quick questions**: configurable via `ai_assistant_quick_questions` `SystemConfig` key - **Quick questions**: configurable via `ai_assistant_quick_questions` `SystemConfig` key
- **System prompt**: configurable via `ai_assistant_prompt` `SystemConfig` key - **System prompt**: configurable via `ai_assistant_prompt` `SystemConfig` key
## AI 数字员工 (Agent Orchestrator) 🆕
- **Dashboard**: `user-frontend/src/views/Agent.vue` — 全功能仪表盘,位于 `/agent` 路由
- **编排服务**: `backend/app/services/agent_orchestrator.py``AgentOrchestrator`
- `start_pipeline(user_id, product_name, product_description, target_market)` — 启动完整流程
- `get_pipeline(pipeline_id, user_id)` — 获取流水线详情
- `list_pipelines(user_id, page, size)` — 分页列表
- **数据模型**: `backend/app/models/agent_pipeline.py``AgentPipeline` (表: `agent_pipelines`)
- JSONB `pipeline_data` 存储 stages + leads + summary
- **API 端点**: `backend/app/api/v1/agent.py` — 3 个端点
- `POST /api/v1/agent/start` — 启动新任务 (timeout: 300s)
- `GET /api/v1/agent/pipelines` — 任务列表
- `GET /api/v1/agent/{pipeline_id}` — 任务详情
- **流程**: 用户输入产品+市场 → AgentOrchestrator 串接 DiscoveryService.search() → analyze() → outreach() → 自动保存高匹配客户
- **前端入口**: `UserLayout.vue` 侧边栏首位 "AI数字员工" (MagicStick 图标)
- **迁移**: 需要运行 `alembic revision --autogenerate -m "add agent_pipelines"` 创建 `agent_pipelines`
## Architecture ## Architecture
- **Backend**: `backend/` — FastAPI + SQLAlchemy 1.4 async + asyncpg, single `app.main:app` - **Backend**: `backend/` — FastAPI + SQLAlchemy 1.4 async + asyncpg, single `app.main:app`
@@ -97,12 +114,9 @@ alembic revision --autogenerate -m "desc"
- **Stripe**: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` in `.env`. `StripePaymentService` via Checkout Sessions. Selected when `pay_type` is `card`/`stripe`. Webhook `POST /api/v1/payment/stripe-webhook`. - **Stripe**: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` in `.env`. `StripePaymentService` via Checkout Sessions. Selected when `pay_type` is `card`/`stripe`. Webhook `POST /api/v1/payment/stripe-webhook`.
- **PayPal**: `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_SANDBOX=True` in `.env`. `PayPalPaymentService` via Orders v2 API. Selected when `pay_type` is `paypal`. Webhook `POST /api/v1/payment/paypal-webhook`. - **PayPal**: `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_SANDBOX=True` in `.env`. `PayPalPaymentService` via Orders v2 API. Selected when `pay_type` is `paypal`. Webhook `POST /api/v1/payment/paypal-webhook`.
- **Credit purchase**: `POST /api/v1/credits/stripe-purchase` with `gateway: "stripe"|"paypal"` for overseas payments (USD), returns `session_url` for redirect. Gateway-agnostic: `gateway` param selects the provider. - **Credit purchase**: `POST /api/v1/credits/stripe-purchase` with `gateway: "stripe"|"paypal"` for overseas payments (USD), returns `session_url` for redirect. Gateway-agnostic: `gateway` param selects the provider.
- **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`. - **Agent Pipeline timeout**: `POST /api/v1/agent/start` may take 2-3 minutes to complete (it chains search → analyze → outreach synchronously). Frontend timeout set to 300s.
- **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing. - **Agent auto-save**: High-scoring leads (>=70 match_score) are auto-saved as Customer records with `source='ai_agent:{pipeline_id}'`. Duplicate check by name+user_id.
- **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape. - **Agent pipeline_data JSONB**: Contains stages progress, leads array (with outreach content), and summary stats. This is the source of truth for the frontend dashboard rendering.
- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`.
- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header.
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
## Project Conventions ## Project Conventions
@@ -110,7 +124,7 @@ alembic revision --autogenerate -m "desc"
- **Chinese UI** — mobile-first, for foreign-trade SOHOs/small teams - **Chinese UI** — mobile-first, for foreign-trade SOHOs/small teams
- **No comments in code** unless explicitly asked - **No comments in code** unless explicitly asked
- **Commit messages** focus on "why" not "what", in English - **Commit messages** focus on "why" not "what", in English
- **Services** instantiate `MarketingService()` (no db needed). For customer health: `CustomerHealthService(db)` - **Services** instantiate `MarketingService()` (no db needed). For customer health: `CustomerHealthService(db)`. For agent: `AgentOrchestrator(db)`.
- **AI providers** in `backend/app/ai/providers/` — inherit from `OpenAIProvider` if compatible with OpenAI API format - **AI providers** in `backend/app/ai/providers/` — inherit from `OpenAIProvider` if compatible with OpenAI API format
- **Static assets** go in `uni-app/src/static/` - **Static assets** go in `uni-app/src/static/`
- **Test DB**: `foreign_trade_test` (uses credentials from `conftest.py`, not `.env`) - **Test DB**: `foreign_trade_test` (uses credentials from `conftest.py`, not `.env`)
+38 -15
View File
@@ -1,7 +1,7 @@
# TradeMate (外贸小助手) - 项目进度文档 # TradeMate (外贸小助手) - 项目进度文档
**更新时间**: 2026-06-02 12:00 **更新时间**: 2026-06-16 18:30
**状态**: ✅ 生产环境运行中 — AI 路由 DB 驱动 + 翻译配额全链路 + ECS RAM 角色认证 **状态**: ✅ 生产环境运行中 — AI 路由 DB 驱动 + 翻译配额全链路 + ECS RAM 角色认证 + AI 数字员工
--- ---
@@ -74,7 +74,24 @@
| 联系人提取 | `discovery.py` | 点击从公司官网抓取 Email/Phone/WhatsApp/WeChat | | 联系人提取 | `discovery.py` | 点击从公司官网抓取 Email/Phone/WhatsApp/WeChat |
| 真实搜索结果 | `mcp_search_server.py` | 对接 Google Custom Search 返回真实数据 | | 真实搜索结果 | `mcp_search_server.py` | 对接 Google Custom Search 返回真实数据 |
### 5. 落地页 + 推荐系统 + 付费体系 ✅ ### 5. AI 数字员工 (Agent Orchestrator) ✅ [NEW]
| 功能 | 文件 | 说明 |
|------|------|------|
| **Pipeline 模型** | `models/agent_pipeline.py` | `agent_pipelines` 表 — UUID PK + JSONB pipeline_data |
| **编排服务** | `services/agent_orchestrator.py` | 串接 DiscoveryService → 分析 → 评分 → 触达 → 自动入库 |
| **Agent API** | `api/v1/agent.py` | 3 端点: POST /start, GET /pipelines, GET /{id} |
| **Agent 仪表盘** | `user-frontend/src/views/Agent.vue` | 统计卡片 + 任务列表 + 流水线进度 + 线索表格 + 触达预览 |
| **侧边栏入口** | `layouts/UserLayout.vue` | "AI数字员工" 作为首位菜单项,图标 MagicStick |
**工作流程**:
1. 用户输入产品名称 + 描述 + 目标市场
2. Agent 自动搜索 → AI 分析匹配度 → 评分排序 → Top 5 生成触达文案
3. 高匹配客户 (≥70分) 自动保存到客户列表
4. 用户在线预览 WhatsApp/LinkedIn/Email 触达文案
5. 4 阶段流水线可视进度 (搜索→分析→触达→完成)
### 6. 落地页 + 推荐系统 + 付费体系 ✅
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
@@ -84,7 +101,7 @@
| 年费定价 | `payment.py` — 新增 yearly 套餐选项 | | 年费定价 | `payment.py` — 新增 yearly 套餐选项 |
| 搜索 API 管理 | `admin_search.py` — 管理后台配置搜索提供商 | | 搜索 API 管理 | `admin_search.py` — 管理后台配置搜索提供商 |
### 5.1 支付系统 ✅ ### 6.1 支付系统 ✅
| 组件 | 文件 | 说明 | | 组件 | 文件 | 说明 |
|------|------|------| |------|------|------|
@@ -104,7 +121,7 @@
**凭证**: `PAY_API_KEY` / `PAY_API_SECRET``.env` 读取(外贸助手密钥),HMAC-SHA256 认证 **凭证**: `PAY_API_KEY` / `PAY_API_SECRET``.env` 读取(外贸助手密钥),HMAC-SHA256 认证
### 6. PC 桌面端布局 ✅ ### 7. PC 桌面端布局 ✅
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
@@ -113,7 +130,7 @@
| 消除重复 tabbar | 桌面端侧边栏替代移动端底部导航 | | 消除重复 tabbar | 桌面端侧边栏替代移动端底部导航 |
| 消除组件边界 | 侧边栏完全在 App.vue 内部 | | 消除组件边界 | 侧边栏完全在 App.vue 内部 |
### 7. Bug 修复 (共 13 个) ### 8. Bug 修复 (共 13 个)
| 序号 | 文件 | 问题描述 | 状态 | | 序号 | 文件 | 问题描述 | 状态 |
|------|------|----------|------| |------|------|----------|------|
@@ -133,7 +150,7 @@
| 14 | `app/api/v1/auth.py` | 登录/注册 CSRF 鸡生蛋 — 匿名用户无 cookie 导致 403 | ✅ 已修复 | | 14 | `app/api/v1/auth.py` | 登录/注册 CSRF 鸡生蛋 — 匿名用户无 cookie 导致 403 | ✅ 已修复 |
| 15 | 4 个前端登录/注册页面 | 后端错误英文直接展示给用户 | ✅ 已修复 | | 15 | 4 个前端登录/注册页面 | 后端错误英文直接展示给用户 | ✅ 已修复 |
### 8. 游客模式 (Guest Mode) ✅ ### 9. 游客模式 (Guest Mode) ✅
| 功能 | 接口 | 说明 | | 功能 | 接口 | 说明 |
|------|------|------| |------|------|------|
@@ -141,7 +158,7 @@
| 公开翻译 | `POST /api/v1/translate/public/translate` | 无需认证 | | 公开翻译 | `POST /api/v1/translate/public/translate` | 无需认证 |
| 公开信息提取 | `POST /api/v1/translate/public/extract` | 无需认证 | | 公开信息提取 | `POST /api/v1/translate/public/extract` | 无需认证 |
### 9. 管理后台完整可用 ### 10. 管理后台完整可用
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
@@ -152,7 +169,7 @@
| AI 模型配置 | 在线增删改 AI 提供商、重载配置、启停控制 | | AI 模型配置 | 在线增删改 AI 提供商、重载配置、启停控制 |
| 搜索配置 | 搜索提供商管理 | | 搜索配置 | 搜索提供商管理 |
### 10. 翻译配额全链路 ✅ ### 11. 翻译配额全链路 ✅
| 组件 | 说明 | | 组件 | 说明 |
|------|------| |------|------|
@@ -162,7 +179,7 @@
| `OpenAIProvider.translate()` | LLM 翻译也走配额检查 (`llm` 版本) | | `OpenAIProvider.translate()` | LLM 翻译也走配额检查 (`llm` 版本) |
| 后台 `Quota.vue` | 配额管理页 (月限额/启用/重置),修复了 API 路径 bug | | 后台 `Quota.vue` | 配额管理页 (月限额/启用/重置),修复了 API 路径 bug |
### 11. 管理后台增强 ### 12. 管理后台增强
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
@@ -171,7 +188,7 @@
| AI 模型配置 | 修复侧边栏链接路径 | | AI 模型配置 | 修复侧边栏链接路径 |
| 登录跳转 | 登录后自动跳转仪表盘 | | 登录跳转 | 登录后自动跳转仪表盘 |
### 12. 其他增强 ### 13. 其他增强
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
@@ -182,7 +199,7 @@
| Docker Compose 增强 | 添加 nginx/admin/user/uni-app 服务 + 独立网络 + Redis AOF | | Docker Compose 增强 | 添加 nginx/admin/user/uni-app 服务 + 独立网络 + Redis AOF |
| CSRF 保护 | 双提交 Cookie 模式,auth/payment/profile 必检 | | CSRF 保护 | 双提交 Cookie 模式,auth/payment/profile 必检 |
### 11. 核心 API 测试通过 ### 14. 核心 API 测试通过
| 功能 | 接口 | 状态 | | 功能 | 接口 | 状态 |
|------|------|------| |------|------|------|
@@ -199,6 +216,9 @@
| 产品 CRUD | `/api/v1/products/*` | ✅ 正常 | | 产品 CRUD | `/api/v1/products/*` | ✅ 正常 |
| 客户 CRUD | `/api/v1/customers/*` | ✅ 正常 | | 客户 CRUD | `/api/v1/customers/*` | ✅ 正常 |
| 套餐计划 | `GET /api/v1/payment/plans` | ✅ 正常 | | 套餐计划 | `GET /api/v1/payment/plans` | ✅ 正常 |
| AI Agent 启动 | `POST /api/v1/agent/start` | ✅ 正常 |
| AI Agent 列表 | `GET /api/v1/agent/pipelines` | ✅ 正常 |
| AI Agent 详情 | `GET /api/v1/agent/{id}` | ✅ 正常 |
--- ---
@@ -209,6 +229,7 @@
2. 测试 WhatsApp 真实集成(需 Meta Business 认证) 2. 测试 WhatsApp 真实集成(需 Meta Business 认证)
3. 性能优化测试 3. 性能优化测试
4. 微信小程序端验证 4. 微信小程序端验证
5. 在管理后台添加 Agent Pipeline 管理页面
--- ---
@@ -262,11 +283,11 @@
trade-assistant/ trade-assistant/
├── backend/ # FastAPI 后端 ├── backend/ # FastAPI 后端
│ ├── app/ │ ├── app/
│ │ ├── api/v1/ # REST API (30+ 路由模块) │ │ ├── api/v1/ # REST API (30+ 路由模块, 含 agent)
│ │ ├── ai/ # AI 抽象层 (router + 5 providers) │ │ ├── ai/ # AI 抽象层 (router + 5 providers)
│ │ ├── core/ # 安全/中间件/异常 (含 CSRF + 限流) │ │ ├── core/ # 安全/中间件/异常 (含 CSRF + 限流)
│ │ ├── models/ # 数据模型 (25+ 模型) │ │ ├── models/ # 数据模型 (25+ 模型, 含 agent_pipeline)
│ │ ├── services/ # 业务逻辑 (30+ 服务) │ │ ├── services/ # 业务逻辑 (30+ 服务, 含 agent_orchestrator)
│ │ ├── workers/ # Celery 任务 │ │ ├── workers/ # Celery 任务
│ │ └── main.py # FastAPI 入口 │ │ └── main.py # FastAPI 入口
│ ├── alembic/ # 数据库迁移 │ ├── alembic/ # 数据库迁移
@@ -275,6 +296,7 @@ trade-assistant/
├── uni-app/ # 移动端 H5 + 小程序 ├── uni-app/ # 移动端 H5 + 小程序
├── admin-frontend/ # PC 管理后台 (Vue 3 + Element Plus) ├── admin-frontend/ # PC 管理后台 (Vue 3 + Element Plus)
├── user-frontend/ # 用户工作台 (Vue 3 + Element Plus) ├── user-frontend/ # 用户工作台 (Vue 3 + Element Plus)
│ └── src/views/Agent.vue # AI 数字员工仪表盘
├── nginx/ # Nginx 配置 ├── nginx/ # Nginx 配置
├── docker-compose.yml # Docker 编排 (6 服务) ├── docker-compose.yml # Docker 编排 (6 服务)
├── scripts/ # 运维脚本 ├── scripts/ # 运维脚本
@@ -367,6 +389,7 @@ cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8
| 日期 | 变更内容 | | 日期 | 变更内容 |
|------|----------| |------|----------|
| 2026-06-16 | **AI 数字员工**: AgentOrchestrator 编排服务 + AgentPipeline 模型 + Agent API + 前端仪表盘 |
| 2026-06-02 | 生产环境部署 + AI 路由 DB 驱动 + 翻译配额扩展至 LLM + ECS RAM 角色认证 + 删除 OpencodeGo/Spark | | 2026-06-02 | 生产环境部署 + AI 路由 DB 驱动 + 翻译配额扩展至 LLM + ECS RAM 角色认证 + 删除 OpencodeGo/Spark |
| 2026-05-29 | 安全加固 (T-005): 限流/CSRF/CORS + AI 提供商 DB 管理 + 客户挖掘联系人提取 | | 2026-05-29 | 安全加固 (T-005): 限流/CSRF/CORS + AI 提供商 DB 管理 + 客户挖掘联系人提取 |
| 2026-05-28 | 加载反馈 + 搜索历史自动保存 + 超时修复 | | 2026-05-28 | 加载反馈 + 搜索历史自动保存 + 超时修复 |
+2 -1
View File
@@ -28,6 +28,7 @@ from . import referral
from . import admin_search from . import admin_search
from . import search from . import search
from . import admin_ai from . import admin_ai
from . import agent
__all__ = [ __all__ = [
'auth', 'marketing', 'translate', 'customer', 'quotation', 'whatsapp', 'auth', 'marketing', 'translate', 'customer', 'quotation', 'whatsapp',
@@ -35,5 +36,5 @@ __all__ = [
'onboarding', 'notification', 'feedback', 'payment', 'interaction', 'onboarding', 'notification', 'feedback', 'payment', 'interaction',
'silent_pattern', 'training', 'followup', 'ai_assistant', 'discovery', 'silent_pattern', 'training', 'followup', 'ai_assistant', 'discovery',
'discovery_record', 'certification', 'invoice', 'usage', 'referral', 'discovery_record', 'certification', 'invoice', 'usage', 'referral',
'admin_search', 'search', 'admin_ai' 'admin_search', 'search', 'admin_ai', 'agent'
] ]
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database import get_db
from app.api.v1.deps import get_current_user_id
from app.services.agent_orchestrator import AgentOrchestrator
router = APIRouter()
class StartPipelineRequest(BaseModel):
product_name: str
product_description: str = ""
target_market: str
@router.post("/start")
async def start_pipeline(
req: StartPipelineRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
orchestrator = AgentOrchestrator(db)
result = await orchestrator.start_pipeline(
user_id=user_id,
product_name=req.product_name,
product_description=req.product_description,
target_market=req.target_market,
)
return {"code": 0, "data": result}
@router.get("/pipelines")
async def list_pipelines(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
orchestrator = AgentOrchestrator(db)
result = await orchestrator.list_pipelines(user_id, page=page, size=size)
return {"code": 0, "data": result}
@router.get("/{pipeline_id}")
async def get_pipeline(
pipeline_id: str,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
orchestrator = AgentOrchestrator(db)
result = await orchestrator.get_pipeline(pipeline_id, user_id)
if not result:
raise HTTPException(status_code=404, detail="Pipeline not found")
return {"code": 0, "data": result}
+2 -1
View File
@@ -129,7 +129,7 @@ async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"} return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, discovery_record, certification, invoice, usage, referral, admin_search, search, admin_ai, credits, admin_credits from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, discovery_record, certification, invoice, usage, referral, admin_search, search, admin_ai, credits, admin_credits, agent
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"]) app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
@@ -164,6 +164,7 @@ app.include_router(admin_ai.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(admin_credits.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(admin_credits.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(credits.router, prefix="/api/v1/credits", tags=["credits"]) app.include_router(credits.router, prefix="/api/v1/credits", tags=["credits"])
app.include_router(search.router, prefix="/api/v1/search", tags=["search"]) app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
app.include_router(agent.router, prefix="/api/v1/agent", tags=["agent"])
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -23,6 +23,7 @@ from .credit_package import CreditPackage, SubscriptionPlan
from .user_credit import UserCredit from .user_credit import UserCredit
from .credit_consumption import CreditConsumption from .credit_consumption import CreditConsumption
from .credit_purchase import CreditPurchase from .credit_purchase import CreditPurchase
from .agent_pipeline import AgentPipeline
__all__ = [ __all__ = [
"User", "Product", "User", "Product",
@@ -45,4 +46,5 @@ __all__ = [
"UserCredit", "UserCredit",
"CreditConsumption", "CreditConsumption",
"CreditPurchase", "CreditPurchase",
"AgentPipeline",
] ]
+30
View File
@@ -0,0 +1,30 @@
from sqlalchemy import Column, String, Integer, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime
from app.database import Base
import uuid
class AgentPipeline(Base):
__tablename__ = "agent_pipelines"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
status = Column(String(50), default="running")
progress = Column(Integer, default=0)
product_name = Column(String(255), nullable=False)
product_description = Column(Text, default="")
target_market = Column(String(255), nullable=False)
pipeline_data = Column(JSONB, default={
"stages": {
"discover": {"status": "pending", "message": ""},
"analyze": {"status": "pending", "message": ""},
"outreach": {"status": "pending", "message": ""},
"complete": {"status": "pending", "message": ""},
},
"leads": [],
"summary": {},
})
error_message = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+248
View File
@@ -0,0 +1,248 @@
import json
import logging
from typing import Dict, Any, Optional, List
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.agent_pipeline import AgentPipeline
from app.models.customer import Customer
from app.ai.router import get_ai_router
from app.services.discovery import DiscoveryService
from app.services.marketing import MarketingService
from app.services.followup_engine import FollowupEngine
logger = logging.getLogger(__name__)
class AgentOrchestrator:
"""AI Digital Employee — chains discovery → analysis → outreach → followup."""
def __init__(self, db: AsyncSession):
self.db = db
self.ai = get_ai_router()
self.discovery = DiscoveryService(db=db)
self.marketing = MarketingService()
self.followup = FollowupEngine(db)
async def start_pipeline(
self,
user_id: str,
product_name: str,
product_description: str,
target_market: str,
) -> Dict[str, Any]:
pipeline = AgentPipeline(
user_id=user_id,
product_name=product_name,
product_description=product_description,
target_market=target_market,
)
self.db.add(pipeline)
await self.db.flush()
pipeline_id = str(pipeline.id)
data = pipeline.pipeline_data or {}
stages = data.get("stages", {})
leads = data.get("leads", [])
try:
# ── Stage 1: Discover ──
stages["discover"] = {"status": "running", "message": "正在搜索潜在客户..."}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
pipeline.progress = 10
await self.db.flush()
search_result = await self.discovery.search(
f"{product_name} {product_description}",
target_market,
)
companies = search_result.get("companies", [])
provider = search_result.get("provider", "unknown")
stages["discover"] = {
"status": "completed",
"message": f"已发现 {len(companies)} 家潜在客户",
"provider": provider,
"count": len(companies),
}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
pipeline.progress = 30
await self.db.flush()
# ── Stage 2: Analyze ──
stages["analyze"] = {"status": "running", "message": "正在分析客户匹配度..."}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
pipeline.progress = 40
await self.db.flush()
analyzed_leads = []
for idx, company in enumerate(companies):
company_url = company.get("contact", "")
if company_url and company_url.startswith("http"):
try:
analysis = await self.discovery.analyze(
company_url,
f"{product_name} {product_description}",
)
except Exception as e:
logger.warning(f"Analysis failed for {company.get('name')}: {e}")
analysis = {"match_score": 50, "match_reason": "分析失败"}
else:
analysis = {"match_score": company.get("match_score", 50), "match_reason": "基于搜索结果的初步评估"}
lead = {
"id": str(idx + 1),
"name": company.get("name", "未知"),
"description": company.get("description", ""),
"url": company.get("contact", ""),
"country": company.get("country", ""),
"source": company.get("source", "web"),
"match_score": analysis.get("match_score", 50),
"match_reason": analysis.get("match_reason", ""),
"contact_info": analysis.get("contact_info", {}),
"company_summary": analysis.get("company_summary", ""),
"product_fit": analysis.get("product_fit", ""),
"outreach": None,
}
analyzed_leads.append(lead)
analyzed_leads.sort(key=lambda x: x["match_score"], reverse=True)
leads = analyzed_leads
stages["analyze"] = {
"status": "completed",
"message": f"已完成 {len(leads)} 家客户分析",
"count": len(leads),
}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
pipeline.progress = 65
await self.db.flush()
# ── Stage 3: Outreach ──
stages["outreach"] = {"status": "running", "message": "正在生成触达文案..."}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
pipeline.progress = 75
await self.db.flush()
top_leads = [l for l in leads if l["match_score"] >= 60][:5]
for lead in top_leads:
try:
outreach = await self.discovery.outreach(
{"name": lead["name"], "url": lead["url"], "description": lead.get("company_summary", lead["description"])},
{"name": product_name, "description": product_description},
)
lead["outreach"] = outreach
except Exception as e:
logger.warning(f"Outreach failed for {lead['name']}: {e}")
lead["outreach"] = None
# Auto-save high-scoring leads as customers
saved_count = 0
for lead in leads:
if lead["match_score"] >= 70 and lead.get("url"):
existing = await self.db.execute(
select(Customer).where(
Customer.user_id == user_id,
Customer.name == lead["name"],
)
)
if not existing.scalar_one_or_none():
customer = Customer(
user_id=user_id,
name=lead["name"],
company=lead.get("company_summary", lead["name"])[:200],
country=lead.get("country", ""),
description=lead.get("description", "")[:500],
status="lead",
source=f"ai_agent:{pipeline_id}",
)
self.db.add(customer)
saved_count += 1
if saved_count > 0:
await self.db.flush()
stages["outreach"] = {
"status": "completed",
"message": f"已为 {len(top_leads)} 个高匹配客户生成触达文案,自动保存 {saved_count} 个客户",
"top_count": len(top_leads),
"saved_count": saved_count,
}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
pipeline.progress = 90
await self.db.flush()
# ── Complete ──
stages["complete"] = {
"status": "completed",
"message": f"AI数字员工任务完成!发现 {len(leads)} 个潜在客户,分析完成,高匹配客户已保存并生成触达文案。",
}
summary = {
"total_leads": len(leads),
"high_match": len([l for l in leads if l["match_score"] >= 70]),
"medium_match": len([l for l in leads if 50 <= l["match_score"] < 70]),
"low_match": len([l for l in leads if l["match_score"] < 50]),
"outreach_generated": len([l for l in leads if l.get("outreach")]),
"customers_saved": saved_count,
}
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": summary}
pipeline.status = "completed"
pipeline.progress = 100
await self.db.flush()
except Exception as e:
logger.error(f"Pipeline {pipeline_id} failed: {e}", exc_info=True)
pipeline.status = "failed"
pipeline.error_message = str(e)[:500]
stages["discover"] = stages.get("discover", {"status": "pending", "message": ""})
pipeline.pipeline_data = {"stages": stages, "leads": leads, "summary": data.get("summary", {})}
await self.db.flush()
return await self._pipeline_to_dict(pipeline)
async def get_pipeline(self, pipeline_id: str, user_id: str) -> Optional[Dict[str, Any]]:
result = await self.db.execute(
select(AgentPipeline).where(
AgentPipeline.id == pipeline_id,
AgentPipeline.user_id == user_id,
)
)
pipeline = result.scalar_one_or_none()
if not pipeline:
return None
return await self._pipeline_to_dict(pipeline)
async def list_pipelines(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
query = (
select(AgentPipeline)
.where(AgentPipeline.user_id == user_id)
.order_by(desc(AgentPipeline.created_at))
.offset((page - 1) * size)
.limit(size)
)
count_q = select(AgentPipeline).where(AgentPipeline.user_id == user_id)
result = await self.db.execute(query)
pipelines = result.scalars().all()
count_result = await self.db.execute(count_q)
total = len(count_result.scalars().all())
items = [await self._pipeline_to_dict(p) for p in pipelines]
return {"items": items, "total": total, "page": page, "size": size}
async def _pipeline_to_dict(self, p: AgentPipeline) -> Dict[str, Any]:
return {
"id": str(p.id),
"status": p.status,
"progress": p.progress,
"product_name": p.product_name,
"product_description": p.product_description,
"target_market": p.target_market,
"pipeline_data": p.pipeline_data,
"error_message": p.error_message,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
+107 -11
View File
@@ -1,7 +1,8 @@
# 外贸小助手 (TradeMate) — 数据库设计文档 # 外贸小助手 (TradeMate) — 数据库设计文档
> 版本: v1.0 > 版本: v1.1
> 创建日期: 2026-05-08 > 创建日期: 2026-05-08
> 最后更新: 2026-06-16 (新增 agent_pipelines 表)
--- ---
@@ -61,16 +62,19 @@
│ 1:N │ 1:N
┌────────────────┐ ┌────────────────┐ ┌──────────────────┐
│ messages │ │ messages │ │ agent_pipelines │
├────────────────┤ ├────────────────┤ ├──────────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ conversation_id│ │ conversation_id│ │ user_id (FK) │
│ direction │ │ direction │ │ status │
│ content │ │ content │ │ progress │
│ content_translt│ │ content_translt│ │ product_name │
│ ai_suggestions │ │ ai_suggestions │ │ target_market │
└────────────────┘ └────────────────┘ │ pipeline_data(JB)│
│ error_message │
│ created_at │
└──────────────────┘
``` ```
--- ---
@@ -89,6 +93,9 @@ CREATE TABLE users (
tier VARCHAR(50) DEFAULT 'free', tier VARCHAR(50) DEFAULT 'free',
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{"preferred_translate_provider": "auto", "reply_tone": "professional"}', settings JSONB DEFAULT '{"preferred_translate_provider": "auto", "reply_tone": "professional"}',
email VARCHAR(255),
last_login_at TIMESTAMP,
login_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW() updated_at TIMESTAMP DEFAULT NOW()
); );
@@ -338,6 +345,85 @@ CREATE INDEX idx_corpus_embedding ON corpus_entries USING ivfflat (embedding vec
--- ---
### 3.9 AI 数字员工流水线表 (agent_pipelines) 🆕
```sql
CREATE TABLE agent_pipelines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status VARCHAR(50) DEFAULT 'running',
progress INTEGER DEFAULT 0,
product_name VARCHAR(255) NOT NULL,
product_description TEXT DEFAULT '',
target_market VARCHAR(255) NOT NULL,
pipeline_data JSONB DEFAULT '{
"stages": {
"discover": {"status": "pending", "message": ""},
"analyze": {"status": "pending", "message": ""},
"outreach": {"status": "pending", "message": ""},
"complete": {"status": "pending", "message": ""}
},
"leads": [],
"summary": {}
}',
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_agent_pipelines_user ON agent_pipelines(user_id);
CREATE INDEX idx_agent_pipelines_status ON agent_pipelines(status);
```
**status 枚举值**:
- `running`: 执行中
- `completed`: 已完成
- `failed`: 失败
**pipeline_data 结构**:
```json
{
"stages": {
"discover": {"status": "completed", "message": "已发现 15 家客户", "count": 15},
"analyze": {"status": "completed", "message": "已完成 15 家分析", "count": 15},
"outreach": {"status": "completed", "message": "已为 5 家生成触达", "top_count": 5},
"complete": {"status": "completed", "message": "任务完成"}
},
"leads": [
{
"name": "Company ABC",
"match_score": 85,
"match_reason": "高度匹配",
"company_summary": "主营...",
"url": "https://...",
"outreach": {"email_body": "...", "whatsapp_message": "..."}
}
],
"summary": {
"total_leads": 15,
"high_match": 5,
"medium_match": 7,
"customers_saved": 3
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| user_id | UUID | 所属用户 |
| status | VARCHAR | 状态: running/completed/failed |
| progress | INTEGER | 进度百分比 0-100 |
| product_name | VARCHAR | 产品名称 |
| product_description | TEXT | 产品描述 |
| target_market | VARCHAR | 目标市场 |
| pipeline_data | JSONB | 流水线数据 (stages + leads + summary) |
| error_message | TEXT | 错误信息 |
| created_at | TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | 更新时间 |
---
## 四、pgvector 扩展 ## 四、pgvector 扩展
```sql ```sql
@@ -367,6 +453,7 @@ LIMIT 5;
| messages | conversation_id | 消息历史 | | messages | conversation_id | 消息历史 |
| quotations | user_id, customer_id, status | 报价单管理 | | quotations | user_id, customer_id, status | 报价单管理 |
| corpus_entries | task_type, domain, embedding | 语料检索 | | corpus_entries | task_type, domain, embedding | 语料检索 |
| agent_pipelines | user_id, status | 流水线查询 |
--- ---
@@ -380,9 +467,18 @@ LIMIT 5;
| 报价单数据 | 3年 | 订单追溯 | | 报价单数据 | 3年 | 订单追溯 |
| 语料数据 | 永久 | AI训练 | | 语料数据 | 永久 | AI训练 |
| 日志数据 | 90天 | 调试审计 | | 日志数据 | 90天 | 调试审计 |
| 流水线数据 | 永久 | AI 数字员工执行记录 |
--- ---
## 七、迁移脚本 ## 七、迁移脚本
使用 Alembic 进行数据库迁移,初始迁移见 `backend/alembic/versions/001_initial.py` 使用 Alembic 进行数据库迁移,初始迁移见 `backend/alembic/versions/001_initial.py`
```bash
# 创建新迁移
cd backend && alembic revision --autogenerate -m "add agent_pipelines"
# 执行迁移
cd backend && alembic upgrade head
```
-39
View File
@@ -707,9 +707,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -724,9 +721,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -741,9 +735,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -758,9 +749,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -775,9 +763,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -792,9 +777,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -809,9 +791,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -826,9 +805,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -843,9 +819,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -860,9 +833,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -877,9 +847,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -894,9 +861,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -911,9 +875,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
+4
View File
@@ -131,4 +131,8 @@ export function subscribeCreditPlan(planId, payType = 'alipay') {
} }
export function cancelCreditSubscription() { return http.post('/credits/cancel-subscription') } export function cancelCreditSubscription() { return http.post('/credits/cancel-subscription') }
export function startAgentPipeline(data) { return http.post('/agent/start', data, { timeout: 300000 }) }
export function listAgentPipelines(params) { return http.get('/agent/pipelines', { params }) }
export function getAgentPipeline(id) { return http.get(`/agent/${id}`) }
export default http export default http
+1 -7
View File
@@ -16,6 +16,7 @@
:collapse-transition="false" :collapse-transition="false"
@select="showMobileMenu = false" @select="showMobileMenu = false"
> >
<el-menu-item index="/agent"><el-icon><MagicStick /></el-icon><span>{{ $t('nav.agent') || 'AI数字员工' }}</span></el-menu-item>
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>{{ $t('nav.discovery') }}</span></el-menu-item> <el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>{{ $t('nav.discovery') }}</span></el-menu-item>
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.workspace') }}</span></el-menu-item> <el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.workspace') }}</span></el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item> <el-menu-item index="/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item>
@@ -137,13 +138,6 @@ const beianInfo = computed(() => {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true } return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}) })
onMounted(async () => {
try {
const res = await getUnreadCount()
unread.value = res.count || res || 0
} catch { /* ignore */ }
})
function handleLogout() { function handleLogout() {
auth.logout() auth.logout()
router.push('/') router.push('/')
+8
View File
@@ -3,6 +3,14 @@ import { createRouter, createWebHistory } from 'vue-router'
const routes = [ const routes = [
{ path: '/login', redirect: '/' }, { path: '/login', redirect: '/' },
{ path: '/', name: 'Landing', component: () => import('@/views/WorkspaceLanding.vue') }, { path: '/', name: 'Landing', component: () => import('@/views/WorkspaceLanding.vue') },
{
path: '/agent',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Agent', component: () => import('@/views/Agent.vue'), meta: { title: 'AI数字员工' } },
]
},
{ {
path: '/workspace', path: '/workspace',
component: () => import('@/layouts/UserLayout.vue'), component: () => import('@/layouts/UserLayout.vue'),
+531
View File
@@ -0,0 +1,531 @@
<template>
<div class="agent-dashboard">
<!-- Header -->
<div class="agent-header">
<div class="agent-header-left">
<h2>{{ $t('agent.title') || 'AI 数字员工' }}</h2>
<p class="agent-subtitle">{{ $t('agent.subtitle') || '智能挖掘 · 分析 · 触达 · 跟进,一站式自动完成' }}</p>
</div>
<el-button type="primary" size="large" @click="showStartDialog = true" :icon="Plus">
{{ $t('agent.newTask') || '新建任务' }}
</el-button>
</div>
<!-- Stats -->
<el-row :gutter="16" class="agent-stats">
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">{{ $t('agent.totalTasks') || '总任务' }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" style="color:#67c23a">{{ stats.completed }}</div>
<div class="stat-label">{{ $t('agent.completed') || '已完成' }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" style="color:#e6a23c">{{ stats.total_leads }}</div>
<div class="stat-label">{{ $t('agent.totalLeads') || '累计线索' }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" style="color:#409eff">{{ stats.saved_customers }}</div>
<div class="stat-label">{{ $t('agent.savedCustomers') || '已保存客户' }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Pipeline List -->
<div class="agent-section">
<div class="section-header">
<h3>{{ $t('agent.taskHistory') || '任务历史' }}</h3>
<el-radio-group v-model="statusFilter" size="small">
<el-radio-button value="">{{ $t('agent.all') || '全部' }}</el-radio-button>
<el-radio-button value="running">{{ $t('agent.running') || '进行中' }}</el-radio-button>
<el-radio-button value="completed">{{ $t('agent.done') || '已完成' }}</el-radio-button>
<el-radio-button value="failed">{{ $t('agent.failed') || '失败' }}</el-radio-button>
</el-radio-group>
</div>
<div v-if="loading" style="text-align:center;padding:60px">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
</div>
<template v-else-if="filteredPipelines.length">
<div class="pipeline-grid">
<el-card
v-for="p in filteredPipelines"
:key="p.id"
shadow="hover"
:class="['pipeline-card', { active: selectedId === p.id }]"
@click="selectPipeline(p)"
>
<div class="pipeline-card-header">
<el-tag :type="statusTag(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
<el-tag v-if="p.progress === 100" type="success" size="small" effect="dark">{{ p.progress }}%</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ p.progress || 0 }}%</el-tag>
</div>
<div class="pipeline-card-body">
<h4>{{ p.product_name }}</h4>
<p class="pipeline-market">{{ p.target_market }}</p>
<p v-if="p.pipeline_data?.summary" class="pipeline-summary">
发现 <strong>{{ p.pipeline_data.summary.total_leads || 0 }}</strong> 个线索
高匹配 <strong>{{ p.pipeline_data.summary.high_match || 0 }}</strong>
</p>
</div>
<div class="pipeline-card-footer">
<span class="pipeline-time">{{ formatTime(p.created_at) }}</span>
</div>
<!-- Progress bar for running -->
<el-progress
v-if="p.status === 'running'"
:percentage="p.progress || 0"
:stroke-width="3"
style="margin-top:8px"
/>
</el-card>
</div>
<div v-if="totalPages > 1" class="pagination-wrap">
<el-pagination
background
layout="prev, pager, next"
:total="totalPipelines"
:page-size="pageSize"
v-model:current-page="currentPage"
@current-change="loadPipelines"
/>
</div>
</template>
<el-empty v-else :description="$t('agent.noTasks') || '暂无任务,点击右上角新建'" :image-size="80" />
</div>
<!-- Pipeline Detail -->
<el-card v-if="selectedPipeline" shadow="never" class="pipeline-detail">
<template #header>
<div class="detail-header">
<div>
<strong>{{ selectedPipeline.product_name }}</strong>
<el-tag :type="statusTag(selectedPipeline.status)" size="small" style="margin-left:8px">
{{ statusLabel(selectedPipeline.status) }}
</el-tag>
<span style="color:#999;font-size:12px;margin-left:12px">{{ selectedPipeline.target_market }}</span>
</div>
<div>
<el-button size="small" @click="selectedPipeline = null">{{ $t('common.close') || '关闭' }}</el-button>
</div>
</div>
</template>
<!-- Stage Progress -->
<div class="stage-steps">
<div
v-for="(st, stKey) in selectedPipeline.pipeline_data?.stages || {}"
:key="stKey"
:class="['stage-step', st.status]"
>
<div class="stage-icon">
<el-icon v-if="st.status === 'completed'" color="#67c23a"><CircleCheck /></el-icon>
<el-icon v-else-if="st.status === 'running'" class="is-loading" color="#409eff"><Loading /></el-icon>
<el-icon v-else color="#999"><CircleClose /></el-icon>
</div>
<div class="stage-content">
<div class="stage-name">{{ stageLabel(stKey) }}</div>
<div class="stage-msg">{{ st.message || '' }}</div>
</div>
</div>
</div>
<!-- Leads Table -->
<div v-if="leads.length" class="leads-section">
<h4 style="margin:16px 0 12px">{{ $t('agent.leads') || '客户线索' }} ({{ leads.length }})</h4>
<el-table :data="leads" stripe style="width:100%" @row-click="showLeadDetail">
<el-table-column prop="name" :label="$t('agent.leadName') || '公司名称'" min-width="160">
<template #default="{ row }">
<div class="lead-name-cell">
<span>{{ row.name }}</span>
<el-tag v-if="row.match_score >= 70" size="small" type="success"></el-tag>
<el-tag v-else-if="row.match_score >= 50" size="small" type="warning"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="match_score" :label="$t('agent.matchScore') || '匹配度'" width="100" align="center">
<template #default="{ row }">
<el-progress
:percentage="row.match_score || 0"
:stroke-width="10"
:color="scoreColor(row.match_score)"
style="width:80px"
/>
</template>
</el-table-column>
<el-table-column prop="country" :label="$t('agent.country') || '国家'" width="100" />
<el-table-column prop="source" :label="$t('agent.source') || '来源'" width="100" />
<el-table-column :label="$t('agent.outreach') || '触达文案'" min-width="120">
<template #default="{ row }">
<el-button
v-if="row.outreach"
size="small"
type="primary"
link
@click.stop="showOutreach(row)"
>{{ $t('agent.preview') || '预览' }}</el-button>
<span v-else style="color:#999;font-size:12px">{{ $t('agent.noOutreach') || '未生成' }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('agent.actions') || '操作'" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click.stop="goToCustomers(row)">
{{ $t('agent.addCustomer') || '添加客户' }}
</el-button>
<el-button v-if="row.url && row.url.startsWith('http')" size="small" link @click.stop="openUrl(row.url)">
{{ $t('agent.visit') || '访问' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Error message -->
<el-alert v-if="selectedPipeline.error_message" :title="selectedPipeline.error_message" type="error" show-icon :closable="false" style="margin-top:12px" />
</el-card>
<!-- Start New Task Dialog -->
<el-dialog
v-model="showStartDialog"
:title="$t('agent.newTask') || '新建 AI 数字员工任务'"
width="520px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item :label="$t('agent.productName') || '产品名称'" prop="product_name">
<el-input v-model="form.product_name" :placeholder="$t('agent.productNamePlaceholder') || '例如:户外折叠椅'" />
</el-form-item>
<el-form-item :label="$t('agent.productDescription') || '产品描述(选填)'" prop="product_description">
<el-input
v-model="form.product_description"
type="textarea"
:rows="3"
:placeholder="$t('agent.productDescPlaceholder') || '描述产品的材质、尺寸、优势等,可帮助AI更精准匹配'"
/>
</el-form-item>
<el-form-item :label="$t('agent.targetMarket') || '目标市场'" prop="target_market">
<el-input v-model="form.target_market" :placeholder="$t('agent.marketPlaceholder') || '例如:美国、德国、东南亚等'" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showStartDialog = false">{{ $t('common.cancel') || '取消' }}</el-button>
<el-button type="primary" :loading="starting" @click="startTask">
{{ $t('agent.start') || '开始执行' }}
</el-button>
</template>
</el-dialog>
<!-- Outreach Preview Dialog -->
<el-dialog
v-model="showOutreachDialog"
:title="outreachLead?.name"
width="600px"
>
<template v-if="outreachData">
<el-tabs>
<el-tab-pane label="WhatsApp">
<pre class="outreach-text">{{ outreachData.whatsapp_message || '未生成' }}</pre>
</el-tab-pane>
<el-tab-pane label="LinkedIn">
<pre class="outreach-text">{{ outreachData.linkedin_message || '未生成' }}</pre>
</el-tab-pane>
<el-tab-pane label="Email">
<div class="outreach-email">
<div v-if="outreachData.subject" class="outreach-subject"><strong>主题</strong>{{ outreachData.subject }}</div>
<pre class="outreach-text">{{ outreachData.email_body || '未生成' }}</pre>
</div>
</el-tab-pane>
<el-tab-pane :label="$t('agent.tips') || '建议'">
<ul v-if="outreachData.tips?.length">
<li v-for="(t, i) in outreachData.tips" :key="i" style="margin:4px 0">{{ t }}</li>
</ul>
<div v-if="outreachData.key_points?.length" style="margin-top:8px">
<strong>{{ $t('agent.keyPoints') || '关键要点' }}</strong>
<el-tag v-for="(kp, i) in outreachData.key_points" :key="i" size="small" style="margin:2px">{{ kp }}</el-tag>
</div>
</el-tab-pane>
</el-tabs>
</template>
</el-dialog>
<el-dialog
v-model="showLeadDialog"
:title="leadDetail?.name"
width="500px"
>
<template v-if="leadDetail">
<div class="lead-info">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('agent.matchScore') || '匹配度'">
<el-progress :percentage="leadDetail.match_score || 0" :stroke-width="14" :color="scoreColor(leadDetail.match_score)" style="width:120px" />
</el-descriptions-item>
<el-descriptions-item :label="$t('agent.matchReason') || '匹配理由'">{{ leadDetail.match_reason || '暂无' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.companySummary') || '公司简介'">{{ leadDetail.company_summary || leadDetail.description || '暂无' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.productFit') || '产品契合度'">{{ leadDetail.product_fit || '暂无' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.country') || '国家'">{{ leadDetail.country || '未知' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.source') || '来源'">{{ leadDetail.source || '未知' }}</el-descriptions-item>
<el-descriptions-item v-if="leadDetail.url" :label="'URL'">
<a :href="leadDetail.url" target="_blank" rel="noopener">{{ leadDetail.url.substring(0, 50) }}</a>
</el-descriptions-item>
</el-descriptions>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Plus, Loading, CircleCheck, CircleClose,
} from '@element-plus/icons-vue'
import {
startAgentPipeline,
listAgentPipelines,
getAgentPipeline,
} from '@/api'
import { createCustomer } from '@/api'
const router = useRouter()
const { t } = useI18n()
// State
const showStartDialog = ref(false)
const showOutreachDialog = ref(false)
const showLeadDialog = ref(false)
const starting = ref(false)
const loading = ref(false)
const pipelines = ref([])
const selectedPipeline = ref(null)
const selectedId = ref(null)
const outreachLead = ref(null)
const outreachData = ref(null)
const leadDetail = ref(null)
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = 12
const totalPipelines = ref(0)
const form = ref({
product_name: '',
product_description: '',
target_market: '',
})
const rules = {
product_name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
target_market: [{ required: true, message: '请输入目标市场', trigger: 'blur' }],
}
// Computed
const totalPages = computed(() => Math.ceil(totalPipelines.value / pageSize))
const filteredPipelines = computed(() => {
if (!statusFilter.value) return pipelines.value
return pipelines.value.filter(p => p.status === statusFilter.value)
})
const leads = computed(() => {
if (!selectedPipeline.value) return []
return selectedPipeline.value.pipeline_data?.leads || []
})
const stats = computed(() => {
const all = pipelines.value
const completed = all.filter(p => p.status === 'completed')
const totalLeads = completed.reduce((sum, p) => sum + (p.pipeline_data?.summary?.total_leads || 0), 0)
const saved = completed.reduce((sum, p) => sum + (p.pipeline_data?.summary?.customers_saved || 0), 0)
return {
total: all.length,
completed: completed.length,
total_leads: totalLeads,
saved_customers: saved,
}
})
// Methods
function statusTag(status) {
return { running: 'warning', completed: 'success', failed: 'danger' }[status] || 'info'
}
function statusLabel(status) {
return { running: '进行中', completed: '已完成', failed: '失败', pending: '等待中' }[status] || status
}
function stageLabel(key) {
return { discover: '客户搜索', analyze: '匹配分析', outreach: '触达文案', complete: '任务完成' }[key] || key
}
function scoreColor(score) {
if (score >= 70) return '#67c23a'
if (score >= 50) return '#e6a23c'
return '#909399'
}
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function loadPipelines() {
loading.value = true
try {
const res = await listAgentPipelines({ page: currentPage.value, size: pageSize })
if (res.code === 0) {
pipelines.value = res.data.items || []
totalPipelines.value = res.data.total || 0
}
} catch (e) {
console.error('Failed to load pipelines', e)
} finally {
loading.value = false
}
}
async function selectPipeline(p) {
selectedId.value = p.id
try {
const res = await getAgentPipeline(p.id)
if (res.code === 0) {
selectedPipeline.value = res.data
}
} catch (e) {
console.error('Failed to load pipeline', e)
}
}
async function startTask() {
const formRef = document.querySelector('.el-form')
if (!formRef) return
try {
await formRef.validate?.()
} catch {
return
}
starting.value = true
try {
const res = await startAgentPipeline({
product_name: form.value.product_name,
product_description: form.value.product_description,
target_market: form.value.target_market,
})
if (res.code === 0) {
showStartDialog.value = false
form.value = { product_name: '', product_description: '', target_market: '' }
await loadPipelines()
selectedPipeline.value = res.data
selectedId.value = res.data.id
currentPage.value = 1
}
} catch (e) {
console.error('Failed to start pipeline', e)
} finally {
starting.value = false
}
}
function showOutreach(lead) {
outreachLead.value = lead
outreachData.value = lead.outreach
showOutreachDialog.value = true
}
function showLeadDetail(row) {
leadDetail.value = row
showLeadDialog.value = true
}
async function goToCustomers(lead) {
try {
await createCustomer({
name: lead.name,
company: lead.company_summary || lead.name,
country: lead.country || '',
description: (lead.description || '').substring(0, 500),
status: 'lead',
source: 'ai_agent',
})
router.push('/customers')
} catch (e) {
console.error('Failed to add customer', e)
}
}
function openUrl(url) {
window.open(url, '_blank', 'noopener')
}
onMounted(loadPipelines)
</script>
<style scoped>
.agent-dashboard { max-width: 1200px; margin: 0 auto; }
.agent-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; flex-wrap: wrap; }
.agent-header-left h2 { margin: 0; font-size: 22px; }
.agent-subtitle { margin: 4px 0 0; color: #999; font-size: 13px; }
.agent-stats { margin-bottom: 24px; }
.stat-item { text-align: center; padding: 8px 0; }
.stat-value { font-size: 28px; font-weight: 700; color: #303133; }
.stat-label { font-size: 12px; color: #999; margin-top: 4px; }
.agent-section { margin-bottom: 24px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.section-header h3 { margin: 0; font-size: 16px; }
.pipeline-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.pipeline-card { cursor: pointer; transition: all 0.2s; }
.pipeline-card:hover { transform: translateY(-2px); }
.pipeline-card.active { border-color: #409eff; box-shadow: 0 0 0 2px rgba(64,158,255,0.2); }
.pipeline-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.pipeline-card-body h4 { margin: 0; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pipeline-market { color: #409eff; font-size: 12px; margin: 4px 0; }
.pipeline-summary { color: #666; font-size: 12px; margin: 4px 0; }
.pipeline-card-footer { display: flex; justify-content: space-between; font-size: 11px; color: #ccc; margin-top: 8px; }
.pipeline-time { color: #999; }
.pagination-wrap { display: flex; justify-content: center; margin-top: 20px; }
.pipeline-detail { margin-top: 20px; }
.detail-header { display: flex; justify-content: space-between; align-items: center; }
.stage-steps { display: flex; gap: 8px; padding: 16px 0; flex-wrap: wrap; }
.stage-step { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 8px; background: #f5f7fa; flex: 1; min-width: 140px; }
.stage-step.completed { background: #f0f9eb; }
.stage-step.running { background: #ecf5ff; }
.stage-step.pending { opacity: 0.6; }
.stage-icon { flex-shrink: 0; font-size: 20px; }
.stage-content { min-width: 0; }
.stage-name { font-size: 13px; font-weight: 600; }
.stage-msg { font-size: 11px; color: #666; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
.lead-name-cell { display: flex; align-items: center; gap: 6px; }
.lead-name-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 140px; display: inline-block; }
.outreach-text { white-space: pre-wrap; word-break: break-word; background: #f5f7fa; padding: 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
.outreach-email .outreach-subject { margin-bottom: 8px; }
.lead-info { padding: 4px 0; }
@media (max-width: 768px) {
.pipeline-grid { grid-template-columns: 1fr; }
.stage-steps { flex-direction: column; }
.agent-header { flex-direction: column; align-items: flex-start; }
}
</style>