From c387c0129bd261af0ed62779b3fffddf0ff89ca0 Mon Sep 17 00:00:00 2001 From: qdaxb <4157870+qdaxb@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:46:09 +0800 Subject: [PATCH 1/4] feat(teams): add team icon, recommended status and favorites features - Add icon and is_recommended fields to Team model (CRD spec) - Create user_team_favorites table for user-team favorite relationships - Add favorites API endpoints (add/remove/list favorites) - Add showcase API endpoints (recommended teams, favorite teams) - Create icon-picker component with Lucide icons - Create TeamCard and TeamShowcase components for chat page - Update TeamEdit with icon picker and recommended switch - Add i18n translations for new features - Add database migration for user_team_favorites table --- ...b4c5d6e7f_add_user_team_favorites_table.py | 46 ++++++ backend/app/api/endpoints/adapter/teams.py | 92 ++++++++++- backend/app/models/__init__.py | 4 +- backend/app/models/user_team_favorite.py | 22 +++ backend/app/schemas/kind.py | 2 + backend/app/schemas/team.py | 8 + backend/app/services/adapters/team_kinds.py | 16 +- backend/app/services/team_favorite.py | 81 ++++++++++ frontend/src/apis/team.ts | 14 ++ frontend/src/components/ui/icon-picker.tsx | 145 +++++++++++++++++ .../features/settings/components/TeamEdit.tsx | 40 ++++- .../features/tasks/components/ChatArea.tsx | 4 + .../features/tasks/components/TeamCard.tsx | 117 ++++++++++++++ .../tasks/components/TeamShowcase.tsx | 151 ++++++++++++++++++ frontend/src/i18n/locales/en/common.json | 6 + frontend/src/i18n/locales/zh-CN/common.json | 6 + frontend/src/types/api.ts | 3 + 17 files changed, 746 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py create mode 100644 backend/app/models/user_team_favorite.py create mode 100644 backend/app/services/team_favorite.py create mode 100644 frontend/src/components/ui/icon-picker.tsx create mode 100644 frontend/src/features/tasks/components/TeamCard.tsx create mode 100644 frontend/src/features/tasks/components/TeamShowcase.tsx diff --git a/backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py b/backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py new file mode 100644 index 00000000..24e82b07 --- /dev/null +++ b/backend/alembic/versions/2a3b4c5d6e7f_add_user_team_favorites_table.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""add_user_team_favorites_table + +Revision ID: 2a3b4c5d6e7f +Revises: 1a2b3c4d5e6f +Create Date: 2025-07-16 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2a3b4c5d6e7f' +down_revision = '1a2b3c4d5e6f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create user_team_favorites table + op.create_table( + 'user_team_favorites', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4', + mysql_collate='utf8mb4_unicode_ci' + ) + op.create_index('ix_user_team_favorites_id', 'user_team_favorites', ['id'], unique=False) + op.create_index('ix_user_team_favorites_user_id', 'user_team_favorites', ['user_id'], unique=False) + op.create_index('ix_user_team_favorites_team_id', 'user_team_favorites', ['team_id'], unique=False) + op.create_index('idx_user_team_favorite', 'user_team_favorites', ['user_id', 'team_id'], unique=True) + + +def downgrade() -> None: + op.drop_index('idx_user_team_favorite', table_name='user_team_favorites') + op.drop_index('ix_user_team_favorites_team_id', table_name='user_team_favorites') + op.drop_index('ix_user_team_favorites_user_id', table_name='user_team_favorites') + op.drop_index('ix_user_team_favorites_id', table_name='user_team_favorites') + op.drop_table('user_team_favorites') diff --git a/backend/app/api/endpoints/adapter/teams.py b/backend/app/api/endpoints/adapter/teams.py index 4bb7fb32..93139d8c 100644 --- a/backend/app/api/endpoints/adapter/teams.py +++ b/backend/app/api/endpoints/adapter/teams.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, status from sqlalchemy.orm import Session +from typing import List, Dict, Any from app.api.dependencies import get_db from app.core import security @@ -13,6 +14,7 @@ from app.schemas.shared_team import TeamShareRequest, TeamShareResponse, JoinSharedTeamRequest, JoinSharedTeamResponse from app.services.adapters.team_kinds import team_kinds_service from app.services.shared_team import shared_team_service +from app.services.team_favorite import team_favorite_service router = APIRouter() @@ -124,4 +126,92 @@ def join_shared_team( db=db, share_token=request.share_token, user_id=current_user.id - ) \ No newline at end of file + ) + + +@router.post("/{team_id}/favorite") +def add_team_to_favorites( + team_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """Add a team to user's favorites""" + return team_favorite_service.add_favorite( + db=db, + team_id=team_id, + user_id=current_user.id + ) + + +@router.delete("/{team_id}/favorite") +def remove_team_from_favorites( + team_id: int, + current_user: User = Depends(security.get_current_user), + db: Session = Depends(get_db) +): + """Remove a team from user's favorites""" + return team_favorite_service.remove_favorite( + db=db, + team_id=team_id, + user_id=current_user.id + ) + + +@router.get("/showcase/recommended", response_model=List[Dict[str, Any]]) +def get_recommended_teams( + limit: int = Query(6, ge=1, le=20, description="Max teams to return"), + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user) +): + """Get recommended teams (is_recommended=true)""" + from app.schemas.kind import Team + + # Get all teams where isRecommended is true + teams = db.query(Kind).filter( + Kind.kind == "Team", + Kind.is_active == True + ).all() + + recommended_teams = [] + favorite_team_ids = team_favorite_service.get_user_favorite_team_ids(db=db, user_id=current_user.id) + + for team in teams: + team_crd = Team.model_validate(team.json) + if team_crd.spec.isRecommended: + team_dict = team_kinds_service._convert_to_team_dict(team, db, team.user_id) + team_dict["is_favorited"] = team.id in favorite_team_ids + recommended_teams.append(team_dict) + if len(recommended_teams) >= limit: + break + + return recommended_teams + + +@router.get("/showcase/favorites", response_model=List[Dict[str, Any]]) +def get_favorite_teams( + limit: int = Query(6, ge=1, le=20, description="Max teams to return"), + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user) +): + """Get user's favorite teams""" + from app.models.user_team_favorite import UserTeamFavorite + + # Get user's favorite team IDs + favorites = db.query(UserTeamFavorite).filter( + UserTeamFavorite.user_id == current_user.id + ).order_by(UserTeamFavorite.created_at.desc()).limit(limit).all() + + favorite_teams = [] + for favorite in favorites: + team = db.query(Kind).filter( + Kind.id == favorite.team_id, + Kind.kind == "Team", + Kind.is_active == True + ).first() + + if team: + team_dict = team_kinds_service._convert_to_team_dict(team, db, team.user_id) + team_dict["is_favorited"] = True + favorite_teams.append(team_dict) + + return favorite_teams \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e52e7862..435c1929 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,11 +14,13 @@ from app.models.subtask import Subtask from app.models.shared_team import SharedTeam from app.models.skill_binary import SkillBinary +from app.models.user_team_favorite import UserTeamFavorite __all__ = [ "User", "Kind", "Subtask", "SharedTeam", - "SkillBinary" + "SkillBinary", + "UserTeamFavorite" ] \ No newline at end of file diff --git a/backend/app/models/user_team_favorite.py b/backend/app/models/user_team_favorite.py new file mode 100644 index 00000000..5046d938 --- /dev/null +++ b/backend/app/models/user_team_favorite.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from datetime import datetime +from sqlalchemy import Column, Integer, DateTime, Index +from app.db.base import Base + + +class UserTeamFavorite(Base): + """User team favorite model for maintaining user-team favorite relationships""" + __tablename__ = "user_team_favorites" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False, index=True) # User who favorited the team + team_id = Column(Integer, nullable=False, index=True) # Team that was favorited + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index('idx_user_team_favorite', 'user_id', 'team_id', unique=True), + {'mysql_charset': 'utf8mb4', 'mysql_collate': 'utf8mb4_unicode_ci'}, + ) diff --git a/backend/app/schemas/kind.py b/backend/app/schemas/kind.py index 1a461829..4bcb83ba 100644 --- a/backend/app/schemas/kind.py +++ b/backend/app/schemas/kind.py @@ -177,6 +177,8 @@ class TeamSpec(BaseModel): """Team specification""" members: List[TeamMember] collaborationModel: str # pipeline、route、coordinate、collaborate + icon: Optional[str] = None # Lucide icon name (e.g., "Users", "Bot", "Zap") + isRecommended: bool = False # Whether this team is recommended class TeamStatus(Status): diff --git a/backend/app/schemas/team.py b/backend/app/schemas/team.py index a8372e0f..9afd97a5 100644 --- a/backend/app/schemas/team.py +++ b/backend/app/schemas/team.py @@ -34,6 +34,8 @@ class TeamBase(BaseModel): bots: List[BotInfo] workflow: Optional[dict[str, Any]] = None is_active: bool = True + icon: Optional[str] = None # Lucide icon name (e.g., "Users", "Bot", "Zap") + is_recommended: bool = False # Whether this team is recommended class TeamCreate(TeamBase): """Team creation model""" @@ -45,6 +47,8 @@ class TeamUpdate(BaseModel): bots: Optional[List[BotInfo]] = None workflow: Optional[dict[str, Any]] = None is_active: Optional[bool] = None + icon: Optional[str] = None # Lucide icon name + is_recommended: Optional[bool] = None # Whether this team is recommended class TeamInDB(TeamBase): """Database team model""" @@ -55,6 +59,7 @@ class TeamInDB(TeamBase): user: Optional[dict[str, Any]] = None share_status: int = 0 # 0-private, 1-sharing, 2-shared from others agent_type: Optional[str] = None # agno, claude, dify, etc. + is_favorited: bool = False # Whether current user has favorited this team class Config: from_attributes = True @@ -71,6 +76,9 @@ class TeamDetail(BaseModel): updated_at: datetime user: Optional[UserInDB] = None share_status: int = 0 # 0-private, 1-sharing, 2-shared from others + icon: Optional[str] = None # Lucide icon name + is_recommended: bool = False # Whether this team is recommended + is_favorited: bool = False # Whether current user has favorited this team class Config: from_attributes = True diff --git a/backend/app/services/adapters/team_kinds.py b/backend/app/services/adapters/team_kinds.py index d7cc35c0..a9142e57 100644 --- a/backend/app/services/adapters/team_kinds.py +++ b/backend/app/services/adapters/team_kinds.py @@ -125,7 +125,9 @@ def create_with_user( "kind": "Team", "spec": { "members": members, - "collaborationModel": collaboration_model + "collaborationModel": collaboration_model, + "icon": getattr(obj_in, 'icon', None), + "isRecommended": getattr(obj_in, 'is_recommended', False) }, "status": { "state": "Available" @@ -462,6 +464,14 @@ def update_with_user( team_crd.spec.collaborationModel = collaboration_model + # Handle icon update + if "icon" in update_data: + team_crd.spec.icon = update_data["icon"] + + # Handle is_recommended update + if "is_recommended" in update_data: + team_crd.spec.isRecommended = update_data["is_recommended"] + # Save the updated team CRD team.json = team_crd.model_dump(mode='json') team.updated_at = datetime.now() @@ -779,7 +789,7 @@ def _convert_to_team_dict(self, team: Kind, db: Session, user_id: int) -> Dict[s # Convert collaboration model to workflow format workflow = {"mode": team_crd.spec.collaborationModel} - + return { "id": team.id, "user_id": team.user_id, @@ -791,6 +801,8 @@ def _convert_to_team_dict(self, team: Kind, db: Session, user_id: int) -> Dict[s "created_at": team.created_at, "updated_at": team.updated_at, "agent_type": agent_type, # Add agent_type field + "icon": team_crd.spec.icon, # Lucide icon name + "is_recommended": team_crd.spec.isRecommended, # Whether this team is recommended } def _get_bot_summary(self, bot: Kind, db: Session, user_id: int) -> Dict[str, Any]: diff --git a/backend/app/services/team_favorite.py b/backend/app/services/team_favorite.py new file mode 100644 index 00000000..5b497f78 --- /dev/null +++ b/backend/app/services/team_favorite.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from typing import List, Dict, Any, Set +from sqlalchemy.orm import Session +from fastapi import HTTPException + +from app.models.user_team_favorite import UserTeamFavorite +from app.models.kind import Kind + + +class TeamFavoriteService: + """Service for team favorite operations""" + + def add_favorite(self, db: Session, *, team_id: int, user_id: int) -> Dict[str, Any]: + """Add a team to user's favorites""" + # Check if team exists + team = db.query(Kind).filter( + Kind.id == team_id, + Kind.kind == "Team", + Kind.is_active == True + ).first() + + if not team: + raise HTTPException( + status_code=404, + detail="Team not found" + ) + + # Check if already favorited + existing = db.query(UserTeamFavorite).filter( + UserTeamFavorite.user_id == user_id, + UserTeamFavorite.team_id == team_id + ).first() + + if existing: + return {"message": "Team already in favorites", "is_favorited": True} + + # Create favorite record + favorite = UserTeamFavorite( + user_id=user_id, + team_id=team_id + ) + db.add(favorite) + db.commit() + + return {"message": "Team added to favorites", "is_favorited": True} + + def remove_favorite(self, db: Session, *, team_id: int, user_id: int) -> Dict[str, Any]: + """Remove a team from user's favorites""" + favorite = db.query(UserTeamFavorite).filter( + UserTeamFavorite.user_id == user_id, + UserTeamFavorite.team_id == team_id + ).first() + + if not favorite: + return {"message": "Team not in favorites", "is_favorited": False} + + db.delete(favorite) + db.commit() + + return {"message": "Team removed from favorites", "is_favorited": False} + + def get_user_favorite_team_ids(self, db: Session, *, user_id: int) -> Set[int]: + """Get set of team IDs that user has favorited""" + favorites = db.query(UserTeamFavorite.team_id).filter( + UserTeamFavorite.user_id == user_id + ).all() + return {f.team_id for f in favorites} + + def is_team_favorited(self, db: Session, *, team_id: int, user_id: int) -> bool: + """Check if a team is in user's favorites""" + favorite = db.query(UserTeamFavorite).filter( + UserTeamFavorite.user_id == user_id, + UserTeamFavorite.team_id == team_id + ).first() + return favorite is not None + + +team_favorite_service = TeamFavoriteService() diff --git a/frontend/src/apis/team.ts b/frontend/src/apis/team.ts index 5d5e6eac..44e56fcd 100644 --- a/frontend/src/apis/team.ts +++ b/frontend/src/apis/team.ts @@ -12,6 +12,8 @@ export interface CreateTeamRequest { bots?: TeamBot[]; workflow?: Record; is_active?: boolean; + icon?: string; // Lucide icon name + is_recommended?: boolean; // Whether this team is recommended } export interface TeamListResponse { @@ -82,4 +84,16 @@ export const teamApis = { async getTeamInputParameters(teamId: number): Promise { return apiClient.get(`/teams/${teamId}/input-parameters`); }, + async addTeamToFavorites(teamId: number): Promise<{ message: string; is_favorited: boolean }> { + return apiClient.post(`/teams/${teamId}/favorite`); + }, + async removeTeamFromFavorites(teamId: number): Promise<{ message: string; is_favorited: boolean }> { + return apiClient.delete(`/teams/${teamId}/favorite`); + }, + async getRecommendedTeams(limit: number = 6): Promise { + return apiClient.get(`/teams/showcase/recommended?limit=${limit}`); + }, + async getFavoriteTeams(limit: number = 6): Promise { + return apiClient.get(`/teams/showcase/favorites?limit=${limit}`); + }, }; diff --git a/frontend/src/components/ui/icon-picker.tsx b/frontend/src/components/ui/icon-picker.tsx new file mode 100644 index 00000000..fbd0622b --- /dev/null +++ b/frontend/src/components/ui/icon-picker.tsx @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useState, useMemo } from 'react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import * as LucideIcons from 'lucide-react'; +import { useTranslation } from '@/hooks/useTranslation'; + +// Common icons for team selection (curated list of ~60 icons) +const COMMON_ICONS = [ + 'Users', 'Bot', 'Zap', 'Rocket', 'Star', 'Heart', 'Brain', 'Lightbulb', + 'Code', 'Terminal', 'Database', 'Server', 'Cloud', 'Globe', 'Network', 'Cpu', + 'Sparkles', 'Wand2', 'Magic', 'Target', 'Award', 'Trophy', 'Medal', 'Crown', + 'Building', 'Home', 'Briefcase', 'Folder', 'FileCode', 'GitBranch', 'Github', 'Gitlab', + 'MessageSquare', 'MessagesSquare', 'Mail', 'Send', 'Phone', 'Video', 'Mic', 'Headphones', + 'Search', 'Eye', 'Settings', 'Wrench', 'Hammer', 'Puzzle', 'Layers', 'Layout', + 'Palette', 'Paintbrush', 'PenTool', 'Image', 'Camera', 'Film', 'Music', 'Play', + 'Shield', 'Lock', 'Key', 'Fingerprint', 'UserCheck', 'UserPlus', 'Users2', 'UsersRound', +]; + +interface IconPickerProps { + value?: string; + onChange: (icon: string) => void; + disabled?: boolean; + className?: string; + teamName?: string; +} + +export default function IconPicker({ + value, + onChange, + disabled = false, + className, + teamName = '', +}: IconPickerProps) { + const { t } = useTranslation('common'); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + // Get the icon component dynamically + const IconComponent = value + ? (LucideIcons as Record>)[value] + : null; + + // Filter icons based on search + const filteredIcons = useMemo(() => { + if (!search.trim()) return COMMON_ICONS; + const searchLower = search.toLowerCase(); + return COMMON_ICONS.filter(icon => + icon.toLowerCase().includes(searchLower) + ); + }, [search]); + + // Get first letter for default avatar + const firstLetter = teamName.trim().charAt(0).toUpperCase() || 'T'; + + const handleIconSelect = (iconName: string) => { + onChange(iconName); + setOpen(false); + setSearch(''); + }; + + return ( + + + + + +
+ setSearch(e.target.value)} + className="h-8" + /> +
+ {filteredIcons.map(iconName => { + const Icon = (LucideIcons as Record>)[iconName]; + if (!Icon) return null; + const isSelected = value === iconName; + return ( + + ); + })} +
+ {filteredIcons.length === 0 && ( +

+ {t('teams.no_match')} +

+ )} + {value && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/features/settings/components/TeamEdit.tsx b/frontend/src/features/settings/components/TeamEdit.tsx index b3ad9cc8..0638ed19 100644 --- a/frontend/src/features/settings/components/TeamEdit.tsx +++ b/frontend/src/features/settings/components/TeamEdit.tsx @@ -15,6 +15,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Switch } from '@/components/ui/switch'; import Image from 'next/image'; import { Loader2 } from 'lucide-react'; import { RiRobot2Line } from 'react-icons/ri'; @@ -24,6 +25,7 @@ import { TeamMode, getFilteredBotsForMode, AgentType } from './team-modes'; import { createTeam, updateTeam } from '../services/teams'; import TeamEditDrawer from './TeamEditDrawer'; import { useTranslation } from '@/hooks/useTranslation'; +import IconPicker from '@/components/ui/icon-picker'; // Import mode-specific editors import SoloModeEditor from './team-modes/SoloModeEditor'; @@ -66,6 +68,8 @@ export default function TeamEdit(props: TeamEditProps) { // Left column: Team Name, Mode, Description const [name, setName] = useState(''); const [mode, setMode] = useState('solo'); + const [icon, setIcon] = useState(''); + const [isRecommended, setIsRecommended] = useState(false); // Right column: LeaderBot (single select), Bots Transfer (multi-select) // Use string key for antd Transfer, stringify bot.id here @@ -199,6 +203,8 @@ export default function TeamEdit(props: TeamEditProps) { setName(formTeam.name); const m = (formTeam.workflow?.mode as TeamMode) || 'pipeline'; setMode(m); + setIcon(formTeam.icon || ''); + setIsRecommended(formTeam.is_recommended || false); const ids = formTeam.bots.map(b => String(b.bot_id)); setSelectedBotKeys(ids); const leaderBot = formTeam.bots.find(b => b.role === 'leader'); @@ -206,6 +212,8 @@ export default function TeamEdit(props: TeamEditProps) { } else { setName(''); setMode('solo'); + setIcon(''); + setIsRecommended(false); setSelectedBotKeys([]); setLeaderBotId(null); } @@ -395,6 +403,8 @@ export default function TeamEdit(props: TeamEditProps) { name: name.trim(), workflow, bots: botsData, + icon: icon || undefined, + is_recommended: isRecommended, }); setTeams(prev => prev.map(team => (team.id === updated.id ? updated : team))); } else { @@ -402,6 +412,8 @@ export default function TeamEdit(props: TeamEditProps) { name: name.trim(), workflow, bots: botsData, + icon: icon || undefined, + is_recommended: isRecommended, }); setTeams(prev => [created, ...prev]); } @@ -468,19 +480,33 @@ export default function TeamEdit(props: TeamEditProps) {
{/* Left column */}
- {/* Team Name */} + {/* Team Name with Icon Picker */}
- setName(e.target.value)} - placeholder={t('team.name_placeholder')} - className="w-full px-4 py-1 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-transparent text-base h-9" +
+ + setName(e.target.value)} + placeholder={t('team.name_placeholder')} + className="flex-1 px-4 py-1 bg-base rounded-md text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-transparent text-base h-9" + /> +
+
+ + {/* Set as Recommended Switch */} +
+ +
diff --git a/frontend/src/features/tasks/components/ChatArea.tsx b/frontend/src/features/tasks/components/ChatArea.tsx index f87692c4..6252c1d5 100644 --- a/frontend/src/features/tasks/components/ChatArea.tsx +++ b/frontend/src/features/tasks/components/ChatArea.tsx @@ -9,6 +9,7 @@ import { Send, CircleStop } from 'lucide-react'; import MessagesArea from './MessagesArea'; import ChatInput from './ChatInput'; import TeamSelector from './TeamSelector'; +import TeamShowcase from './TeamShowcase'; import ModelSelector, { Model, DEFAULT_MODEL_NAME } from './ModelSelector'; import RepositorySelector from './RepositorySelector'; import BranchSelector from './BranchSelector'; @@ -604,6 +605,9 @@ export default function ChatArea({ )}
+ + {/* Team Showcase - Recommended and Favorites */} +
diff --git a/frontend/src/features/tasks/components/TeamCard.tsx b/frontend/src/features/tasks/components/TeamCard.tsx new file mode 100644 index 00000000..28ecc1c6 --- /dev/null +++ b/frontend/src/features/tasks/components/TeamCard.tsx @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React from 'react'; +import { Star } from 'lucide-react'; +import * as LucideIcons from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { Team } from '@/types/api'; + +interface TeamCardProps { + team: Team; + onSelect: (team: Team) => void; + onToggleFavorite: (team: Team) => void; + isTogglingFavorite?: boolean; +} + +export default function TeamCard({ + team, + onSelect, + onToggleFavorite, + isTogglingFavorite = false, +}: TeamCardProps) { + // Get the icon component dynamically + const IconComponent = team.icon + ? (LucideIcons as Record>)[team.icon] + : null; + + // Get first letter for default avatar + const firstLetter = team.name.trim().charAt(0).toUpperCase() || 'T'; + + const handleFavoriteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isTogglingFavorite) { + onToggleFavorite(team); + } + }; + + return ( +
onSelect(team)} + > + {/* Header: Icon + Name + Favorite */} +
+
+ {/* Team Icon/Avatar */} +
+ {IconComponent ? ( + + ) : ( + {firstLetter} + )} +
+ + {/* Team Name */} +
+

