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:
44
backend/alembic/versions/011_add_agent_market_fields.py
Normal file
44
backend/alembic/versions/011_add_agent_market_fields.py
Normal 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
|
||||
598
backend/app/api/agent_market.py
Normal file
598
backend/app/api/agent_market.py
Normal 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]
|
||||
@@ -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()
|
||||
|
||||
633
frontend/src/views/AgentMarket.vue
Normal file
633
frontend/src/views/AgentMarket.vue
Normal 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>
|
||||
Reference in New Issue
Block a user