Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions service/app/api/v1/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions service/app/models/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 28 additions & 3 deletions service/app/repos/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -259,7 +260,7 @@ export function Xyzen({
<AuthErrorScreen onRetry={handleRetry} variant="fullscreen" />
)
) : (
<>{mainLayout}</>
<AutoUpdateWrapper>{mainLayout}</AutoUpdateWrapper>
);

// Check if we're on the secret code page
Expand All @@ -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 <UpdateOverlay targetVersion={targetVersion} />;
}

return <>{children}</>;
}

const LOADING_MESSAGES = [
"我要加广告,老板说不行",
"「懒」是第一生产力",
Expand Down
23 changes: 22 additions & 1 deletion web/src/app/chat/spatial/FocusedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function FocusedView({
const listContainerRef = useRef<HTMLDivElement | null>(null);
const t = useTranslation().t;

const { activateChannelForAgent } = useXyzen();
const { activateChannelForAgent, reorderAgents } = useXyzen();

// Convert AgentData to Agent type for AgentList component
const agentsForList: Agent[] = useMemo(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -228,12 +247,14 @@ export function FocusedView({
<AgentList
agents={agentsForList}
variant="compact"
sortable={true}
selectedAgentId={selectedAgentId}
getAgentStatus={getAgentStatus}
getAgentRole={getAgentRole}
onAgentClick={handleAgentClick}
onEdit={onEditAgent ? handleEditClick : undefined}
onDelete={onDeleteAgent ? handleDeleteClick : undefined}
onReorder={handleReorder}
/>
</div>
</motion.div>
Expand Down
Loading