+ {team.name} +

+ {team.user?.user_name && team.share_status === 2 && ( +

+ by {team.user.user_name} +

+ )} +
+
+ + {/* Favorite Button */} + +
+ + {/* Agent Type Badge */} + {team.agent_type && ( +
+ + {team.agent_type} + +
+ )} +
+ ); +} diff --git a/frontend/src/features/tasks/components/TeamShowcase.tsx b/frontend/src/features/tasks/components/TeamShowcase.tsx new file mode 100644 index 00000000..1fbdf7f7 --- /dev/null +++ b/frontend/src/features/tasks/components/TeamShowcase.tsx @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { teamApis } from '@/apis/team'; +import type { Team } from '@/types/api'; +import TeamCard from './TeamCard'; +import { Loader2 } from 'lucide-react'; + +interface TeamShowcaseProps { + onSelectTeam: (team: Team) => void; + className?: string; +} + +export default function TeamShowcase({ onSelectTeam, className = '' }: TeamShowcaseProps) { + const { t } = useTranslation('common'); + const [recommendedTeams, setRecommendedTeams] = useState([]); + const [favoriteTeams, setFavoriteTeams] = useState([]); + const [isLoadingRecommended, setIsLoadingRecommended] = useState(true); + const [isLoadingFavorites, setIsLoadingFavorites] = useState(true); + const [togglingFavoriteIds, setTogglingFavoriteIds] = useState>(new Set()); + + // Fetch recommended teams + useEffect(() => { + const fetchRecommended = async () => { + try { + const teams = await teamApis.getRecommendedTeams(6); + setRecommendedTeams(teams); + } catch (error) { + console.error('Failed to fetch recommended teams:', error); + } finally { + setIsLoadingRecommended(false); + } + }; + fetchRecommended(); + }, []); + + // Fetch favorite teams + useEffect(() => { + const fetchFavorites = async () => { + try { + const teams = await teamApis.getFavoriteTeams(6); + setFavoriteTeams(teams); + } catch (error) { + console.error('Failed to fetch favorite teams:', error); + } finally { + setIsLoadingFavorites(false); + } + }; + fetchFavorites(); + }, []); + + // Toggle favorite status + const handleToggleFavorite = useCallback(async (team: Team) => { + if (togglingFavoriteIds.has(team.id)) return; + + setTogglingFavoriteIds(prev => new Set(prev).add(team.id)); + + try { + if (team.is_favorited) { + await teamApis.removeTeamFromFavorites(team.id); + // Update both lists + setRecommendedTeams(prev => + prev.map(t => (t.id === team.id ? { ...t, is_favorited: false } : t)) + ); + setFavoriteTeams(prev => prev.filter(t => t.id !== team.id)); + } else { + await teamApis.addTeamToFavorites(team.id); + // Update recommended list + setRecommendedTeams(prev => + prev.map(t => (t.id === team.id ? { ...t, is_favorited: true } : t)) + ); + // Add to favorites if not already there + if (!favoriteTeams.some(t => t.id === team.id)) { + setFavoriteTeams(prev => [{ ...team, is_favorited: true }, ...prev].slice(0, 6)); + } + } + } catch (error) { + console.error('Failed to toggle favorite:', error); + } finally { + setTogglingFavoriteIds(prev => { + const next = new Set(prev); + next.delete(team.id); + return next; + }); + } + }, [togglingFavoriteIds, favoriteTeams]); + + const hasContent = recommendedTeams.length > 0 || favoriteTeams.length > 0; + const isLoading = isLoadingRecommended || isLoadingFavorites; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!hasContent) { + return null; + } + + return ( +
+ {/* Recommended Teams Section */} + {recommendedTeams.length > 0 && ( +
+

+ {t('teams.recommended')} +

+
+ {recommendedTeams.map(team => ( + + ))} +
+
+ )} + + {/* Favorite Teams Section */} + {favoriteTeams.length > 0 && ( +
+

+ {t('teams.favorites')} +

+
+ {favoriteTeams.map(team => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 358456d3..e1d98458 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -183,6 +183,12 @@ "copy_success": "Copied successfully", "share_instructions_content1": "Copy and send this link to others, they can use your shared team to complete tasks directly through the link.", "share_instructions_content2": "After sharing the team, you can still manage and edit this team, and others can apply the latest team configuration but cannot edit the team settings.", + "recommended": "Recommended Apps", + "favorites": "My Favorites", + "set_recommended": "Set as Recommended", + "add_to_favorites": "Add to Favorites", + "remove_from_favorites": "Remove from Favorites", + "select_icon": "Select Icon", "share": { "title": "Team Share", "confirm_message": "Do you want to use {{userName}}'s {{teamName}} to execute tasks?", diff --git a/frontend/src/i18n/locales/zh-CN/common.json b/frontend/src/i18n/locales/zh-CN/common.json index 0e19bf4f..01d311a8 100644 --- a/frontend/src/i18n/locales/zh-CN/common.json +++ b/frontend/src/i18n/locales/zh-CN/common.json @@ -184,6 +184,12 @@ "copy_success": "复制成功", "share_instructions_content1": "复制链接发送给其他人,他们可以通过链接使用你分享的团队来直接完成任务。 ", "share_instructions_content2": "团队共享后,你仍然可以管理和编辑这个团队,其他人可以应用最新的团队配置但不能编辑团队设置。", + "recommended": "推荐应用", + "favorites": "我的收藏", + "set_recommended": "设为推荐", + "add_to_favorites": "添加收藏", + "remove_from_favorites": "取消收藏", + "select_icon": "选择图标", "share": { "title": "团队分享", "confirm_message": "确定要使用 {{userName}} 的 {{teamName}} 执行任务吗?", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index afc1fd66..5afc06c1 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -96,6 +96,9 @@ export interface Team { share_status?: number; // 0: 个人团队, 1: 分享中, 2: 共享团队 agent_type?: string; // agno, claude, dify, etc. is_mix_team?: boolean; // true if team has multiple different agent types (e.g., ClaudeCode + Agno) + icon?: string; // Lucide icon name (e.g., "Users", "Bot", "Zap") + is_recommended?: boolean; // Whether this team is recommended + is_favorited?: boolean; // Whether current user has favorited this team user?: { user_name: string; }; From 3a08be3a26bd6625420347d0fe9893cbdbbd3470 Mon Sep 17 00:00:00 2001 From: axb Date: Sun, 30 Nov 2025 11:30:08 +0800 Subject: [PATCH 2/4] fix: fix team fav icons --- backend/app/api/endpoints/adapter/teams.py | 8 +++ .../features/settings/components/TeamList.tsx | 70 ++++++++++++++++++- .../src/features/settings/services/teams.ts | 14 ++++ frontend/src/i18n/locales/en/common.json | 5 +- frontend/src/i18n/locales/zh-CN/common.json | 5 +- 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/backend/app/api/endpoints/adapter/teams.py b/backend/app/api/endpoints/adapter/teams.py index 95754dff..f073974c 100644 --- a/backend/app/api/endpoints/adapter/teams.py +++ b/backend/app/api/endpoints/adapter/teams.py @@ -42,6 +42,14 @@ def list_teams( items = team_kinds_service.get_user_teams( db=db, user_id=current_user.id, skip=skip, limit=limit ) + + # Add is_favorited field to each team + favorite_team_ids = team_favorite_service.get_user_favorite_team_ids( + db=db, user_id=current_user.id + ) + for item in items: + item["is_favorited"] = item["id"] in favorite_team_ids + if page == 1 and len(items) < limit: total = len(items) else: diff --git a/frontend/src/features/settings/components/TeamList.tsx b/frontend/src/features/settings/components/TeamList.tsx index e1e9ce0a..98450d56 100644 --- a/frontend/src/features/settings/components/TeamList.tsx +++ b/frontend/src/features/settings/components/TeamList.tsx @@ -7,8 +7,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import '@/features/common/scrollbar.css'; -import { AiOutlineTeam } from 'react-icons/ai'; import { RiRobot2Line } from 'react-icons/ri'; +import * as LucideIcons from 'lucide-react'; import LoadingState from '@/features/common/LoadingState'; import { PencilIcon, @@ -16,9 +16,11 @@ import { DocumentDuplicateIcon, ChatBubbleLeftEllipsisIcon, ShareIcon, + StarIcon, } from '@heroicons/react/24/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; import { Bot, Team } from '@/types/api'; -import { fetchTeamsList, deleteTeam, shareTeam } from '../services/teams'; +import { fetchTeamsList, deleteTeam, shareTeam, toggleTeamFavorite } from '../services/teams'; import { fetchBotsList } from '../services/bots'; import TeamEdit from './TeamEdit'; import BotList from './BotList'; @@ -55,6 +57,7 @@ export default function TeamList() { const [shareData, setShareData] = useState<{ teamName: string; shareUrl: string } | null>(null); const [sharingId, setSharingId] = useState(null); const [_deletingId, setDeletingId] = useState(null); + const [togglingFavoriteId, setTogglingFavoriteId] = useState(null); const [botListVisible, setBotListVisible] = useState(false); const router = useRouter(); const isEditing = editingTeamId !== null; @@ -207,6 +210,27 @@ export default function TeamList() { return !team.share_status || team.share_status === 0 || team.share_status === 1; // Personal teams (no share_status or share_status=0) show share button }; + // Handle toggle favorite + const handleToggleFavorite = async (team: Team) => { + if (togglingFavoriteId === team.id) return; + + setTogglingFavoriteId(team.id); + try { + const result = await toggleTeamFavorite(team.id, team.is_favorited || false); + // Update team's favorite status in the list + setTeamsSorted(prev => + prev.map(t => (t.id === team.id ? { ...t, is_favorited: result.is_favorited } : t)) + ); + } catch { + toast({ + variant: 'destructive', + title: t('teams.favorite_failed'), + }); + } finally { + setTogglingFavoriteId(null); + } + }; + return ( <>
@@ -250,7 +274,29 @@ export default function TeamList() { >
- + {(() => { + // Get the icon component dynamically + const IconComponent = team.icon + ? ( + LucideIcons as unknown as Record< + string, + React.ComponentType<{ className?: string }> + > + )[team.icon] + : null; + // Get first letter for default avatar + const firstLetter = team.name.trim().charAt(0).toUpperCase() || 'T'; + + return ( +
+ {IconComponent ? ( + + ) : ( + {firstLetter} + )} +
+ ); + })()}

@@ -286,6 +332,24 @@ export default function TeamList() {

+
@@ -102,12 +100,7 @@ export default function TeamCard({ {/* Agent Type Badge */} {team.agent_type && (
- + {team.agent_type}
From 325865e10a207bb888033e3c4c15a6084a4b1b0c Mon Sep 17 00:00:00 2001 From: axb Date: Sun, 30 Nov 2025 11:36:48 +0800 Subject: [PATCH 4/4] feat: hide recommand toggle --- .../features/settings/components/TeamEdit.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/frontend/src/features/settings/components/TeamEdit.tsx b/frontend/src/features/settings/components/TeamEdit.tsx index 0638ed19..945ad196 100644 --- a/frontend/src/features/settings/components/TeamEdit.tsx +++ b/frontend/src/features/settings/components/TeamEdit.tsx @@ -15,7 +15,6 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Switch } from '@/components/ui/switch'; import Image from 'next/image'; import { Loader2 } from 'lucide-react'; import { RiRobot2Line } from 'react-icons/ri'; @@ -69,7 +68,6 @@ export default function TeamEdit(props: TeamEditProps) { const [name, setName] = useState(''); const [mode, setMode] = useState('solo'); const [icon, setIcon] = useState(''); - const [isRecommended, setIsRecommended] = useState(false); // Right column: LeaderBot (single select), Bots Transfer (multi-select) // Use string key for antd Transfer, stringify bot.id here @@ -204,7 +202,6 @@ export default function TeamEdit(props: TeamEditProps) { const m = (formTeam.workflow?.mode as TeamMode) || 'pipeline'; setMode(m); setIcon(formTeam.icon || ''); - setIsRecommended(formTeam.is_recommended || false); const ids = formTeam.bots.map(b => String(b.bot_id)); setSelectedBotKeys(ids); const leaderBot = formTeam.bots.find(b => b.role === 'leader'); @@ -213,7 +210,6 @@ export default function TeamEdit(props: TeamEditProps) { setName(''); setMode('solo'); setIcon(''); - setIsRecommended(false); setSelectedBotKeys([]); setLeaderBotId(null); } @@ -404,7 +400,6 @@ export default function TeamEdit(props: TeamEditProps) { workflow, bots: botsData, icon: icon || undefined, - is_recommended: isRecommended, }); setTeams(prev => prev.map(team => (team.id === updated.id ? updated : team))); } else { @@ -413,7 +408,6 @@ export default function TeamEdit(props: TeamEditProps) { workflow, bots: botsData, icon: icon || undefined, - is_recommended: isRecommended, }); setTeams(prev => [created, ...prev]); } @@ -498,18 +492,6 @@ export default function TeamEdit(props: TeamEditProps) { />
- - {/* Set as Recommended Switch */} -
- - -
- {/* Mode component */}