diff --git a/service/app/api/v1/agents.py b/service/app/api/v1/agents.py index bdcd0b38..b61aa04d 100644 --- a/service/app/api/v1/agents.py +++ b/service/app/api/v1/agents.py @@ -16,6 +16,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlmodel.ext.asyncio.session import AsyncSession from app.agents.types import SystemAgentInfo @@ -226,6 +227,37 @@ async def create_agent_from_template( return AgentRead(**created_agent.model_dump()) +class AgentReorderRequest(BaseModel): + """Request body for reordering agents.""" + + agent_ids: list[UUID] + + +@router.put("/reorder", status_code=204) +async def reorder_agents( + request: AgentReorderRequest, + user_id: str = Depends(get_current_user), + db: AsyncSession = Depends(get_session), +) -> None: + """ + Reorder agents by providing a list of agent IDs in the desired order. + + The sort_order of each agent will be updated based on its position in the list. + Only agents owned by the current user will be updated. + + Args: + request: Request body containing ordered list of agent IDs + user_id: Authenticated user ID (injected by dependency) + db: Database session (injected by dependency) + + Returns: + None: Returns 204 No Content on success + """ + agent_repo = AgentRepository(db) + await agent_repo.update_agents_sort_order(user_id, request.agent_ids) + await db.commit() + + @router.get("/stats", response_model=dict[str, AgentStatsAggregated]) async def get_all_agent_stats( user: str = Depends(get_current_user), diff --git a/service/app/models/agent.py b/service/app/models/agent.py index 7b295e9c..61a67eb7 100644 --- a/service/app/models/agent.py +++ b/service/app/models/agent.py @@ -46,6 +46,7 @@ class AgentBase(SQLModel): temperature: float | None = None prompt: str | None = None user_id: str | None = Field(index=True, default=None, nullable=True) + sort_order: int = Field(default=0, index=True) require_tool_confirmation: bool = Field(default=False) provider_id: UUID | None = Field(default=None, index=True) knowledge_set_id: UUID | None = Field(default=None, index=True) diff --git a/service/app/repos/agent.py b/service/app/repos/agent.py index 921cd845..1bb5046f 100644 --- a/service/app/repos/agent.py +++ b/service/app/repos/agent.py @@ -2,7 +2,7 @@ from typing import Sequence from uuid import UUID -from sqlmodel import col, select +from sqlmodel import col, func, select from sqlmodel.ext.asyncio.session import AsyncSession from app.models.agent import Agent, AgentCreate, AgentScope, AgentUpdate @@ -32,7 +32,7 @@ async def get_agent_by_id(self, agent_id: UUID) -> Agent | None: async def get_agents_by_user(self, user_id: str) -> Sequence[Agent]: """ - Fetches all agents for a given user. + Fetches all agents for a given user, ordered by sort_order. Args: user_id: The user ID. @@ -41,7 +41,7 @@ async def get_agents_by_user(self, user_id: str) -> Sequence[Agent]: List of Agent instances. """ logger.debug(f"Fetching agents for user_id: {user_id}") - statement = select(Agent).where(Agent.user_id == user_id) + statement = select(Agent).where(Agent.user_id == user_id).order_by(col(Agent.sort_order)) result = await self.db.exec(statement) return result.all() @@ -203,6 +203,11 @@ async def create_agent(self, agent_data: AgentCreate, user_id: str) -> Agent: # Extract MCP server IDs before creating agent mcp_server_ids = agent_data.mcp_server_ids + # Calculate next sort_order for this user + max_order_result = await self.db.exec(select(func.max(Agent.sort_order)).where(Agent.user_id == user_id)) + max_order = max_order_result.one_or_none() or 0 + next_sort_order = max_order + 1 + # Generate graph_config if not provided (single source of truth: builtin react config) graph_config = agent_data.graph_config if graph_config is None: @@ -236,6 +241,7 @@ async def create_agent(self, agent_data: AgentCreate, user_id: str) -> Agent: agent_dict = agent_data.model_dump(exclude={"mcp_server_ids"}) agent_dict["user_id"] = user_id agent_dict["graph_config"] = graph_config # Use generated or provided config + agent_dict["sort_order"] = next_sort_order agent = Agent(**agent_dict) self.db.add(agent) @@ -395,3 +401,22 @@ async def link_agent_to_mcp_servers(self, agent_id: UUID, mcp_server_ids: Sequen self.db.add(link) await self.db.flush() + + async def update_agents_sort_order(self, user_id: str, agent_ids: list[UUID]) -> None: + """ + Updates the sort_order of multiple agents based on their position in the list. + This function does NOT commit the transaction. + + Args: + user_id: The user ID (for authorization check). + agent_ids: Ordered list of agent UUIDs. The index becomes the new sort_order. + """ + logger.debug(f"Updating sort order for {len(agent_ids)} agents") + + for index, agent_id in enumerate(agent_ids): + agent = await self.db.get(Agent, agent_id) + if agent and agent.user_id == user_id: + agent.sort_order = index + self.db.add(agent) + + await self.db.flush() diff --git a/service/migrations/versions/5c6c342a4420_add_sort_order_to_agent.py b/service/migrations/versions/5c6c342a4420_add_sort_order_to_agent.py new file mode 100644 index 00000000..723a665d --- /dev/null +++ b/service/migrations/versions/5c6c342a4420_add_sort_order_to_agent.py @@ -0,0 +1,53 @@ +"""Add sort_order to Agent + +Revision ID: 5c6c342a4420 +Revises: 90e892e60144 +Create Date: 2026-01-26 19:44:39.313549 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "5c6c342a4420" +down_revision: Union[str, Sequence[str], None] = "90e892e60144" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Add column as nullable first with default 0 + op.add_column("agent", sa.Column("sort_order", sa.Integer(), nullable=True, server_default="0")) + + # Update existing rows to have sequential sort_order per user + op.execute(""" + WITH ranked AS ( + SELECT id, user_id, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) - 1 as new_order + FROM agent + ) + UPDATE agent + SET sort_order = ranked.new_order + FROM ranked + WHERE agent.id = ranked.id + """) + + # Now make it non-nullable + op.alter_column("agent", "sort_order", nullable=False) + + # Remove server_default (not needed after migration) + op.alter_column("agent", "sort_order", server_default=None) + + # Create index + op.create_index(op.f("ix_agent_sort_order"), "agent", ["sort_order"], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_agent_sort_order"), table_name="agent") + op.drop_column("agent", "sort_order") diff --git a/web/package.json b/web/package.json index 950026d9..d33d159e 100644 --- a/web/package.json +++ b/web/package.json @@ -63,6 +63,7 @@ "@ariakit/react": "^0.4.20", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@emoji-mart/data": "1.2.1", "@emotion/is-prop-valid": "^1.3.1", "@faker-js/faker": "^10.1.0", diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 3fc94db4..940f4919 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -8,9 +8,10 @@ import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useState } from "react"; import { SecretCodePage } from "@/components/admin/SecretCodePage"; -import { CenteredInput } from "@/components/features"; +import { CenteredInput, UpdateOverlay } from "@/components/features"; import { DEFAULT_BACKEND_URL } from "@/configs"; import { MOBILE_BREAKPOINT } from "@/configs/common"; +import { useAutoUpdate } from "@/hooks/useAutoUpdate"; import useTheme from "@/hooks/useTheme"; import { LAYOUT_STYLE, type InputPosition } from "@/store/slices/uiSlice/types"; import { AppFullscreen } from "./AppFullscreen"; @@ -259,7 +260,7 @@ export function Xyzen({ ) ) : ( - <>{mainLayout} + {mainLayout} ); // Check if we're on the secret code page @@ -278,6 +279,20 @@ export function Xyzen({ ); } +/** + * Wrapper component that handles auto-update logic. + * Must be rendered inside QueryClientProvider since it uses useBackendVersion. + */ +function AutoUpdateWrapper({ children }: { children: React.ReactNode }) { + const { isUpdating, targetVersion } = useAutoUpdate(); + + if (isUpdating && targetVersion) { + return ; + } + + return <>{children}; +} + const LOADING_MESSAGES = [ "我要加广告,老板说不行", "「懒」是第一生产力", diff --git a/web/src/app/chat/spatial/FocusedView.tsx b/web/src/app/chat/spatial/FocusedView.tsx index 85422945..bd198266 100644 --- a/web/src/app/chat/spatial/FocusedView.tsx +++ b/web/src/app/chat/spatial/FocusedView.tsx @@ -32,7 +32,7 @@ export function FocusedView({ const listContainerRef = useRef(null); const t = useTranslation().t; - const { activateChannelForAgent } = useXyzen(); + const { activateChannelForAgent, reorderAgents } = useXyzen(); // Convert AgentData to Agent type for AgentList component const agentsForList: Agent[] = useMemo( @@ -119,6 +119,25 @@ export function FocusedView({ [agentDataMap, onDeleteAgent], ); + // Handle reorder - map node IDs back to real agent IDs + const handleReorder = useCallback( + async (nodeIds: string[]) => { + // Convert node IDs to actual agent IDs + const agentIds = nodeIds + .map((nodeId) => agentDataMap.get(nodeId)?.agentId) + .filter((id): id is string => !!id); + + if (agentIds.length > 0) { + try { + await reorderAgents(agentIds); + } catch (error) { + console.error("Failed to reorder agents:", error); + } + } + }, + [agentDataMap, reorderAgents], + ); + // Activate the channel for the selected agent useEffect(() => { if (agent.agentId) { @@ -228,12 +247,14 @@ export function FocusedView({ diff --git a/web/src/components/agents/AgentList.tsx b/web/src/components/agents/AgentList.tsx index 3fff480c..b33962a3 100644 --- a/web/src/components/agents/AgentList.tsx +++ b/web/src/components/agents/AgentList.tsx @@ -1,9 +1,28 @@ "use client"; import type { Agent } from "@/types/agents"; +import { + DndContext, + type DragEndEvent, + DragOverlay, + type DragStartEvent, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { motion, type Variants } from "framer-motion"; -import React from "react"; -import { AgentListItem } from "./AgentListItem"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AgentListItem, type DragHandleProps } from "./AgentListItem"; // Container animation variants for detailed variant const containerVariants: Variants = { @@ -17,10 +36,99 @@ const containerVariants: Variants = { }, }; +// Sortable wrapper for AgentListItem +interface SortableItemProps { + agent: Agent; + variant: "detailed" | "compact"; + // Detailed variant props + isMarketplacePublished?: boolean; + lastConversationTime?: string; + // Compact variant props + isSelected?: boolean; + status?: "idle" | "busy"; + role?: string; + // Shared props + onClick?: (agent: Agent) => void; + onEdit?: (agent: Agent) => void; + onDelete?: (agent: Agent) => void; +} + +const SortableItem: React.FC = ({ + agent, + variant, + isMarketplacePublished, + lastConversationTime, + isSelected, + status, + role, + onClick, + onEdit, + onDelete, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: agent.id }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const dragHandleProps: DragHandleProps = { + attributes, + listeners: listeners ?? {}, + }; + + if (variant === "detailed") { + return ( + + ); + } + + return ( + + ); +}; + // Base props for both variants interface AgentListBaseProps { agents: Agent[]; onAgentClick?: (agent: Agent) => void; + // Sorting support + sortable?: boolean; + onReorder?: (agentIds: string[]) => void; } // Props for detailed variant @@ -53,33 +161,188 @@ interface CompactAgentListProps extends AgentListBaseProps { export type AgentListProps = DetailedAgentListProps | CompactAgentListProps; export const AgentList: React.FC = (props) => { - const { agents, variant, onAgentClick } = props; + const { agents, variant, onAgentClick, sortable = false, onReorder } = props; + + // Local state for drag ordering + const [items, setItems] = useState(agents); + const [activeId, setActiveId] = useState(null); + + // Track whether user is actively dragging to prevent sync during drag + const isDraggingRef = useRef(false); + + // Sync items when agents change (membership or order) - but not during drag + useEffect(() => { + // Don't sync during active drag operations + if (isDraggingRef.current) return; + + // Compare as sets to detect membership changes + const agentIdSet = new Set(agents.map((a) => a.id)); + const itemIdSet = new Set(items.map((a) => a.id)); + const membershipChanged = + agentIdSet.size !== itemIdSet.size || + ![...agentIdSet].every((id) => itemIdSet.has(id)); + + if (membershipChanged) { + // Agents added or removed - reset to props + setItems(agents); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- `items` excluded intentionally to prevent infinite loop + }, [agents]); + + const displayAgents = sortable ? items : agents; + const activeAgent = activeId + ? displayAgents.find((a) => a.id === activeId) + : null; + + // Sensors for mouse and touch + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + isDraggingRef.current = true; + setActiveId(event.active.id as string); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + isDraggingRef.current = false; + setActiveId(null); + + if (over && active.id !== over.id) { + setItems((currentItems) => { + const oldIndex = currentItems.findIndex((a) => a.id === active.id); + const newIndex = currentItems.findIndex((a) => a.id === over.id); + const newItems = arrayMove(currentItems, oldIndex, newIndex); + // Call onReorder after state update via setTimeout to avoid render conflicts + setTimeout(() => { + onReorder?.(newItems.map((a) => a.id)); + }, 0); + return newItems; + }); + } + }, + [onReorder], + ); + + const handleDragCancel = useCallback(() => { + isDraggingRef.current = false; + setActiveId(null); + }, []); + + // Render overlay item + const renderOverlayItem = () => { + if (!activeAgent) return null; + + if (variant === "detailed") { + const { publishedAgentIds, lastConversationTimeByAgent } = + props as DetailedAgentListProps; + return ( + + ); + } + + const { selectedAgentId, getAgentStatus, getAgentRole, publishedAgentIds } = + props as CompactAgentListProps; + return ( + + ); + }; if (variant === "detailed") { const { publishedAgentIds, lastConversationTimeByAgent, onEdit, onDelete } = props as DetailedAgentListProps; - return ( + const content = ( - {agents.map((agent) => ( - - ))} + {displayAgents.map((agent) => + sortable ? ( + + ) : ( + + ), + )} ); + + if (sortable) { + return ( + + a.id)} + strategy={verticalListSortingStrategy} + > + {content} + + // NOTE: Render DragOverlay into document.body to avoid positioning + bugs when // this list is inside a transformed/animated container + (e.g. framer-motion). // CSS transforms create a new containing block, + which can cause @dnd-kit’s // fixed-position overlay to calculate + coordinates relative to that container // (often showing the dragged + item jumping to the bottom). + {createPortal( + {renderOverlayItem()}, + document.body, + )} + + ); + } + + return content; } // Compact variant @@ -92,24 +355,70 @@ export const AgentList: React.FC = (props) => { onDelete, } = props as CompactAgentListProps; - return ( + const content = (
- {agents.map((agent) => ( - - ))} + {displayAgents.map((agent) => + sortable ? ( + + ) : ( + + ), + )}
); + + if (sortable) { + return ( + + a.id)} + strategy={verticalListSortingStrategy} + > + {content} + + // NOTE: Render DragOverlay into document.body to avoid positioning bugs + when // this list is inside a transformed/animated container (e.g. + framer-motion). // CSS transforms create a new containing block, which + can cause @dnd-kit’s // fixed-position overlay to calculate coordinates + relative to that container // (often showing the dragged item jumping to + the bottom). + {createPortal( + {renderOverlayItem()}, + document.body, + )} + + ); + } + + return content; }; export default AgentList; diff --git a/web/src/components/agents/AgentListItem.tsx b/web/src/components/agents/AgentListItem.tsx index 8a8652a4..68efa8a6 100644 --- a/web/src/components/agents/AgentListItem.tsx +++ b/web/src/components/agents/AgentListItem.tsx @@ -139,10 +139,21 @@ const ContextMenu: React.FC = ({ ); }; +// Drag handle props type +export interface DragHandleProps { + attributes: React.HTMLAttributes; + listeners: React.DOMAttributes; +} + // Shared props for both variants interface AgentListItemBaseProps { agent: Agent; onClick?: (agent: Agent) => void; + // Drag and drop support + isDragging?: boolean; + dragHandleProps?: DragHandleProps; + style?: React.CSSProperties; + setNodeRef?: (node: HTMLElement | null) => void; } // Props specific to detailed variant @@ -182,6 +193,10 @@ const DetailedAgentListItem: React.FC = ({ onClick, onEdit, onDelete, + isDragging = false, + dragHandleProps, + style, + setNodeRef, }) => { const { t } = useTranslation(); const [contextMenu, setContextMenu] = useState<{ @@ -236,15 +251,119 @@ const DetailedAgentListItem: React.FC = ({ }); }; + // When sortable (dragHandleProps provided), use simple div to avoid motion conflicts + if (dragHandleProps) { + return ( + <> +
{ + if (isLongPress.current || isDragging) return; + onClick?.(agent); + }} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} + className={` + group relative flex cursor-pointer items-start gap-4 rounded-sm border p-3 + border-neutral-200 bg-white hover:bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60 + ${agent.id === "default-chat" ? "select-none" : ""} + ${isDragging ? "shadow-xl z-50 cursor-grabbing" : ""} + `} + {...dragHandleProps.attributes} + {...dragHandleProps.listeners} + > + {/* Avatar */} +
+ {agent.name} +
+ + {/* Content */} +
+
+

