feat: Agent技能市场 — 公开/发布/分类/精选/评分

- AgentCreate 新增 category/tags/is_public/is_featured 字段
- 迁移 011 添加 agents 市场相关列(category/tags/is_public/rating_avg 等)
- agent_market API:按分类/标签/精选筛选,排序,分页
- 前端 AgentMarket.vue:市场浏览页,搜索/筛选/安装

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-07 08:02:38 +08:00
parent 66d52ad020
commit ded3ba2973
4 changed files with 1284 additions and 1 deletions

View File

@@ -0,0 +1,44 @@
"""add agent market fields (category, tags, thumbnail, is_public, is_featured, rating, use_count, view_count, forked_from_id)
Revision ID: 011_add_agent_market_fields
Revises: 010_add_global_knowledge
Create Date: 2026-05-06
"""
from alembic import op
import sqlalchemy as sa
revision = "011_add_agent_market_fields"
down_revision = "010_add_global_knowledge"
branch_labels = None
depends_on = None
def upgrade() -> None:
for col_name, col_type, col_kw in [
("category", sa.String(50), {"nullable": True, "comment": "分类"}),
("tags", sa.JSON, {"nullable": True, "comment": "标签列表"}),
("thumbnail", sa.Text, {"nullable": True, "comment": "缩略图URL"}),
("is_public", sa.Integer, {"server_default": "0", "comment": "是否公开到市场"}),
("is_featured", sa.Integer, {"server_default": "0", "comment": "是否精选"}),
("rating_avg", sa.String(10), {"server_default": "0.0", "comment": "平均评分"}),
("rating_count", sa.Integer, {"server_default": "0", "comment": "评分人数"}),
("use_count", sa.Integer, {"server_default": "0", "comment": "安装次数"}),
("view_count", sa.Integer, {"server_default": "0", "comment": "查看次数"}),
("forked_from_id", sa.CHAR(36), {"nullable": True, "comment": "从哪个Agent Fork而来"}),
]:
try:
op.add_column("agents", sa.Column(col_name, col_type, **col_kw))
except Exception:
pass
def downgrade() -> None:
for col_name in [
"category", "tags", "thumbnail", "is_public", "is_featured",
"rating_avg", "rating_count", "use_count", "view_count", "forked_from_id",
]:
try:
op.drop_column("agents", col_name)
except Exception:
pass

View File