+ {agent.name} +

+ + {/* Marketplace published badge */} + {isMarketplacePublished && ( + + + + + + + + + + {t("agents.badges.marketplace", { + defaultValue: "Published to Marketplace", + })} + + + )} +
+ +

+ {agent.description} +

+ + {/* Last conversation time */} + {lastConversationTime && ( +

+ {formatTime(lastConversationTime)} +

+ )} +
+
+ + {/* Context menu */} + {contextMenu && + createPortal( + onEdit?.(agent)} + onDelete={() => onDelete?.(agent)} + onClose={() => setContextMenu(null)} + isDefaultAgent={isDefaultAgent} + isMarketplacePublished={isMarketplacePublished} + />, + document.body, + )} + + ); + } + + // Non-sortable: use motion.div with animations return ( <> { - if (isLongPress.current) return; + if (isLongPress.current || isDragging) return; onClick?.(agent); }} onContextMenu={handleContextMenu} @@ -252,10 +371,11 @@ const DetailedAgentListItem: React.FC = ({ onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} className={` - group relative flex cursor-pointer items-start gap-4 rounded-sm border p-3 - border-neutral-200 bg-white hover:bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60 - ${agent.id === "default-chat" ? "select-none" : ""} - `} + group relative flex cursor-pointer items-start gap-4 rounded-sm border p-3 + border-neutral-200 bg-white hover:bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60 + ${agent.id === "default-chat" ? "select-none" : ""} + ${isDragging ? "shadow-xl z-50 cursor-grabbing" : ""} + `} > {/* Avatar */}
@@ -350,6 +470,10 @@ const CompactAgentListItem: React.FC = ({ onClick, onEdit, onDelete, + isDragging = false, + dragHandleProps, + style, + setNodeRef, }) => { const [contextMenu, setContextMenu] = useState<{ x: number; @@ -403,21 +527,36 @@ const CompactAgentListItem: React.FC = ({ return ( <> - +
{/* Context menu - rendered via portal to escape overflow:hidden containers */} {contextMenu && diff --git a/web/src/components/agents/index.ts b/web/src/components/agents/index.ts index e71b8a02..24b84b19 100644 --- a/web/src/components/agents/index.ts +++ b/web/src/components/agents/index.ts @@ -1,2 +1,6 @@ export { AgentList, type AgentListProps } from "./AgentList"; -export { AgentListItem, type AgentListItemProps } from "./AgentListItem"; +export { + AgentListItem, + type AgentListItemProps, + type DragHandleProps, +} from "./AgentListItem"; diff --git a/web/src/components/features/UpdateOverlay.tsx b/web/src/components/features/UpdateOverlay.tsx new file mode 100644 index 00000000..6bd544d4 --- /dev/null +++ b/web/src/components/features/UpdateOverlay.tsx @@ -0,0 +1,24 @@ +import { LoadingSpinner } from "@/components/base/LoadingSpinner"; +import { useTranslation } from "react-i18next"; + +interface UpdateOverlayProps { + /** The version being updated to */ + targetVersion: string; +} + +/** + * Fullscreen overlay shown during auto-update process. + * Displays a spinner and "Updating to vX.X.X..." message. + */ +export function UpdateOverlay({ targetVersion }: UpdateOverlayProps) { + const { t } = useTranslation(); + + return ( +
+ +

+ {t("app.update.updating", { version: targetVersion })} +

+
+ ); +} diff --git a/web/src/components/features/index.ts b/web/src/components/features/index.ts index 109fae57..93665a67 100644 --- a/web/src/components/features/index.ts +++ b/web/src/components/features/index.ts @@ -6,3 +6,4 @@ export { CenteredInput } from "./CenteredInput"; export { FileUploadButton } from "./FileUploadButton"; export { FileUploadThumbnail } from "./FileUploadThumbnail"; export { FileUploadPreview } from "./FileUploadPreview"; +export { UpdateOverlay } from "./UpdateOverlay"; diff --git a/web/src/components/layouts/XyzenAgent.tsx b/web/src/components/layouts/XyzenAgent.tsx index 94a536b8..f4dfebe3 100644 --- a/web/src/components/layouts/XyzenAgent.tsx +++ b/web/src/components/layouts/XyzenAgent.tsx @@ -4,7 +4,7 @@ import { TooltipProvider } from "@/components/animate-ui/components/animate/tool import { AgentList } from "@/components/agents"; import { useAuth } from "@/hooks/useAuth"; import { motion } from "framer-motion"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import AddAgentModal from "@/components/modals/AddAgentModal"; @@ -35,6 +35,7 @@ export default function XyzenAgent({ createDefaultChannel, deleteAgent, updateAgentAvatar, + reorderAgents, chatHistory, channels, @@ -148,6 +149,17 @@ export default function XyzenAgent({ setConfirmModalOpen(true); }; + const handleReorder = useCallback( + async (agentIds: string[]) => { + try { + await reorderAgents(agentIds); + } catch (error) { + console.error("Failed to reorder agents:", error); + } + }, + [reorderAgents], + ); + // Find system agents within the user's agents list using tags const filteredSystemAgents = agents.filter((agent) => { if (!agent.tags) return false; @@ -179,11 +191,13 @@ export default function XyzenAgent({ {/* Memory Search - Disabled: pending RAG/pgvector implementation */} diff --git a/web/src/components/layouts/components/TierInfoModal.tsx b/web/src/components/layouts/components/TierInfoModal.tsx index 1403c8f6..ffcae7b6 100644 --- a/web/src/components/layouts/components/TierInfoModal.tsx +++ b/web/src/components/layouts/components/TierInfoModal.tsx @@ -48,9 +48,9 @@ interface TierInfo { rate: string; speed: number; // 0-100 reasoning: number; // 0-100 - speedLabel: string; - reasoningLabel: string; - features: string[]; + speedLabelKey: string; + reasoningLabelKey: string; + featureKeys: string[]; models: ModelInfo[]; gradient?: string; accentColor: string; @@ -79,11 +79,11 @@ const TIERS: TierInfo[] = [ rate: "0.0x", speed: 95, reasoning: 35, - speedLabel: "极快", - reasoningLabel: "基础", + speedLabelKey: "ultraFast", + reasoningLabelKey: "basic", accentColor: "text-amber-500", buttonStyle: "bg-surface-200", - features: ["快速翻译", "文本摘要", "简单问答"], + featureKeys: ["quickTranslation", "textSummary", "simpleQA"], models: [ { name: "Gemini 2.5 Flash-Lite", provider: "google" }, { name: "Qwen3 30B A3B", provider: "qwen" }, @@ -96,11 +96,11 @@ const TIERS: TierInfo[] = [ rate: "1.0x", speed: 80, reasoning: 75, - speedLabel: "快", - reasoningLabel: "标准", + speedLabelKey: "fast", + reasoningLabelKey: "standard", accentColor: "text-blue-500", buttonStyle: "bg-blue-600 hover:bg-blue-500", - features: ["日常闲聊与助手", "邮件撰写", "知识问答"], + featureKeys: ["dailyChat", "emailWriting", "knowledgeQA"], models: [ { name: "Claude Haiku 4.5", provider: "anthropic" }, { name: "DeepSeek V3.2", provider: "deepseek" }, @@ -114,12 +114,12 @@ const TIERS: TierInfo[] = [ rate: "3.0x", speed: 65, reasoning: 90, - speedLabel: "适中", - reasoningLabel: "优秀", + speedLabelKey: "moderate", + reasoningLabelKey: "excellent", accentColor: "text-violet-500", buttonStyle: "bg-violet-600 hover:bg-violet-500", recommended: true, - features: ["PDF 文档深度分析", "代码编写与调试", "复杂任务规划与拆解"], + featureKeys: ["pdfAnalysis", "codeWriting", "taskPlanning"], models: [ { name: "Claude Sonnet 4.5", provider: "anthropic" }, { name: "Gemini 3 Pro", provider: "google" }, @@ -133,14 +133,14 @@ const TIERS: TierInfo[] = [ rate: "6.8x", speed: 30, reasoning: 100, - speedLabel: "思考中...", - reasoningLabel: "MAX", + speedLabelKey: "thinking", + reasoningLabelKey: "max", accentColor: "text-purple-400", gradient: "from-purple-500/10 to-pink-500/10 dark:from-purple-900/40 dark:to-pink-900/40", buttonStyle: "bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500", - features: ["深度逻辑推理能力", "科研级学术分析", "复杂数学与算法求解"], + featureKeys: ["deepReasoning", "academicAnalysis", "mathSolving"], models: [ { name: "Claude Opus 4.5", provider: "anthropic" }, { name: "GPT-5.2 Pro", provider: "openai" }, @@ -235,7 +235,9 @@ export function TierInfoModal({ open, onOpenChange }: TierInfoModalProps) { {t("app.tierSelector.infoModal.speed")} - {t(tier.speedLabel)} + {t( + `app.tierSelector.speedLabels.${tier.speedLabelKey}`, + )}
@@ -255,7 +257,9 @@ export function TierInfoModal({ open, onOpenChange }: TierInfoModalProps) { {t("app.tierSelector.infoModal.reasoning")} - {t(tier.reasoningLabel)} + {t( + `app.tierSelector.reasoningLabels.${tier.reasoningLabelKey}`, + )}
@@ -271,7 +275,7 @@ export function TierInfoModal({ open, onOpenChange }: TierInfoModalProps) { {/* Features */}
- {tier.features.map((feature, idx) => ( + {tier.featureKeys.map((featureKey, idx) => (
  • - {t(feature)} + + {t(`app.tierSelector.features.${featureKey}`)} +
  • ))}
    diff --git a/web/src/hooks/queries/useSystemQuery.ts b/web/src/hooks/queries/useSystemQuery.ts index 1dfb830f..fe7fc015 100644 --- a/web/src/hooks/queries/useSystemQuery.ts +++ b/web/src/hooks/queries/useSystemQuery.ts @@ -8,6 +8,11 @@ import { systemService } from "@/service/systemService"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "./queryKeys"; +interface UseBackendVersionOptions { + /** Whether to enable the query (default: true) */ + enabled?: boolean; +} + /** * Fetch the backend system version information * @@ -16,12 +21,14 @@ import { queryKeys } from "./queryKeys"; * const { data: version, isLoading, error, refetch } = useBackendVersion(); * ``` */ -export function useBackendVersion() { +export function useBackendVersion(options: UseBackendVersionOptions = {}) { + const { enabled = true } = options; return useQuery({ queryKey: queryKeys.system.version(), queryFn: () => systemService.getVersion(), staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes retry: 1, // Only retry once on failure + enabled, }); } diff --git a/web/src/hooks/useAutoUpdate.ts b/web/src/hooks/useAutoUpdate.ts new file mode 100644 index 00000000..c34914a8 --- /dev/null +++ b/web/src/hooks/useAutoUpdate.ts @@ -0,0 +1,164 @@ +import { useBackendVersion } from "@/hooks/queries"; +import { getFrontendVersion } from "@/types/version"; +import { useCallback, useEffect, useState } from "react"; + +const STORAGE_KEY = "xyzen-update-state"; +const MAX_RETRIES = 3; + +interface UpdateState { + targetVersion: string; + retryCount: number; +} + +interface UseAutoUpdateResult { + /** True during update process (cache clearing, reloading) */ + isUpdating: boolean; + /** The backend version we're updating to, if updating */ + targetVersion: string | null; +} + +/** + * Clears all caches and unregisters service workers + */ +async function clearCachesAndServiceWorkers(): Promise { + // Unregister all service workers + if ("serviceWorker" in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + + // Clear all caches + if ("caches" in window) { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((name) => caches.delete(name))); + } +} + +/** + * Gets the current update state from localStorage + */ +function getUpdateState(): UpdateState | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + return JSON.parse(stored) as UpdateState; + } catch { + return null; + } +} + +/** + * Saves update state to localStorage + */ +function saveUpdateState(state: UpdateState): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Storage unavailable or quota exceeded - proceed without persistence + } +} + +/** + * Clears update state from localStorage + */ +function clearUpdateState(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Storage unavailable - proceed without clearing + } +} + +/** + * Hook that auto-updates the frontend when version mismatches backend. + * + * Flow: + * 1. Fetches backend version after auth succeeds + * 2. Compares with frontend version + * 3. If mismatch: clears caches and reloads (up to MAX_RETRIES times) + * 4. Prevents infinite loops via localStorage retry tracking + * + * @param enabled - Whether to enable auto-update checking (default: true) + * @returns Object with isUpdating state and target version + */ +export function useAutoUpdate(enabled = true): UseAutoUpdateResult { + const [isUpdating, setIsUpdating] = useState(false); + const [targetVersion, setTargetVersion] = useState(null); + + const { + data: backendData, + isLoading, + isError, + } = useBackendVersion({ enabled }); + + const performUpdate = useCallback(async (version: string) => { + setIsUpdating(true); + setTargetVersion(version); + + try { + await clearCachesAndServiceWorkers(); + // Small delay to ensure UI shows the updating state + await new Promise((resolve) => setTimeout(resolve, 500)); + window.location.reload(); + } catch (error) { + console.error("[AutoUpdate] Failed to clear caches:", error); + // Still try to reload even if cache clearing fails + window.location.reload(); + } + }, []); + + useEffect(() => { + // Skip if disabled, still loading, or errored + if (!enabled || isLoading || isError || !backendData) { + return; + } + + const frontendVersion = getFrontendVersion().version; + const backendVersion = backendData.version; + + // Versions match - clear any update state and continue normally + if (frontendVersion === backendVersion) { + clearUpdateState(); + return; + } + + // Version mismatch - check retry count + const existingState = getUpdateState(); + + // If we're targeting the same version, increment retry count + // Otherwise, this is a new version - start fresh + const retryCount = + existingState?.targetVersion === backendVersion + ? existingState.retryCount + 1 + : 1; + + // Exceeded max retries - give up to prevent infinite loop + if (retryCount > MAX_RETRIES) { + console.warn( + `[AutoUpdate] Failed to update to ${backendVersion} after ${MAX_RETRIES} attempts. ` + + `Frontend: ${frontendVersion}, Backend: ${backendVersion}`, + ); + clearUpdateState(); + return; + } + + // Save state before reload + saveUpdateState({ + targetVersion: backendVersion, + retryCount, + }); + + console.info( + `[AutoUpdate] Version mismatch detected. ` + + `Frontend: ${frontendVersion}, Backend: ${backendVersion}. ` + + `Attempt ${retryCount}/${MAX_RETRIES}.`, + ); + + void performUpdate(backendVersion); + }, [enabled, backendData, isLoading, isError, performUpdate]); + + return { + isUpdating, + targetVersion, + }; +} diff --git a/web/src/i18n/locales/en/app.json b/web/src/i18n/locales/en/app.json index 84e27e08..2931f66a 100644 --- a/web/src/i18n/locales/en/app.json +++ b/web/src/i18n/locales/en/app.json @@ -1,5 +1,8 @@ { "title": "Xyzen", + "update": { + "updating": "Updating to v{{version}}..." + }, "pwa": { "installTitle": "Install App", "installDescription": "Click the install icon in your address bar for a better experience." @@ -86,6 +89,7 @@ "free": "0.0x", "rateFormat": "{{rate}}x", "consumption": "Consumption", + "multiplier": "Multiplier", "infoModal": { "title": "Select Intelligence Mode", "subtitle": "Flexibly allocate AI computing power based on task difficulty", @@ -95,6 +99,32 @@ "imageGen": "Image Generation", "disclaimer": "* Models listed are for capability reference only and do not guarantee the actual model used." }, + "speedLabels": { + "ultraFast": "Ultra Fast", + "fast": "Fast", + "moderate": "Moderate", + "thinking": "Thinking..." + }, + "reasoningLabels": { + "basic": "Basic", + "standard": "Standard", + "excellent": "Excellent", + "max": "MAX" + }, + "features": { + "quickTranslation": "Quick Translation", + "textSummary": "Text Summary", + "simpleQA": "Simple Q&A", + "dailyChat": "Daily Chat & Assistant", + "emailWriting": "Email Writing", + "knowledgeQA": "Knowledge Q&A", + "pdfAnalysis": "Deep PDF Analysis", + "codeWriting": "Code Writing & Debugging", + "taskPlanning": "Complex Task Planning", + "deepReasoning": "Deep Logic Reasoning", + "academicAnalysis": "Research-Level Analysis", + "mathSolving": "Complex Math & Algorithms" + }, "tiers": { "ultra": { "name": "Xyzen Ultra", diff --git a/web/src/i18n/locales/en/common.json b/web/src/i18n/locales/en/common.json index 3e09bd65..ef3a991f 100644 --- a/web/src/i18n/locales/en/common.json +++ b/web/src/i18n/locales/en/common.json @@ -3,6 +3,7 @@ "cancel": "Cancel", "loading": "Loading...", "delete": "Delete", + "recommended": "Recommended", "close": "Close", "dismiss": "Dismiss" } diff --git a/web/src/i18n/locales/ja/app.json b/web/src/i18n/locales/ja/app.json index 037199b6..0e882dcb 100644 --- a/web/src/i18n/locales/ja/app.json +++ b/web/src/i18n/locales/ja/app.json @@ -1,5 +1,8 @@ { "title": "Xyzen", + "update": { + "updating": "v{{version}}に更新中..." + }, "pwa": { "installTitle": "アプリをインストール", "installDescription": "より良い体験のために、アドレスバーのインストールアイコンをクリックしてください。" @@ -78,6 +81,7 @@ "free": "0.0x", "rateFormat": "{{rate}}x", "consumption": "消費", + "multiplier": "倍率", "infoModal": { "title": "インテリジェンスモードを選択", "subtitle": "タスクの難易度に応じてAI計算能力を柔軟に割り当て", @@ -87,6 +91,32 @@ "imageGen": "画像生成", "disclaimer": "* このリストはモデル能力の参考用であり、実際に使用されるモデルを保証するものではありません。" }, + "speedLabels": { + "ultraFast": "超高速", + "fast": "高速", + "moderate": "標準", + "thinking": "思考中..." + }, + "reasoningLabels": { + "basic": "基本", + "standard": "標準", + "excellent": "優秀", + "max": "MAX" + }, + "features": { + "quickTranslation": "クイック翻訳", + "textSummary": "テキスト要約", + "simpleQA": "シンプルなQ&A", + "dailyChat": "日常会話とアシスタント", + "emailWriting": "メール作成", + "knowledgeQA": "ナレッジQ&A", + "pdfAnalysis": "PDF詳細分析", + "codeWriting": "コード作成とデバッグ", + "taskPlanning": "複雑なタスク計画", + "deepReasoning": "深い論理的推論", + "academicAnalysis": "研究レベルの分析", + "mathSolving": "複雑な数学とアルゴリズム" + }, "tiers": { "ultra": { "name": "Xyzen ウルトラ", diff --git a/web/src/i18n/locales/ja/common.json b/web/src/i18n/locales/ja/common.json index a92b81df..76bef2e7 100644 --- a/web/src/i18n/locales/ja/common.json +++ b/web/src/i18n/locales/ja/common.json @@ -3,6 +3,7 @@ "cancel": "キャンセル", "loading": "読み込み中...", "delete": "削除", + "recommended": "おすすめ", "close": "閉じる", "dismiss": "閉じる" } diff --git a/web/src/i18n/locales/zh/app.json b/web/src/i18n/locales/zh/app.json index b1fa8987..bd759ab6 100644 --- a/web/src/i18n/locales/zh/app.json +++ b/web/src/i18n/locales/zh/app.json @@ -1,5 +1,8 @@ { "title": "Xyzen", + "update": { + "updating": "正在更新到 v{{version}}..." + }, "pwa": { "installTitle": "安装应用", "installDescription": "点击地址栏的安装图标,获得更好的使用体验。" @@ -86,6 +89,7 @@ "free": "0.0x", "rateFormat": "{{rate}}x", "consumption": "消耗", + "multiplier": "倍率", "infoModal": { "title": "选择智能算力模式", "subtitle": "根据任务难度,灵活调配 AI 算力消耗", @@ -95,6 +99,32 @@ "imageGen": "图像生成", "disclaimer": "* 此列表仅作为模型能力参考,不代表实际调用的具体模型。" }, + "speedLabels": { + "ultraFast": "极快", + "fast": "快", + "moderate": "适中", + "thinking": "思考中..." + }, + "reasoningLabels": { + "basic": "基础", + "standard": "标准", + "excellent": "优秀", + "max": "MAX" + }, + "features": { + "quickTranslation": "快速翻译", + "textSummary": "文本摘要", + "simpleQA": "简单问答", + "dailyChat": "日常闲聊与助手", + "emailWriting": "邮件撰写", + "knowledgeQA": "知识问答", + "pdfAnalysis": "PDF 文档深度分析", + "codeWriting": "代码编写与调试", + "taskPlanning": "复杂任务规划与拆解", + "deepReasoning": "深度逻辑推理能力", + "academicAnalysis": "科研级学术分析", + "mathSolving": "复杂数学与算法求解" + }, "tiers": { "ultra": { "name": "Xyzen Ultra", diff --git a/web/src/i18n/locales/zh/common.json b/web/src/i18n/locales/zh/common.json index 9eba7d3d..e064f7be 100644 --- a/web/src/i18n/locales/zh/common.json +++ b/web/src/i18n/locales/zh/common.json @@ -3,6 +3,7 @@ "cancel": "取消", "loading": "加载中...", "delete": "删除", + "recommended": "推荐", "close": "关闭", "dismiss": "忽略" } diff --git a/web/src/store/slices/agentSlice.ts b/web/src/store/slices/agentSlice.ts index cbfa866d..073d41a9 100644 --- a/web/src/store/slices/agentSlice.ts +++ b/web/src/store/slices/agentSlice.ts @@ -38,6 +38,7 @@ export interface AgentSlice { fetchAgentStats: () => Promise; fetchDailyActivity: () => Promise; incrementLocalAgentMessageCount: (agentId: string) => void; + reorderAgents: (agentIds: string[]) => Promise; isCreatingAgent: boolean; createAgent: (agent: Omit) => Promise; @@ -308,6 +309,55 @@ export const createAgentSlice: StateCreator< }); }, + reorderAgents: async (agentIds: string[]) => { + // Store previous order for rollback + const previousAgents = [...get().agents]; + + // Optimistic update: reorder agents locally based on agentIds + set((state) => { + const agentMap = new Map(state.agents.map((a) => [a.id, a])); + const reorderedAgents: AgentWithLayout[] = []; + + for (const id of agentIds) { + const agent = agentMap.get(id); + if (agent) { + reorderedAgents.push(agent); + } + } + + // Add any agents not in the new order (shouldn't happen normally) + for (const agent of state.agents) { + if (!agentIds.includes(agent.id)) { + reorderedAgents.push(agent); + } + } + + state.agents = reorderedAgents; + }); + + // Persist to backend + try { + const response = await fetch( + `${get().backendUrl}/xyzen/api/v1/agents/reorder`, + { + method: "PUT", + headers: createAuthHeaders(), + body: JSON.stringify({ agent_ids: agentIds }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to reorder agents: ${errorText}`); + } + } catch (error) { + // Rollback on error + console.error("Failed to reorder agents:", error); + set({ agents: previousAgents }); + throw error; + } + }, + createAgent: async (agent) => { const { isCreatingAgent } = get(); if (isCreatingAgent) { diff --git a/web/src/types/agents.ts b/web/src/types/agents.ts index dd3570ca..b9404409 100644 --- a/web/src/types/agents.ts +++ b/web/src/types/agents.ts @@ -113,6 +113,7 @@ export interface Agent { user_id: string; created_at: string; updated_at: string; + sort_order?: number; // Regular agent properties prompt?: string; diff --git a/web/yarn.lock b/web/yarn.lock index 3e77e3a6..b630d66e 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1419,6 +1419,19 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + "@dnd-kit/utilities@npm:^3.2.2": version: 3.2.2 resolution: "@dnd-kit/utilities@npm:3.2.2" @@ -5343,6 +5356,7 @@ __metadata: "@ariakit/react": "npm:^0.4.20" "@dnd-kit/core": "npm:^6.3.1" "@dnd-kit/modifiers": "npm:^9.0.0" + "@dnd-kit/sortable": "npm:^10.0.0" "@emoji-mart/data": "npm:1.2.1" "@emotion/is-prop-valid": "npm:^1.3.1" "@eslint/js": "npm:^9.30.1"