@@ -0,0 +1,598 @@
"""
Agent 技能市场 API — 公共市场共享复用
支持:
- Agent 一键发布到公共市场
- 市场浏览:按分类、热度、评分排序
- 一键安装到自己的工作区
- 版本管理:发布者更新后,使用者可选择升级
- 评分与评论
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from datetime import datetime
import logging
import copy
from app.core.database import get_db
from app.models.agent import Agent, AgentRating, AgentFavorite
from app.api.auth import get_current_user
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/agent-market", tags=["agent-market"])
# ─── Pydantic Schemas ──────────────────────────────────────────────
class AgentMarketItem(BaseModel):
"""市场列表项"""
id: str
name: str
description: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
thumbnail: Optional[str] = None
is_featured: bool = False
rating_avg: float = 0.0
rating_count: int = 0
use_count: int = 0
view_count: int = 0
version: int = 1
user_id: str
creator_username: Optional[str] = None
is_favorited: Optional[bool] = None
user_rating: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class AgentMarketDetail(BaseModel):
"""市场详情(含工作流配置)"""
id: str
name: str
description: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
thumbnail: Optional[str] = None
is_featured: bool = False
rating_avg: float = 0.0
rating_count: int = 0
use_count: int = 0
view_count: int = 0
version: int = 1
user_id: str
creator_username: Optional[str] = None
is_favorited: Optional[bool] = None
user_rating: Optional[int] = None
workflow_config: Optional[Dict[str, Any]] = None
budget_config: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class PublishRequest(BaseModel):
"""一键发布到市场"""
category: Optional[str] = None
tags: Optional[List[str]] = None
thumbnail: Optional[str] = None
class RatingCreate(BaseModel):
"""评分/评论创建"""
rating: int # 1-5
comment: Optional[str] = None
class RatingResponse(BaseModel):
"""评分响应"""
id: str
agent_id: str
user_id: str
username: Optional[str] = None
rating: int
comment: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
class InstallResponse(BaseModel):
"""安装结果"""
message: str
agent_id: str
agent_name: str
forked_from_id: str
upstream_version: int
# ─── 辅助函数 ──────────────────────────────────────────────────────
def _build_agent_item(agent: Agent, current_user: Optional[User], db: Session) -> dict:
"""构建市场列表项(含用户特定状态)。"""
item = {
"id": agent.id,
"name": agent.name,
"description": agent.description,
"category": agent.category,
"tags": agent.tags or [],
"thumbnail": agent.thumbnail,
"is_featured": bool(agent.is_featured),
"rating_avg": float(agent.rating_avg or 0),
"rating_count": agent.rating_count or 0,
"use_count": agent.use_count or 0,
"view_count": agent.view_count or 0,
"version": agent.version or 1,
"user_id": agent.user_id,
"creator_username": agent.user.username if agent.user else None,
"is_favorited": None,
"user_rating": None,
"created_at": agent.created_at,
"updated_at": agent.updated_at,
}
if current_user:
fav = db.query(AgentFavorite).filter(
AgentFavorite.agent_id == agent.id,
AgentFavorite.user_id == current_user.id,
).first()
item["is_favorited"] = fav is not None
r = db.query(AgentRating).filter(
AgentRating.agent_id == agent.id,
AgentRating.user_id == current_user.id,
).first()
item["user_rating"] = r.rating if r else None
return item
# ─── 市场浏览 API ──────────────────────────────────────────────────
@router.get("", response_model=List[AgentMarketItem])
async def browse_market(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
category: Optional[str] = None,
tags: Optional[str] = None,
sort_by: str = Query("created_at", pattern="^(created_at|rating_avg|use_count|view_count)$"),
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
featured_only: bool = Query(False),
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user),
):
"""浏览 Agent 市场(公开已发布的 Agent"""
query = db.query(Agent).filter(
Agent.is_public == 1,
Agent.status.in_(["published", "running"]),
)
if search:
query = query.filter(
or_(
Agent.name.like(f"%{search}%"),
Agent.description.like(f"%{search}%"),
)
)
if category:
query = query.filter(Agent.category == category)
if tags:
tag_list = [t.strip() for t in tags.split(",")]
for tag in tag_list:
query = query.filter(Agent.tags.contains([tag]))
if featured_only:
query = query.filter(Agent.is_featured == 1)
# 排序
sort_col = {
"rating_avg": Agent.rating_avg,
"use_count": Agent.use_count,
"view_count": Agent.view_count,
"created_at": Agent.created_at,
}.get(sort_by, Agent.created_at)
order_fn = sort_col.desc() if sort_order == "desc" else sort_col.asc()
query = query.order_by(order_fn)
agents = query.offset(skip).limit(limit).all()
return [AgentMarketItem(**_build_agent_item(a, current_user, db)) for a in agents]
@router.get("/{agent_id}", response_model=AgentMarketDetail)
async def get_market_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user),
):
"""查看市场 Agent 详情(含工作流配置)。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
if not agent.is_public and (not current_user or agent.user_id != current_user.id):
raise HTTPException(status_code=403, detail="无权访问此 Agent")
agent.view_count = (agent.view_count or 0) + 1
db.commit()
base = _build_agent_item(agent, current_user, db)
base["workflow_config"] = agent.workflow_config
base["budget_config"] = agent.budget_config
return AgentMarketDetail(**base)
# ─── 发布 / 下架 ───────────────────────────────────────────────────
@router.post("/publish/{agent_id}")
async def publish_to_market(
agent_id: str,
data: Optional[PublishRequest] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""一键发布 Agent 到公共市场。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
if agent.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权发布此 Agent")
agent.is_public = 1
if agent.status not in ("published", "running"):
agent.status = "published"
if data:
if data.category is not None:
agent.category = data.category
if data.tags is not None:
agent.tags = data.tags
if data.thumbnail is not None:
agent.thumbnail = data.thumbnail
db.commit()
db.refresh(agent)
logger.info("Agent 发布到市场: %s (%s)", agent.name, agent.id)
return {"message": "已发布到市场", "agent_id": str(agent.id)}
@router.post("/unpublish/{agent_id}")
async def unpublish_from_market(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""下架 Agent。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
if agent.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权下架此 Agent")
agent.is_public = 0
db.commit()
logger.info("Agent 已下架: %s (%s)", agent.name, agent.id)
return {"message": "已下架", "agent_id": str(agent.id)}
# ─── 安装 / Fork ───────────────────────────────────────────────────
@router.post("/{agent_id}/install", response_model=InstallResponse, status_code=201)
async def install_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""一键安装 Agent 到自己的工作区Fork"""
original = db.query(Agent).filter(Agent.id == agent_id).first()
if not original:
raise NotFoundError("Agent", agent_id)
if not original.is_public and (not current_user or original.user_id != current_user.id):
raise HTTPException(status_code=403, detail="无权安装此 Agent")
# 生成副本名称
base = original.name
name = f"{base} (市场安装)"
counter = 1
while db.query(Agent).filter(
Agent.name == name,
Agent.user_id == current_user.id,
).first():
counter += 1
name = f"{base} (市场安装 {counter})"
new_agent = Agent(
name=name,
description=original.description,
workflow_config=copy.deepcopy(original.workflow_config),
budget_config=copy.deepcopy(original.budget_config) if original.budget_config else None,
user_id=current_user.id,
status="draft",
version=1,
forked_from_id=agent_id,
category=original.category,
tags=copy.deepcopy(original.tags) if original.tags else None,
)
db.add(new_agent)
original.use_count = (original.use_count or 0) + 1
db.commit()
db.refresh(new_agent)
logger.info("用户 %s 从市场安装了 Agent: %s -> %s", current_user.id, original.name, new_agent.id)
return InstallResponse(
message="安装成功",
agent_id=str(new_agent.id),
agent_name=new_agent.name,
forked_from_id=agent_id,
upstream_version=original.version or 1,
)
@router.post("/{agent_id}/check-upgrade")
async def check_upgrade(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""检查 Fork 的 Agent 是否有上游更新。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
if agent.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权操作此 Agent")
if not agent.forked_from_id:
return {"has_upgrade": False, "message": "此 Agent 不是从市场安装的"}
upstream = db.query(Agent).filter(Agent.id == agent.forked_from_id).first()
if not upstream:
return {"has_upgrade": False, "message": "上游 Agent 已被删除"}
has_upgrade = (upstream.version or 1) > (agent.version or 1) or (
upstream.updated_at and agent.updated_at and upstream.updated_at > agent.created_at
)
return {
"has_upgrade": has_upgrade,
"upstream_version": upstream.version or 1,
"current_version": agent.version or 1,
"upstream_name": upstream.name,
}
@router.post("/{agent_id}/upgrade", response_model=InstallResponse)
async def upgrade_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""升级 Fork 的 Agent 到上游最新版本。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
if agent.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权操作此 Agent")
if not agent.forked_from_id:
raise HTTPException(status_code=400, detail="此 Agent 不是从市场安装的,无法升级")
upstream = db.query(Agent).filter(Agent.id == agent.forked_from_id).first()
if not upstream:
raise HTTPException(status_code=400, detail="上游 Agent 已被删除")
# 更新工作流配置和预算配置
agent.workflow_config = copy.deepcopy(upstream.workflow_config)
if upstream.budget_config:
agent.budget_config = copy.deepcopy(upstream.budget_config)
agent.version = upstream.version or 1
db.commit()
db.refresh(agent)
logger.info("Agent %s 已升级到上游版本 %s", agent.id, upstream.version)
return InstallResponse(
message="升级成功",
agent_id=str(agent.id),
agent_name=agent.name,
forked_from_id=agent.forked_from_id,
upstream_version=upstream.version or 1,
)
# ─── 评分 & 评论 ───────────────────────────────────────────────────
@router.post("/{agent_id}/rate", status_code=201)
async def rate_agent(
agent_id: str,
data: RatingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""评分/评论 Agent。"""
if data.rating < 1 or data.rating > 5:
raise ValidationError("评分必须在 1-5 之间")
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
existing = db.query(AgentRating).filter(
AgentRating.agent_id == agent_id,
AgentRating.user_id == current_user.id,
).first()
if existing:
existing.rating = data.rating
existing.comment = data.comment
else:
r = AgentRating(
agent_id=agent_id,
user_id=current_user.id,
rating=data.rating,
comment=data.comment,
)
db.add(r)
agent.rating_count = (agent.rating_count or 0) + 1
# 重新计算平均分
avg = db.query(func.avg(AgentRating.rating)).filter(
AgentRating.agent_id == agent_id,
).scalar()
agent.rating_avg = str(round(float(avg), 1)) if avg else "0.0"
db.commit()
return {
"message": "评分成功",
"rating": data.rating,
"rating_avg": float(agent.rating_avg),
"rating_count": agent.rating_count,
}
@router.get("/{agent_id}/ratings", response_model=List[RatingResponse])
async def get_agent_ratings(
agent_id: str,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
"""获取 Agent 的评分/评论列表。"""
ratings = (
db.query(AgentRating)
.filter(AgentRating.agent_id == agent_id)
.order_by(AgentRating.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
result = []
for r in ratings:
result.append({
"id": r.id,
"agent_id": r.agent_id,
"user_id": r.user_id,
"username": r.user.username if r.user else None,
"rating": r.rating,
"comment": r.comment,
"created_at": r.created_at,
})
return result
# ─── 收藏 ──────────────────────────────────────────────────────────
@router.post("/{agent_id}/favorite", status_code=201)
async def favorite_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""收藏 Agent。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError("Agent", agent_id)
existing = db.query(AgentFavorite).filter(
AgentFavorite.agent_id == agent_id,
AgentFavorite.user_id == current_user.id,
).first()
if existing:
raise HTTPException(status_code=400, detail="已收藏此 Agent")
fav = AgentFavorite(agent_id=agent_id, user_id=current_user.id)
db.add(fav)
db.commit()
return {"message": "收藏成功"}
@router.delete("/{agent_id}/favorite")
async def unfavorite_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""取消收藏。"""
fav = db.query(AgentFavorite).filter(
AgentFavorite.agent_id == agent_id,
AgentFavorite.user_id == current_user.id,
).first()
if not fav:
raise HTTPException(status_code=404, detail="未收藏此 Agent")
db.delete(fav)
db.commit()
return {"message": "已取消收藏"}
@router.get("/my/favorites", response_model=List[AgentMarketItem])
async def my_favorites(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我的收藏。"""
favs = (
db.query(AgentFavorite)
.filter(AgentFavorite.user_id == current_user.id)
.order_by(AgentFavorite.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
result = []
for f in favs:
agent = db.query(Agent).filter(Agent.id == f.agent_id).first()
if agent:
item = _build_agent_item(agent, current_user, db)
item["is_favorited"] = True
result.append(AgentMarketItem(**item))
return result
@router.get("/my/shared", response_model=List[AgentMarketItem])
async def my_shared(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我分享到市场的 Agent。"""
agents = (
db.query(Agent)
.filter(
Agent.user_id == current_user.id,
Agent.is_public == 1,
)
.order_by(Agent.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [AgentMarketItem(**_build_agent_item(a, current_user, db)) for a in agents]
# ─── 分类列表 ──────────────────────────────────────────────────────
@router.get("/categories/list")
async def list_categories(db: Session = Depends(get_db)):
"""获取市场中所有分类。"""
rows = (
db.query(Agent.category, func.count(Agent.id))
.filter(Agent.is_public == 1, Agent.category.isnot(None))
.group_by(Agent.category)
.all()
)
return [{"category": cat, "count": cnt} for cat, cnt in rows if cat]

View File

@@ -37,6 +37,10 @@ class AgentCreate(BaseModel):
description: Optional[str] = None
workflow_config: Dict[str, Any] # 包含nodes和edges
budget_config: Optional[Dict[str, Any]] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
is_public: bool = False
is_featured: bool = False
class AgentUpdate(BaseModel):
@@ -218,7 +222,11 @@ async def create_agent(
workflow_config=agent_data.workflow_config,
budget_config=agent_data.budget_config,
user_id=current_user.id,
status="draft"
status="draft",
category=agent_data.category,
tags=agent_data.tags,
is_public=1 if agent_data.is_public else 0,
is_featured=1 if agent_data.is_featured else 0,
)
db.add(agent)
db.commit()

View File

@@ -0,0 +1,633 @@
<template>
<MainLayout>
<div class="agent-market">
<!-- 搜索和筛选 -->
<el-card class="search-card">
<el-row :gutter="20">
<el-col :span="8">
<el-input
v-model="searchQuery"
placeholder="搜索 Agent..."
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #append>
<el-button @click="handleSearch">搜索</el-button>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="categoryFilter" placeholder="分类" clearable @change="handleSearch">
<el-option label="LLM" value="llm" />
<el-option label="数据处理" value="data_processing" />
<el-option label="自动化" value="automation" />
<el-option label="集成" value="integration" />
<el-option label="聊天助手" value="chat_assistant" />
<el-option label="其他" value="other" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="sortBy" placeholder="排序" @change="handleSearch">
<el-option label="最新" value="created_at" />
<el-option label="评分最高" value="rating_avg" />
<el-option label="使用最多" value="use_count" />
<el-option label="查看最多" value="view_count" />
</el-select>
</el-col>
<el-col :span="4">
<el-switch
v-model="featuredOnly"
active-text="仅精选"
@change="handleSearch"
/>
</el-col>
<el-col :span="4">
<el-button-group>
<el-button :type="viewMode === 'market' ? 'primary' : 'default'" @click="switchView('market')">
市场
</el-button>
<el-button :type="viewMode === 'favorites' ? 'primary' : 'default'" @click="switchView('favorites')">
我的收藏
</el-button>
<el-button :type="viewMode === 'shared' ? 'primary' : 'default'" @click="switchView('shared')">
我分享的
</el-button>
</el-button-group>
</el-col>
</el-row>
</el-card>
<!-- Agent 列表 -->
<el-card class="agents-card">
<template #header>
<div class="card-header">
<span>{{ viewTitle }}</span>
<span class="total-count"> {{ totalCount }} Agent</span>
</div>
</template>
<el-row :gutter="20" v-loading="loading">
<el-col
v-for="agent in agents"
:key="agent.id"
:span="6"
style="margin-bottom: 20px;"
>
<el-card
class="agent-card"
:body-style="{ padding: '10px' }"
shadow="hover"
@click="viewAgent(agent)"
>
<div class="agent-badge" v-if="agent.is_featured">
<el-tag type="warning" size="small">精选</el-tag>
</div>
<div class="agent-info">
<h3 class="agent-name">
<el-icon><User /></el-icon>
{{ agent.name }}
</h3>
<p class="agent-description">{{ agent.description || '无描述' }}</p>
<div class="agent-meta">
<div class="agent-stats">
<el-icon><View /></el-icon>
<span>{{ agent.view_count }}</span>
<el-icon><Star /></el-icon>
<span>{{ agent.rating_avg.toFixed(1) }}</span>
<el-icon><DocumentCopy /></el-icon>
<span>{{ agent.use_count }}</span>
</div>
<div class="agent-category" v-if="agent.category">
<el-tag size="small">{{ getCategoryName(agent.category) }}</el-tag>
</div>
</div>
<div class="agent-creator">
作者: {{ agent.creator_username || '未知' }}
<span class="version">v{{ agent.version }}</span>
</div>
<div class="agent-tags" v-if="agent.tags && agent.tags.length > 0">
<el-tag
v-for="tag in agent.tags.slice(0, 3)"
:key="tag"
size="small"
style="margin-right: 5px;"
>
{{ tag }}
</el-tag>
</div>
<div class="agent-actions">
<el-button
type="primary"
size="small"
@click.stop="installAgent(agent)"
>
<el-icon><Download /></el-icon>
一键安装
</el-button>
<el-button
:type="agent.is_favorited ? 'warning' : 'default'"
size="small"
@click.stop="toggleFavorite(agent)"
>
<el-icon><Star /></el-icon>
{{ agent.is_favorited ? '已收藏' : '收藏' }}
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 空状态 -->
<el-empty v-if="!loading && agents.length === 0" description="暂无 Agent" />
<!-- 分页 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 24, 48, 96]"
:total="totalCount"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
style="margin-top: 20px; justify-content: center;"
/>
</el-card>
<!-- Agent 详情对话框 -->
<el-dialog
v-model="showDetailDialog"
:title="selectedAgent?.name"
width="1000px"
:close-on-click-modal="false"
>
<div v-if="selectedAgent" class="agent-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="Agent 名称">{{ selectedAgent.name }}</el-descriptions-item>
<el-descriptions-item label="创建者">{{ selectedAgent.creator_username || '未知' }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ getCategoryName(selectedAgent.category) }}</el-descriptions-item>
<el-descriptions-item label="版本">v{{ selectedAgent.version }}</el-descriptions-item>
<el-descriptions-item label="评分">
<el-rate v-model="selectedAgent.rating_avg" disabled show-score />
<span style="margin-left: 8px; color: #909399;">({{ selectedAgent.rating_count }} 人评分)</span>
</el-descriptions-item>
<el-descriptions-item label="安装次数">{{ selectedAgent.use_count }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ selectedAgent.description || '无描述' }}
</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">
<el-tag
v-for="tag in (selectedAgent.tags || [])"
:key="tag"
style="margin-right: 5px;"
>
{{ tag }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="agent-actions-detail" style="margin-top: 20px;">
<el-button type="primary" @click="installAgent(selectedAgent)">
<el-icon><Download /></el-icon>
一键安装到我的工作区
</el-button>
<el-button
:type="selectedAgent.is_favorited ? 'warning' : 'default'"
@click="toggleFavorite(selectedAgent)"
>
<el-icon><Star /></el-icon>
{{ selectedAgent.is_favorited ? '已收藏' : '收藏' }}
</el-button>
<div style="display: inline-flex; align-items: center; margin-left: 16px;">
<span style="margin-right: 8px;">我的评分:</span>
<el-rate
v-model="myRating"
:max="5"
@change="rateAgent"
/>
</div>
</div>
<!-- 评论列表 -->
<el-divider>评论 ({{ comments.length }})</el-divider>
<div class="comment-section">
<div class="comment-input">
<el-input
v-model="newComment"
type="textarea"
:rows="2"
placeholder="写下你的评论..."
/>
<el-button
type="primary"
size="small"
style="margin-top: 8px;"
@click="submitComment"
:loading="submittingComment"
>
提交评论
</el-button>
</div>
<div
v-for="c in comments"
:key="c.id"
class="comment-item"
>
<div class="comment-header">
<span class="comment-user">{{ c.username || '匿名用户' }}</span>
<el-rate :model-value="c.rating" disabled size="small" />
<span class="comment-time">{{ formatTime(c.created_at) }}</span>
</div>
<div class="comment-body" v-if="c.comment">{{ c.comment }}</div>
</div>
<el-empty v-if="comments.length === 0" description="暂无评论" :image-size="60" />
</div>
</div>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import MainLayout from '@/components/MainLayout.vue'
import { Search, Star, View, DocumentCopy, User, Download } from '@element-plus/icons-vue'
import api from '@/api'
const router = useRouter()
// 数据
const agents = ref<any[]>([])
const loading = ref(false)
const totalCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(12)
// 搜索和筛选
const searchQuery = ref('')
const categoryFilter = ref('')
const sortBy = ref('created_at')
const featuredOnly = ref(false)
const viewMode = ref('market')
// 对话框
const showDetailDialog = ref(false)
const selectedAgent = ref<any>(null)
const comments = ref<any[]>([])
const myRating = ref(0)
const newComment = ref('')
const submittingComment = ref(false)
const viewTitle = ref('Agent 技能市场')
// 加载 Agent 列表
const loadAgents = async (url?: string) => {
loading.value = true
try {
let apiUrl = url || '/api/v1/agent-market'
const params: any = {
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
sort_by: sortBy.value,
sort_order: 'desc'
}
if (!url) {
if (searchQuery.value) params.search = searchQuery.value
if (categoryFilter.value) params.category = categoryFilter.value
if (featuredOnly.value) params.featured_only = true
}
const response = await api.get(apiUrl, { params })
agents.value = response.data
totalCount.value = parseInt(response.headers['x-total-count'] || response.data.length)
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '加载 Agent 列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
currentPage.value = 1
viewMode.value = 'market'
viewTitle.value = 'Agent 技能市场'
loadAgents()
}
// 分页
const handlePageChange = (page: number) => {
currentPage.value = page
loadPageForView()
}
const handleSizeChange = (size: number) => {
pageSize.value = size
currentPage.value = 1
loadPageForView()
}
const loadPageForView = () => {
if (viewMode.value === 'favorites') loadAgents('/api/v1/agent-market/my/favorites')
else if (viewMode.value === 'shared') loadAgents('/api/v1/agent-market/my/shared')
else loadAgents()
}
// 切换视图
const switchView = (mode: string) => {
viewMode.value = mode
currentPage.value = 1
if (mode === 'market') {
viewTitle.value = 'Agent 技能市场'
loadAgents()
} else if (mode === 'favorites') {
viewTitle.value = '我的收藏'
loadAgents('/api/v1/agent-market/my/favorites')
} else if (mode === 'shared') {
viewTitle.value = '我分享的 Agent'
loadAgents('/api/v1/agent-market/my/shared')
}
}
// 查看 Agent 详情
const viewAgent = async (agent: any) => {
try {
const response = await api.get(`/api/v1/agent-market/${agent.id}`)
selectedAgent.value = response.data
myRating.value = response.data.user_rating || 0
showDetailDialog.value = true
// 加载评论
loadComments(agent.id)
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '加载 Agent 详情失败')
}
}
// 加载评论
const loadComments = async (agentId: string) => {
try {
const response = await api.get(`/api/v1/agent-market/${agentId}/ratings`)
comments.value = response.data
} catch {
comments.value = []
}
}
// 提交评论
const submitComment = async () => {
if (!selectedAgent.value) return
if (!myRating.value && !newComment.value) {
ElMessage.warning('请至少评分或填写评论')
return
}
submittingComment.value = true
try {
await api.post(`/api/v1/agent-market/${selectedAgent.value.id}/rate`, {
rating: myRating.value || 5,
comment: newComment.value || ''
})
ElMessage.success('评论提交成功')
newComment.value = ''
loadComments(selectedAgent.value.id)
// 刷新列表更新评分
if (selectedAgent.value) {
const resp = await api.get(`/api/v1/agent-market/${selectedAgent.value.id}`)
selectedAgent.value = resp.data
myRating.value = resp.data.user_rating || 0
}
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '提交评论失败')
} finally {
submittingComment.value = false
}
}
// 评分
const rateAgent = async (rating: number) => {
if (!selectedAgent.value) return
try {
await api.post(`/api/v1/agent-market/${selectedAgent.value.id}/rate`, {
rating,
comment: ''
})
ElMessage.success('评分成功')
loadComments(selectedAgent.value.id)
const resp = await api.get(`/api/v1/agent-market/${selectedAgent.value.id}`)
selectedAgent.value = resp.data
myRating.value = rating
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '评分失败')
}
}
// 安装 Agent
const installAgent = async (agent: any) => {
try {
const resp = await api.post(`/api/v1/agent-market/${agent.id}/install`)
ElMessage.success(`已安装到我的工作区: ${resp.data.agent_name}`)
// 直接跳转到 Agent 设计器
router.push(`/agents/${resp.data.agent_id}/design`)
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '安装失败')
}
}
// 收藏/取消收藏
const toggleFavorite = async (agent: any) => {
try {
if (agent.is_favorited) {
await api.delete(`/api/v1/agent-market/${agent.id}/favorite`)
agent.is_favorited = false
ElMessage.success('已取消收藏')
} else {
await api.post(`/api/v1/agent-market/${agent.id}/favorite`)
agent.is_favorited = true
ElMessage.success('已收藏')
}
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '操作失败')
}
}
// 获取分类名称
const getCategoryName = (category: string) => {
const categoryMap: Record<string, string> = {
'llm': 'LLM',
'data_processing': '数据处理',
'automation': '自动化',
'integration': '集成',
'chat_assistant': '聊天助手',
'other': '其他'
}
return categoryMap[category] || category
}
// 格式化时间
const formatTime = (t: string) => {
if (!t) return ''
return new Date(t).toLocaleString('zh-CN')
}
onMounted(() => {
loadAgents()
})
</script>
<style scoped>
.agent-market {
width: 100%;
min-height: 100vh;
}
.search-card {
margin-bottom: 20px;
}
.agents-card {
min-height: 500px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.total-count {
color: #909399;
font-size: 14px;
}
.agent-card {
cursor: pointer;
transition: all 0.3s;
height: 100%;
position: relative;
}
.agent-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.agent-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}
.agent-info {
padding: 8px;
}
.agent-name {
font-size: 16px;
font-weight: bold;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.agent-description {
font-size: 12px;
color: #909399;
margin: 0 0 8px 0;
height: 36px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.agent-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.agent-stats {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #909399;
}
.agent-creator {
font-size: 11px;
color: #c0c4cc;
margin-bottom: 6px;
}
.agent-creator .version {
margin-left: 6px;
color: #409eff;
}
.agent-tags {
margin-bottom: 8px;
}
.agent-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.agent-actions-detail {
display: flex;
gap: 12px;
align-items: center;
}
.comment-section {
margin-top: 8px;
}
.comment-input {
margin-bottom: 16px;
}
.comment-item {
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
}
.comment-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.comment-user {
font-weight: 500;
color: #303133;
}
.comment-time {
font-size: 12px;
color: #c0c4cc;
}
.comment-body {
font-size: 14px;
color: #606266;
line-height: 1.6;
}
</style>