diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..06b69cc3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,41 @@ +# Xyzen Roadmap + +This roadmap outlines the development stages of the Xyzen AI Laboratory Server. It serves as a high-level guide for tracking major feature implementation and system architecture evolution. + +## Phase 1: Core Consolidation (Current) +Focus: Cleaning up the legacy structure, unifying models, and establishing best practices. + +- [ ] **Unified Agent System**: Complete the migration to a single `Agent` model for both regular and graph-based agents. +- [ ] **Idiomatic FastAPI Refactor**: Implement Dependency Injection (DI) for resource fetching and authorization across all API handlers. +- [ ] **Frontend State Management**: Finalize the migration of all server-side state to TanStack Query and clean up Zustand slices. +- [ ] **Error Handling**: Implement a global exception handler and unified error code system across backend and frontend. + +## Phase 2: Agent Intelligence & Workflows +Focus: Expanding the capabilities of the agent engine. + +- [ ] **LangGraph Orchestration**: Full integration of LangGraph for complex, stateful multi-agent workflows. +- [ ] **Advanced MCP Integration**: Dynamic discovery and management of Model Context Protocol (MCP) servers. +- [ ] **Tool Confirmation UI**: A robust interface for users to inspect and approve agent tool calls before execution. +- [ ] **Streaming Optimization**: Enhancing WebSocket performance for real-time agent thought process visualization. + +## Phase 3: Knowledge Base & RAG +Focus: Providing agents with memory and specialized knowledge. + +- [ ] **Vector Database Support**: Integration with PostgreSQL (pgvector) or a dedicated vector DB for RAG capabilities. +- [ ] **File Processing Pipeline**: Automated ingestion and chunking of documents (PDF, Markdown, Code). +- [ ] **Knowledge Graphs**: Exploring graph-based retrieval to complement vector search. + +## Phase 4: Infrastructure & Scale +Focus: Making Xyzen production-ready. + +- [ ] **Multi-Provider Support**: Seamless switching between OpenAI, Anthropic, Gemini, and local models (Ollama). +- [ ] **User Usage Tracking**: Monitoring token consumption and execution costs. +- [ ] **Deployment Templates**: Easy-to-use Docker Compose and Kubernetes configurations for various environments. + +--- + +## Done ✅ +- [x] **Project Foundation**: Initial FastAPI + SQLModel backend setup. +- [x] **Frontend Shell**: React + Tailwind + shadcn/ui dashboard layout. +- [x] **Basic Agent Chat**: Functional WebSocket-based chat with regular agents. +- [x] **Dockerized Environment**: Fully containerized development setup with PostgreSQL and MinIO. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..e56ae538 --- /dev/null +++ b/TODO.md @@ -0,0 +1,30 @@ +# Xyzen Task Tracker (TODO) + +This file tracks tactical, short-term tasks and immediate technical debt. For high-level milestones, see `ROADMAP.md`. + +## 🛠️ Immediate Priorities +- [ ] **Dependency Injection Refactor**: Move `auth_service` and `agent` fetching into FastAPI dependencies in `agents.py`. +- [ ] **Agent Repository Cleanup**: Remove legacy methods in `AgentRepository` that supported the old unified agent service (e.g., `get_agent_with_mcp_servers`). +- [ ] **Frontend Type Alignment**: Update `web/src/types/agents.ts` to match the simplified `AgentReadWithDetails` model from the backend. + +## 🚀 Backend Tasks +- [ ] **Pydantic V2 Migration**: Verify all SQLModels and Schemas are utilizing Pydantic V2 features optimally. +- [ ] **Logging Middleware**: Add request/response logging for better debugging in the Docker environment. +- [ ] **Auth Error Mapping**: Finish mapping `ErrCodeError` to appropriate FastAPI `HTTPException` responses in `middleware/auth`. + +## 🎨 Frontend Tasks +- [ ] **TanStack Query Refactor**: Move agent fetching from `agentSlice.ts` (Zustand) to a dedicated hook in `hooks/queries/useAgents.ts`. +- [ ] **AddAgentModal UI**: Allow users to select a specific LLM Provider during the creation of a regular agent. +- [ ] **Loading States**: Add skeleton loaders to the `AgentExplorer` sidebar. + +## 🧪 Testing & Quality +- [ ] **Backend Unit Tests**: Add test cases for the newly unified `get_agent` endpoint. +- [ ] **Frontend Linting**: Fix existing `yarn lint` warnings in `web/src/components/layouts/ChatToolbar.tsx`. +- [ ] **API Documentation**: Update docstrings in `handler/api/v1/` to ensure Swagger UI is accurate. + +## ✅ Completed Tasks +- [x] **Agent Unification**: Unified `get_agent` endpoint to return `AgentReadWithDetails` and removed `UnifiedAgentRead` dependencies. +- [x] **Default Agent Cloning**: Implemented logic in `SystemAgentManager` to clone system agents as user-owned default agents. +- [x] **Tag-based Identification**: Updated frontend (Chat, Agent List, Avatars) to identify default agents via tags (e.g., `default_chat`) rather than hardcoded UUIDs. +- [x] **Workshop Removal**: Completely removed the legacy "Workshop" feature from both backend and frontend to simplify the core agent experience. +- [x] **Policy Update**: Updated `AgentPolicy` to allow reading of system-scoped reference agents. diff --git a/service/app/main.py b/service/app/main.py index 0bd0686b..854e80de 100644 --- a/service/app/main.py +++ b/service/app/main.py @@ -26,7 +26,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await initialize_providers_on_startup() - # Initialize system agents (Chat and Workshop agents) + # Initialize system agents (Chat agent) from core.system_agent import SystemAgentManager from infra.database import AsyncSessionLocal diff --git a/service/core/auth/policies/agent_policy.py b/service/core/auth/policies/agent_policy.py index 7a2572b6..8f274bb2 100644 --- a/service/core/auth/policies/agent_policy.py +++ b/service/core/auth/policies/agent_policy.py @@ -3,7 +3,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from common.code import ErrCode -from models.agent import Agent +from models.agent import Agent, AgentScope from repos.agent import AgentRepository from .resource_policy import ResourcePolicyBase @@ -17,7 +17,9 @@ async def authorize_read(self, resource_id: UUID, user_id: str) -> Agent: agent = await self.agent_repo.get_agent_by_id(resource_id) if not agent: raise ErrCode.AGENT_NOT_FOUND.with_messages(f"Agent {resource_id} not found") - if agent.user_id == user_id: + + # System agents are readable by everyone, user agents only by owner + if agent.scope == AgentScope.SYSTEM or agent.user_id == user_id: return agent raise ErrCode.AGENT_ACCESS_DENIED.with_messages(f"User {user_id} can not access agent {resource_id}") diff --git a/service/core/chat/langchain.py b/service/core/chat/langchain.py index 758a7ed0..08c1c157 100644 --- a/service/core/chat/langchain.py +++ b/service/core/chat/langchain.py @@ -140,8 +140,6 @@ async def _load_db_history(db: AsyncSession, topic: TopicModel) -> list[Any]: for message in messages: role = (message.role or "").lower() content = message.content or "" - if not content: - continue if role == "user": # Check if message has file attachments try: @@ -153,10 +151,6 @@ async def _load_db_history(db: AsyncSession, topic: TopicModel) -> list[Any]: # Multimodal message: combine text and file content multimodal_content: list[dict[str, Any]] = [{"type": "text", "text": content}] multimodal_content.extend(file_contents) - # Debug: Log the exact content being sent - logger.debug( - f"Multimodal content types for message {message.id}: {[item.get('type') for item in multimodal_content]}" - ) for idx, item in enumerate(file_contents): item_type = item.get("type") if item_type == "image_url": @@ -183,6 +177,7 @@ async def _load_db_history(db: AsyncSession, topic: TopicModel) -> list[Any]: try: file_contents = await process_message_files(db, message.id) if file_contents: + logger.debug("Successfully processed files for message") # Multimodal assistant message # Combine text content with file content multimodal_content: list[dict[str, Any]] = [] @@ -234,6 +229,7 @@ async def _load_db_history(db: AsyncSession, topic: TopicModel) -> list[Any]: else: # Skip unknown/tool roles for now continue + logger.info(f"Length of history: {len(history)}") return history except Exception as e: logger.warning(f"Failed to load DB chat history for topic {getattr(topic, 'id', None)}: {e}") @@ -311,7 +307,7 @@ async def get_ai_response_stream_langchain_legacy( model_name = agent.model # Get system prompt with MCP awareness - system_prompt = await build_system_prompt(db, agent) + system_prompt = await build_system_prompt(db, agent, model_name) yield {"type": ChatEventType.PROCESSING, "data": {"status": ProcessingStatus.PREPARING_REQUEST}} @@ -366,10 +362,7 @@ async def get_ai_response_stream_langchain_legacy( else: history_messages.append(system_msg) - async for chunk in langchain_agent.astream( - {"messages": history_messages}, - stream_mode=["updates", "messages"], - ): + async for chunk in langchain_agent.astream({"messages": history_messages}, stream_mode=["updates", "messages"]): # chunk is a tuple: (stream_mode, data) try: mode, data = chunk @@ -398,7 +391,7 @@ async def get_ai_response_stream_langchain_legacy( continue last_message = messages[-1] - logger.debug("Last message in step '%s': %r", step_name, last_message) + # logger.debug("Last message in step '%s': %r", step_name, last_message) # Check if this is a tool call request (from LLM node) if hasattr(last_message, "tool_calls") and last_message.tool_calls: diff --git a/service/core/chat/messages.py b/service/core/chat/messages.py index 8a1d984b..36202f5d 100644 --- a/service/core/chat/messages.py +++ b/service/core/chat/messages.py @@ -35,7 +35,7 @@ async def agent_has_dynamic_mcp(db: AsyncSession, agent: Optional[Agent]) -> boo return any(s.name == "DynamicMCPServer" or "dynamic_mcp_server" in (s.url or "").lower() for s in mcp_servers) -async def build_system_prompt(db: AsyncSession, agent: Optional[Agent]) -> str: +async def build_system_prompt(db: AsyncSession, agent: Optional[Agent], model_name: str | None) -> str: """ Build system prompt for the agent. @@ -54,11 +54,14 @@ async def build_system_prompt(db: AsyncSession, agent: Optional[Agent]) -> str: if agent and agent.prompt: base_prompt = agent.prompt - formatting_instructions = """ -Please format your output using Markdown. -When writing code, use triple backticks with the language identifier (e.g. ```python). -If you generate HTML that should be previewed, use ```html. -If you generate ECharts JSON options, use ```echart. -""" + if model_name and "image" in model_name: + formatting_instructions = "" + else: + formatting_instructions = """ + Please format your output using Markdown. + When writing code, use triple backticks with the language identifier (e.g. ```python). + If you generate HTML that should be previewed, use ```html. + If you generate ECharts JSON options, use ```echart. + """ return f"{base_prompt}\n{formatting_instructions}" diff --git a/service/core/system_agent.py b/service/core/system_agent.py index dc851882..526e0a47 100644 --- a/service/core/system_agent.py +++ b/service/core/system_agent.py @@ -2,13 +2,14 @@ System Agent Manager Manages system-wide default agents that are available to all users. -Creates and maintains the Chat Agent and Workshop Agent with distinct personalities. +Creates and maintains the Chat Agent with distinct personalities. """ import logging from typing import TypedDict from uuid import UUID +from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from core.providers import SYSTEM_USER_ID @@ -46,32 +47,9 @@ class AgentConfig(TypedDict): 你的目标是成为用户最可靠的AI助手,帮助他们解决问题并提供有价值的信息。""", "personality": "friendly_assistant", "capabilities": ["general_chat", "qa", "assistance", "tools"], - "avatar": "https://avatars.githubusercontent.com/u/176685?v=4", + "avatar": "/defaults/agents/avatar1.png", "tags": ["助手", "对话", "工具", "帮助"], }, - "workshop": { - "name": "创作工坊", - "description": "专注于AI助手的设计、创建和优化的专业助手", - "prompt": """你是一个专业的AI助手设计师和创作顾问。你专门帮助用户设计、创建和优化AI助手。 - -专业能力: -- 深入了解各种AI能力和工具集成方案 -- 熟悉对话设计模式和用户体验最佳实践 -- 能够分析需求并提供专业的架构建议 -- 指导用户进行提示词工程和角色设定 - -工作方式: -- 通过提问来深入了解用户的具体需求 -- 提供结构化的设计建议和实施步骤 -- 推荐合适的工具和能力组合 -- 帮助优化现有助手的表现 - -你的目标是帮助用户创建出色的AI助手,提供专业指导和创意灵感。""", - "personality": "creative_mentor", - "capabilities": ["agent_design", "tool_selection", "prompt_engineering", "workflow_optimization"], - "avatar": "https://cdn1.deepmd.net/static/img/affb038eChatGPT Image 2025年8月6日 10_33_07.png", - "tags": ["设计", "创作", "优化", "专业"], - }, } @@ -88,92 +66,103 @@ def __init__(self, db: AsyncSession): self.agent_repo = AgentRepository(db) self.provider_repo = ProviderRepository(db) - async def ensure_system_agents(self) -> dict[str, Agent]: + async def ensure_user_default_agents(self, user_id: str) -> list[Agent]: """ - Create or update both system agents on startup. + Check and ensure user has all default agents by verifying tags. + If a specific default agent is missing, it will be recreated. + + Args: + user_id: The ID of the user to initialize defaults for. Returns: - Dictionary mapping agent keys to Agent instances + List of newly created default agents for the user. """ - logger.info("Ensuring system agents exist...") - - # Get system provider for both agents + # Fetch existing agents for the user to check tags + statement = select(Agent).where(Agent.user_id == user_id) + result = await self.db.exec(statement) + existing_agents = result.all() + existing_tags = set() + for agent in existing_agents: + if agent.tags: + existing_tags.update(agent.tags) + + logger.info(f"Checking default agents for user: {user_id}") + + # Get system provider to set as default if available system_provider = await self.provider_repo.get_system_provider() - if not system_provider: - logger.warning("No system provider found - system agents will use user providers") - created_agents: dict[str, Agent] = {} - - for agent_key, agent_config in SYSTEM_AGENTS.items(): - try: - agent = await self._ensure_single_agent(agent_config, system_provider) - created_agents[agent_key] = agent - logger.info(f"System agent '{agent_key}' ready: {agent.name} (ID: {agent.id})") - except Exception as e: - logger.error(f"Failed to create system agent '{agent_key}': {e}") + created_agents = [] + for agent_key, config in SYSTEM_AGENTS.items(): + tag = f"default_{agent_key}" + if tag in existing_tags: continue - logger.info(f"System agent initialization complete: {len(created_agents)}/{len(SYSTEM_AGENTS)} agents ready") - return created_agents + # Add a tag to identify which default agent this is + tags = config.get("tags", []) + if tag not in tags: + tags = tags + [tag] + + agent_data = AgentCreate( + scope=AgentScope.USER, + name=config["name"], + description=config["description"], + prompt=config["prompt"], + avatar=config.get("avatar"), + tags=tags, + provider_id=system_provider.id if system_provider else None, + mcp_server_ids=[], # Defaults start clean + require_tool_confirmation=False, + model=None, + temperature=0.7, + ) + + agent = await self.agent_repo.create_agent(agent_data, user_id) + created_agents.append(agent) + logger.info(f"Created default {agent_key} agent for user {user_id}: {agent.id}") - async def _ensure_single_agent(self, agent_config: AgentConfig, system_provider: "Provider | None") -> Agent: - """ - Create or update a single system agent. - - Args: - agent_config: Agent configuration dictionary - system_provider: System provider instance or None + return created_agents - Returns: - Agent instance + async def ensure_system_agents(self) -> dict[str, Agent]: """ - # Check if agent already exists by name and scope - existing = await self.agent_repo.get_agent_by_name_and_scope(agent_config["name"], AgentScope.SYSTEM) - - if existing: - # Update existing agent if needed - updated = await self._update_system_agent(existing, agent_config, system_provider) - return updated - else: - # Create new system agent - created = await self._create_system_agent(agent_config, system_provider) - return created - - async def _create_system_agent(self, agent_config: AgentConfig, system_provider: "Provider | None") -> Agent: + Legacy: Create or update system agents. + Now primarily used for global/template reference if needed. """ - Create a new system agent. + logger.info("Ensuring system reference agents exist...") - Args: - agent_config: Agent configuration dictionary - system_provider: System provider instance or None + system_provider = await self.provider_repo.get_system_provider() + created_agents: dict[str, Agent] = {} - Returns: - Newly created Agent instance - """ - logger.info(f"Creating new system agent: {agent_config['name']}") - - # Get default MCP servers for this agent type - mcp_server_ids = await self._get_default_mcp_servers(agent_config["personality"]) - - agent_data = AgentCreate( - scope=AgentScope.SYSTEM, - name=agent_config["name"], - description=agent_config["description"], - prompt=agent_config["prompt"], - avatar=agent_config.get("avatar"), - tags=agent_config.get("tags"), - provider_id=system_provider.id if system_provider else None, - mcp_server_ids=mcp_server_ids, - require_tool_confirmation=False, - model=None, # Will use provider default - temperature=0.7, # Balanced creativity - ) + for agent_key, agent_config in SYSTEM_AGENTS.items(): + try: + # Check if agent already exists by name and scope + existing = await self.agent_repo.get_agent_by_name_and_scope(agent_config["name"], AgentScope.SYSTEM) + + if existing: + agent = await self._update_system_agent(existing, agent_config, system_provider) + else: + logger.info(f"Creating new system reference agent: {agent_config['name']}") + mcp_server_ids = await self._get_default_mcp_servers(agent_config["personality"]) + agent_data = AgentCreate( + scope=AgentScope.SYSTEM, + name=agent_config["name"], + description=agent_config["description"], + prompt=agent_config["prompt"], + avatar=agent_config.get("avatar"), + tags=agent_config.get("tags"), + provider_id=system_provider.id if system_provider else None, + mcp_server_ids=mcp_server_ids, + require_tool_confirmation=False, + model=None, + temperature=0.7, + ) + agent = await self.agent_repo.create_agent(agent_data, SYSTEM_USER_ID) - # Create agent - created_agent = await self.agent_repo.create_agent(agent_data, SYSTEM_USER_ID) + created_agents[agent_key] = agent + except Exception as e: + logger.error(f"Failed to handle system agent '{agent_key}': {e}") + continue - logger.info(f"Created system agent: {created_agent.name} (ID: {created_agent.id})") - return created_agent + return created_agents async def _update_system_agent( self, existing: Agent, agent_config: AgentConfig, system_provider: "Provider | None" @@ -226,7 +215,7 @@ async def _get_default_mcp_servers(self, agent_personality: str) -> list[UUID]: Get default MCP servers for each agent personality. Args: - agent_personality: Personality type ('friendly_assistant' or 'creative_mentor') + agent_personality: Personality type ('friendly_assistant') Returns: List of MCP server UUIDs @@ -237,9 +226,6 @@ async def _get_default_mcp_servers(self, agent_personality: str) -> list[UUID]: if agent_personality == "friendly_assistant": # Chat agent: basic utility tools return await self._get_basic_mcp_servers() - elif agent_personality == "creative_mentor": - # Workshop agent: creation and design tools - return await self._get_workshop_mcp_servers() return [] @@ -254,23 +240,12 @@ async def _get_basic_mcp_servers(self) -> list[UUID]: # For now, return empty list - can be enhanced with actual MCP servers return [] - async def _get_workshop_mcp_servers(self) -> list[UUID]: - """ - Get workshop-focused MCP servers for creation assistant. - - Returns: - List of workshop MCP server UUIDs - """ - # TODO: Query for workshop MCP servers (design tools, templates, etc.) - # For now, return empty list - can be enhanced with actual MCP servers - return [] - async def get_system_agent(self, agent_type: str) -> Agent | None: """ Get a specific system agent by type. Args: - agent_type: Either 'chat' or 'workshop' + agent_type: 'chat' Returns: Agent instance or None if not found diff --git a/service/handler/api/v1/agents.py b/service/handler/api/v1/agents.py index 0b72b88a..6b9c0224 100644 --- a/service/handler/api/v1/agents.py +++ b/service/handler/api/v1/agents.py @@ -1,23 +1,27 @@ +""" +Agent API Handlers. + +This module provides the following endpoints for agent management: +- POST /: Create a new agent. +- GET /: Get all agents for the current user. +- GET /{agent_id}: Get details for a specific agent. +- PATCH /{agent_id}: Update an existing agent. +- DELETE /{agent_id}: Delete an agent. +- GET /system/chat: Get the user's default chat agent. +- GET /system/all: Get all user default agents. +""" + from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlmodel.ext.asyncio.session import AsyncSession from common.code import ErrCodeError, handle_auth_error -from core.agent_service import AgentService, UnifiedAgentRead from core.auth import AuthorizationService, get_auth_service from core.system_agent import SystemAgentManager from infra.database import get_session from middleware.auth import get_current_user from models.agent import AgentCreate, AgentRead, AgentReadWithDetails, AgentScope, AgentUpdate - -# Ensure forward references are resolved after importing both models -# try: -# AgentReadWithDetails.model_rebuild() -# except Exception as e: -# # If rebuild fails, log the error for debugging -# import logging -# logging.getLogger(__name__).warning(f"Failed to rebuild AgentReadWithDetails: {e}") from repos import AgentRepository, ProviderRepository router = APIRouter(tags=["agents"]) @@ -74,6 +78,7 @@ async def get_agents( Returns all agents owned by the authenticated user, ordered by creation time. Each agent includes its basic configuration, metadata, and associated MCP servers. + If the user has no agents, default agents will be initialized for them. Args: user: Authenticated user ID (injected by dependency) @@ -83,8 +88,13 @@ async def get_agents( list[AgentReadWithDetails]: list of agents owned by the user with MCP server details Raises: - HTTPException: None - this endpoint always succeeds, returning empty list if no agents + HTTPException: None - this endpoint always succeeds """ + # Ensure user has default agents if they have none + system_manager = SystemAgentManager(db) + await system_manager.ensure_user_default_agents(user) + await db.commit() + agent_repo = AgentRepository(db) agents = await agent_repo.get_agents_by_user(user) @@ -102,87 +112,42 @@ async def get_agents( return agents_with_details -@router.get("/all/unified", response_model=list[UnifiedAgentRead]) -async def get_all_agents_unified( - user: str = Depends(get_current_user), - db: AsyncSession = Depends(get_session), -) -> list[UnifiedAgentRead]: - """ - Get all agents (both regular and graph) for the current authenticated user. - - Returns a unified list that includes both regular agents and graph agents, - with consistent formatting and type indicators. This endpoint is designed - for frontend consumption where both agent types should be displayed together. - - Args: - user: Authenticated user ID (injected by dependency) - db: Database session (injected by dependency) - - Returns: - list[UnifiedAgentRead]: Unified list of all agents owned by the user - - Raises: - HTTPException: None - this endpoint always succeeds, returning empty list if no agents - """ - agent_service = AgentService(db) - return await agent_service.get_all_agents_for_user(user) - - -@router.get("/{agent_id}", response_model=UnifiedAgentRead) +@router.get("/{agent_id}", response_model=AgentReadWithDetails) async def get_agent( - agent_id: str, + agent_id: UUID, user_id: str = Depends(get_current_user), auth_service: AuthorizationService = Depends(get_auth_service), db: AsyncSession = Depends(get_session), -) -> UnifiedAgentRead: +) -> AgentReadWithDetails: """ - Get a single agent by ID (supports regular, graph, and builtin agents). + Get a single agent by ID. - Returns the requested agent with full configuration details. - For regular and graph agents, authorization ensures the user owns the agent. - Builtin agents are available to all users. + Returns the requested agent with full configuration details including MCP servers. + Authorization ensures the user has access to the agent (owner or system agent). Args: - agent_id: Agent identifier (UUID string for regular/graph, or builtin agent ID) - user: Authenticated user ID (injected by dependency) + agent_id: UUID of the agent to fetch + user_id: Authenticated user ID (injected by dependency) db: Database session (injected by dependency) Returns: - UnifiedAgentRead: The requested agent with unified format + AgentReadWithDetails: The requested agent with MCP server details Raises: HTTPException: 404 if agent not found, 403 if access denied """ - agent_service = AgentService(db) - - # Handle builtin agents - if agent_id.startswith("builtin_"): - agent = await agent_service.get_agent_by_id(agent_id) - if not agent: - raise HTTPException(status_code=404, detail="Builtin agent not found") - return agent - - # Handle regular/graph agents try: - agent_uuid = UUID(agent_id) - agent = await agent_service.get_agent_by_id(agent_uuid, user_id) - if not agent: - raise HTTPException(status_code=404, detail="Agent not found") - - # For regular/graph agents, verify ownership - if agent.agent_type in ["regular", "graph"]: - # Get the actual agent model to check ownership - from core.agent_type_detector import AgentTypeDetector - - detector = AgentTypeDetector(db) + agent = await auth_service.authorize_agent_read(agent_id, user_id) - agent_with_type = await detector.get_agent_with_type(agent_uuid, user_id) - if not agent_with_type: - raise HTTPException(status_code=404, detail="Agent not found or access denied") + agent_repo = AgentRepository(db) + mcp_servers = await agent_repo.get_agent_mcp_servers(agent.id) - return agent - except ValueError: - raise HTTPException(status_code=400, detail="Invalid agent ID format") + # Create agent dict with MCP servers + agent_dict = agent.model_dump() + agent_dict["mcp_servers"] = mcp_servers + return AgentReadWithDetails(**agent_dict) + except ErrCodeError as e: + raise handle_auth_error(e) @router.patch("/{agent_id}", response_model=AgentReadWithDetails) @@ -277,6 +242,10 @@ async def delete_agent( if agent.scope == AgentScope.SYSTEM: raise HTTPException(status_code=403, detail="Cannot delete system agents") + # Prevent deletion of default agents + if agent.tags and any(tag.startswith("default_") for tag in agent.tags): + raise HTTPException(status_code=403, detail="Cannot delete default agents") + agent_repo = AgentRepository(db) await agent_repo.delete_agent(agent.id) await db.commit() @@ -290,70 +259,40 @@ async def get_system_chat_agent( db: AsyncSession = Depends(get_session), ) -> AgentReadWithDetails: """ - Get the system chat agent available to all users. + Get the user's default chat agent. - Returns the "随便聊聊" system agent with MCP server details. - This agent is available to all authenticated users. + Returns the user's personal copy of the "随便聊聊" agent with MCP server details. + If it doesn't exist, it will be initialized. Args: user: Authenticated user ID (injected by dependency) db: Database session (injected by dependency) Returns: - AgentReadWithDetails: The system chat agent with MCP server details + AgentReadWithDetails: The user's chat agent with MCP server details Raises: - HTTPException: 404 if system chat agent not found + HTTPException: 404 if chat agent not found """ - system_manager = SystemAgentManager(db) - chat_agent = await system_manager.get_system_agent("chat") - - if not chat_agent: - raise HTTPException(status_code=404, detail="System chat agent not found") - - # Get MCP servers for the system agent agent_repo = AgentRepository(db) - mcp_servers = await agent_repo.get_agent_mcp_servers(chat_agent.id) - - # Create agent dict with MCP servers - agent_dict = chat_agent.model_dump() - agent_dict["mcp_servers"] = mcp_servers - return AgentReadWithDetails(**agent_dict) - - -@router.get("/system/workshop", response_model=AgentReadWithDetails) -async def get_system_workshop_agent( - user: str = Depends(get_current_user), - db: AsyncSession = Depends(get_session), -) -> AgentReadWithDetails: - """ - Get the system workshop agent available to all users. - - Returns the "创作工坊" system agent with MCP server details. - This agent is available to all authenticated users. - - Args: - user: Authenticated user ID (injected by dependency) - db: Database session (injected by dependency) + agents = await agent_repo.get_agents_by_user(user) - Returns: - AgentReadWithDetails: The system workshop agent with MCP server details + chat_agent = next((a for a in agents if a.tags and "default_chat" in a.tags), None) - Raises: - HTTPException: 404 if system workshop agent not found - """ - system_manager = SystemAgentManager(db) - workshop_agent = await system_manager.get_system_agent("workshop") + if not chat_agent: + system_manager = SystemAgentManager(db) + new_agents = await system_manager.ensure_user_default_agents(user) + await db.commit() + chat_agent = next((a for a in new_agents if a.tags and "default_chat" in a.tags), None) - if not workshop_agent: - raise HTTPException(status_code=404, detail="System workshop agent not found") + if not chat_agent: + raise HTTPException(status_code=404, detail="Chat agent not found") - # Get MCP servers for the system agent - agent_repo = AgentRepository(db) - mcp_servers = await agent_repo.get_agent_mcp_servers(workshop_agent.id) + # Get MCP servers for the agent + mcp_servers = await agent_repo.get_agent_mcp_servers(chat_agent.id) # Create agent dict with MCP servers - agent_dict = workshop_agent.model_dump() + agent_dict = chat_agent.model_dump() agent_dict["mcp_servers"] = mcp_servers return AgentReadWithDetails(**agent_dict) @@ -364,26 +303,33 @@ async def get_all_system_agents( db: AsyncSession = Depends(get_session), ) -> list[AgentReadWithDetails]: """ - Get all system agents available to all users. + Get all default agents for the user. - Returns both the chat and workshop system agents with MCP server details. - These agents are available to all authenticated users. + Returns the user's personal copies of system agents with MCP server details. + These are the agents tagged with 'default_'. Args: user: Authenticated user ID (injected by dependency) db: Database session (injected by dependency) Returns: - list[AgentReadWithDetails]: list of all system agents with MCP server details + list[AgentReadWithDetails]: list of all user default agents with MCP server details """ - system_manager = SystemAgentManager(db) - system_agents = await system_manager.get_all_system_agents() + agent_repo = AgentRepository(db) + agents = await agent_repo.get_agents_by_user(user) + + # Filter for default agents + default_agents = [a for a in agents if a.tags and any(t.startswith("default_") for t in a.tags)] + + if not default_agents: + system_manager = SystemAgentManager(db) + default_agents = await system_manager.ensure_user_default_agents(user) + await db.commit() # Load MCP servers for each system agent - agent_repo = AgentRepository(db) agents_with_details = [] - for agent in system_agents: + for agent in default_agents: # Get MCP servers for this agent mcp_servers = await agent_repo.get_agent_mcp_servers(agent.id) diff --git a/service/repos/agent.py b/service/repos/agent.py index 197790bb..578242a3 100644 --- a/service/repos/agent.py +++ b/service/repos/agent.py @@ -5,7 +5,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -from models.agent import Agent, AgentCreate, AgentUpdate, AgentScope +from models.agent import Agent, AgentCreate, AgentScope, AgentUpdate from models.links import AgentMcpServerLink from models.mcp import McpServer diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index d87527de..9a8d3855 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -42,7 +42,6 @@ export function Xyzen({ setBackendUrl, toggleXyzen, fetchAgents, - fetchSystemAgents, fetchMcpServers, fetchChatHistory, activateChannel, @@ -131,7 +130,6 @@ export function Xyzen({ // 1. Fetch all necessary data in parallel await Promise.all([ fetchAgents(), - fetchSystemAgents(), fetchMcpServers(), fetchChatHistory(), ]); @@ -160,7 +158,6 @@ export function Xyzen({ status, initialLoadComplete, fetchAgents, - fetchSystemAgents, fetchMcpServers, fetchChatHistory, activateChannel, diff --git a/web/src/components/admin/CodesList.tsx b/web/src/components/admin/CodesList.tsx index cd0b981a..b2a20f14 100644 --- a/web/src/components/admin/CodesList.tsx +++ b/web/src/components/admin/CodesList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; interface GeneratedCode { id: string; @@ -28,7 +28,7 @@ export function CodesList({ const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const loadCodes = async () => { + const loadCodes = useCallback(async () => { setIsLoading(true); setError(null); @@ -53,7 +53,7 @@ export function CodesList({ } finally { setIsLoading(false); } - }; + }, [adminSecret, backendUrl]); const handleDeactivate = async (codeId: string) => { if (!confirm("Are you sure you want to deactivate this code?")) return; @@ -104,7 +104,7 @@ export function CodesList({ useEffect(() => { loadCodes(); - }, []); + }, [loadCodes]); useEffect(() => { if (newCode) { diff --git a/web/src/components/admin/SecretCodePage.tsx b/web/src/components/admin/SecretCodePage.tsx index 0d68938e..8a09f7ab 100644 --- a/web/src/components/admin/SecretCodePage.tsx +++ b/web/src/components/admin/SecretCodePage.tsx @@ -58,7 +58,7 @@ export function SecretCodePage() { } else { setAuthError("Failed to verify admin secret key"); } - } catch (err) { + } catch { setAuthError("Network error: Failed to verify secret key"); } finally { setIsVerifying(false); diff --git a/web/src/components/layouts/AgentExplorer.tsx b/web/src/components/layouts/AgentExplorer.tsx index 8ee3ad52..5aff8e69 100644 --- a/web/src/components/layouts/AgentExplorer.tsx +++ b/web/src/components/layouts/AgentExplorer.tsx @@ -314,8 +314,8 @@ export default function AgentExplorer() { Official and community published graph agents will appear here.

- Create and publish your own graph agents in the Workshop to share - with the community. + Create and publish your own graph agents to share with the + community.

diff --git a/web/src/components/layouts/Workshop.tsx b/web/src/components/layouts/Workshop.tsx deleted file mode 100644 index 878f1329..00000000 --- a/web/src/components/layouts/Workshop.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; -import { Badge } from "@/components/base/Badge"; -import { useXyzen } from "@/store"; -import { - EyeIcon, - EyeSlashIcon, - PlayIcon, - StopIcon, -} from "@heroicons/react/24/outline"; -import { motion } from "framer-motion"; -import { useEffect } from "react"; - -// Import the types we need -import type { Agent } from "@/types/agents"; -import { isGraphAgent } from "@/types/agents"; - -// Agent card component for workshop - moved outside the main component -function GraphAgentCard({ - agent, - onTogglePublish, -}: { - agent: Agent; - onTogglePublish: (agentId: string) => void; -}) { - // Only render if it's a graph agent - if (!isGraphAgent(agent)) { - return null; - } - - return ( - -
-
-

- {agent.name} -

-
- {agent.is_published ? ( - - - Published - - ) : ( - - - Private - - )} - {agent.is_active ? ( - - - Ready - - ) : ( - - - Building - - )} -
-
-
- -

- {agent.description} -

- -
- {/*
- 📊 {agent.node_count || 0} nodes - 🔗 {agent.edge_count || 0} edges -
*/} - - -
-
- ); -} - -export default function Workshop() { - const { - agents, - fetchAgents, - toggleGraphAgentPublish, - user, - backendUrl, - fetchWorkshopHistory, - } = useXyzen(); - - // Filter to show only user's graph agents (both published and unpublished) - // Note: Since /graph-agents/ endpoint returns user's own agents, we show all graph agents - const userGraphAgents = agents.filter((agent) => isGraphAgent(agent)); - - // Removed debug logging - - useEffect(() => { - if (backendUrl) { - fetchAgents(); - } - }, [fetchAgents, backendUrl]); - - // Fetch workshop history when component mounts - useEffect(() => { - if (user && backendUrl) { - fetchWorkshopHistory(); - } - }, [fetchWorkshopHistory, user, backendUrl]); - - const handleTogglePublish = async (agentId: string) => { - try { - const agent = userGraphAgents.find((a) => a.id === agentId); - if (!agent) return; - - // Show some feedback - const action = agent.is_published ? "unpublishing" : "publishing"; - console.log(`${action} agent: ${agent.name}`); - - await toggleGraphAgentPublish(agentId); - - // Success feedback - console.log( - `Successfully ${agent.is_published ? "unpublished" : "published"} agent: ${agent.name}`, - ); - } catch (error) { - console.error("Failed to toggle publish status:", error); - // Could add toast notification here - alert("Failed to update publish status. Please try again."); - } - }; - - // Always show Workshop content regardless of layout style - // Users want to see their graph agents in both sidebar and fullscreen modes - - // Workshop view - clean and simple - return ( -
- {/* Left: User Graph Agents */} -
- {/* User Graph Agents List */} -
- {userGraphAgents.length === 0 ? ( -
-
-
📊
-

- No graph agents yet -

-

- Create graph agents using MCP tools -

-
-
- ) : ( -
- {/* Quick Stats */} -
-
-
-
- {userGraphAgents.filter((a) => a.is_published).length} -
-
- Published -
-
-
-
- {userGraphAgents.filter((a) => !a.is_published).length} -
-
- Private -
-
-
-
- - {/* Published Agents */} - {userGraphAgents.filter((a) => a.is_published).length > 0 && ( -
-

- 🌐 - Published ( - {userGraphAgents.filter((a) => a.is_published).length}) -

-
- {userGraphAgents - .filter((a) => a.is_published) - .map((agent) => ( - - ))} -
-
- )} - - {/* Private Agents */} - {userGraphAgents.filter((a) => !a.is_published).length > 0 && ( -
-

- 🔒 - Private ( - {userGraphAgents.filter((a) => !a.is_published).length}) -

-
- {userGraphAgents - .filter((a) => !a.is_published) - .map((agent) => ( - - ))} -
-
- )} -
- )} -
-
- - {/* Right: Empty space for tools view */} -
-
-
🎨
-

- Agent Designer -

-

- Design area -

-
-
-
- ); -} diff --git a/web/src/components/layouts/WorkshopChat.tsx b/web/src/components/layouts/WorkshopChat.tsx deleted file mode 100644 index f0f2cc63..00000000 --- a/web/src/components/layouts/WorkshopChat.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { CHAT_THEMES } from "@/configs/chatThemes"; - -import EditableTitle from "@/components/base/EditableTitle"; -import NotificationModal from "@/components/modals/NotificationModal"; -import type { WorkShopChatConfig } from "@/hooks/useWorkShopChat"; -import { useWorkShopChat } from "@/hooks/useWorkShopChat"; -import { ArrowPathIcon } from "@heroicons/react/24/outline"; -import { AnimatePresence, motion } from "framer-motion"; - -import ChatBubble from "./components/ChatBubble"; -import ChatInput from "./components/ChatInput"; -import ChatToolbar from "./components/ChatToolbar"; -import EmptyChat from "./components/EmptyChat"; -import ResponseSpinner from "./components/ResponseSpinner"; -import WelcomeMessage from "./components/WelcomeMessage"; - -interface BaseChatProps { - config: WorkShopChatConfig; - historyEnabled?: boolean; -} - -// Theme-specific styling -const getThemeStyles = (theme: "indigo" | "purple") => { - if (theme === "purple") { - return { - agentBorder: "border-purple-100 dark:border-purple-900", - agentName: "text-purple-600 dark:text-purple-400", - responseSpinner: - "bg-purple-50 text-purple-600 ring-purple-100 dark:bg-purple-900/20 dark:text-purple-300 dark:ring-purple-800/40", - scrollButton: "bg-purple-600 hover:bg-purple-700", - }; - } - return { - agentBorder: "border-indigo-100 dark:border-indigo-900", - agentName: "text-indigo-600 dark:text-indigo-400", - responseSpinner: - "bg-indigo-50 text-indigo-600 ring-indigo-100 dark:bg-indigo-900/20 dark:text-indigo-300 dark:ring-indigo-800/40", - scrollButton: "bg-indigo-600 hover:bg-indigo-700", - }; -}; - -// Empty state component for different themes -const ThemedEmptyState: React.FC<{ config: WorkShopChatConfig }> = ({ - config, -}) => { - if (config.theme === "indigo") { - return ; - } - - // Workshop theme with motion animations - return ( -
- -
{config.emptyState.icon}
-
- - -

- {config.emptyState.title} -

-

- {config.emptyState.description} -

-
- - {config.emptyState.features && ( - - {config.emptyState.features.map((feature, _index) => ( - - {feature} - - ))} - - )} -
- ); -}; - -// Welcome message component for different themes -const ThemedWelcomeMessage: React.FC<{ config: WorkShopChatConfig }> = ({ - config, -}) => { - if (!config.welcomeMessage || config.theme === "indigo") { - return ; - } - - const { welcomeMessage } = config; - return ( -
- -
{welcomeMessage.icon}
-
- - -

- {welcomeMessage.title} -

-

- {welcomeMessage.description} -

-
- - {welcomeMessage.tags && ( - - {welcomeMessage.tags.map((tag) => ( - - {tag} - - ))} - - )} -
- ); -}; - -function BaseChat({ config, historyEnabled = false }: BaseChatProps) { - const { - // State - autoScroll, - isRetrying, - showHistory, - inputHeight, - sendBlocked, - - // Computed - currentChannel, - currentAgent, - messages, - connected, - error, - responding, - - // Refs - messagesEndRef, - messagesContainerRef, - - // Handlers - handleSendMessage, - handleToggleHistory, - handleCloseHistory, - handleSelectTopic, - handleInputHeightChange, - handleRetryConnection, - handleScrollToBottom, - handleScroll, - - // Store values - activeChatChannel, - notification, - closeNotification, - pendingInput, - updateTopicName, - } = useWorkShopChat(config); - - const themeStyles = getThemeStyles(config.theme); - - if (!activeChatChannel) { - return ( -
- - - {/* Add toolbar even in empty state for history access */} -
-
- -
-
- ); - } - - return ( -
- {/* Main Chat Content Wrapper */} -
- {/* Agent Header */} - {currentAgent ? ( -
-
- {currentAgent.name} -
-
- - {currentAgent.name} - - - • - - { - if (activeChatChannel) { - return updateTopicName(activeChatChannel, newTitle); - } - return Promise.resolve(); - }} - textClassName="text-sm text-neutral-600 dark:text-neutral-400" - /> - {responding && ( - - )} -
-

- {currentAgent.description} -

-
-
-
- ) : ( -
- { - if (activeChatChannel) { - return updateTopicName(activeChatChannel, newTitle); - } - return Promise.resolve(); - }} - className="mb-1" - textClassName="text-lg font-medium text-neutral-800 dark:text-white" - /> -

- {config.welcomeMessage?.description || - config.emptyState.description} -

- {responding && ( - - )} -
- )} - - {/* Connection Status */} - {!connected && ( -
- - {error || config.connectionMessages.connecting} - - -
- )} - - {/* Messages Area */} -
-
-
- {messages.length === 0 ? ( - - ) : ( -
- - {messages.map((msg) => ( - - ))} - -
-
- )} -
-
- - {/* Scroll to Bottom Button */} - {!autoScroll && messages.length > 0 && ( - - )} -
- - {/* Input Area */} -
- - {sendBlocked && ( -
- 正在生成回复,暂时无法发送。请稍后再试。 -
- )} - -
-
- {/* End of Main Chat Content Wrapper */} - - {/* History Sidebar - Same Layer */} - {/*{showHistory && historyEnabled && ( -
- -
- )}*/} - - {/* Notification Modal */} - {notification && ( - - )} -
- ); -} - -export default function WorkshopChat() { - return ; -} diff --git a/web/src/components/layouts/XyzenAgent.tsx b/web/src/components/layouts/XyzenAgent.tsx index b9207fb0..367ce7ad 100644 --- a/web/src/components/layouts/XyzenAgent.tsx +++ b/web/src/components/layouts/XyzenAgent.tsx @@ -89,24 +89,20 @@ const ContextMenu: React.FC = ({ className="fixed z-50 w-48 rounded-sm border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800" style={{ left: x, top: y }} > - {isDefaultAgent ? ( -
-

- 默认助手不可编辑 -

-
- ) : ( - <> - + <> + + {!isDefaultAgent && ( - - )} + )} + ); }; @@ -136,12 +132,8 @@ const AgentCard: React.FC = ({ y: number; } | null>(null); - // Treat the two built-in system agents as non-editable defaults - const isDefaultSystemAgent = - agent.id === "00000000-0000-0000-0000-000000000001" || - agent.id === "00000000-0000-0000-0000-000000000002" || - agent.agent_type === "builtin" || - agent.agent_type === "system"; + // Check if it's a default agent based on tags + const isDefaultAgent = agent.tags?.some((tag) => tag.startsWith("default_")); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -173,11 +165,9 @@ const AgentCard: React.FC = ({ {agent.name} = ({ onEdit={() => onEdit?.(agent)} onDelete={() => onDelete?.(agent)} onClose={() => setContextMenu(null)} - isDefaultAgent={isDefaultSystemAgent} + isDefaultAgent={isDefaultAgent} agent={agent} /> )} @@ -261,7 +251,7 @@ const containerVariants: Variants = { }; interface XyzenAgentProps { - systemAgentType?: "chat" | "workshop" | "all"; + systemAgentType?: "chat" | "all"; } export default function XyzenAgent({ @@ -274,9 +264,7 @@ export default function XyzenAgent({ const [agentToDelete, setAgentToDelete] = useState(null); const { agents, - systemAgents, fetchAgents, - fetchSystemAgents, createDefaultChannel, deleteAgent, removeGraphAgentFromSidebar, @@ -303,19 +291,18 @@ export default function XyzenAgent({ } }, [llmProviders.length, llmProvidersLoading, fetchMyProviders]); - // Ensure MCP servers are loaded first, then fetch system agents + // Ensure MCP servers are loaded first useEffect(() => { - const loadAgentsWithMcps = async () => { + const loadMcps = async () => { try { await fetchMcpServers(); - await fetchSystemAgents(); } catch (error) { - console.error("Failed to load agents with MCPs:", error); + console.error("Failed to load MCP servers:", error); } }; - loadAgentsWithMcps(); - }, [fetchMcpServers, fetchSystemAgents]); + loadMcps(); + }, [fetchMcpServers]); const handleAgentClick = async (agent: Agent) => { // 使用实际的 agent ID(系统助手和普通助手都有真实的 ID) @@ -360,21 +347,24 @@ export default function XyzenAgent({ setConfirmModalOpen(true); }; - // 过滤系统助手基于当前面板类型 - const filteredSystemAgents = systemAgents.filter((agent) => { - if (systemAgentType === "all") return true; - if (systemAgentType === "chat") { - return agent.id === "00000000-0000-0000-0000-000000000001"; // System Chat Agent + // Find system agents within the user's agents list using tags + const filteredSystemAgents = agents.filter((agent) => { + if (!agent.tags) return false; + + if (systemAgentType === "all") { + return agent.tags.some((tag) => tag.startsWith("default_")); } - if (systemAgentType === "workshop") { - return agent.id === "00000000-0000-0000-0000-000000000002"; // System Workshop Agent + if (systemAgentType === "chat") { + return agent.tags.includes("default_chat"); } return false; }); - // 合并过滤后的系统助手、用户助手和可见的图形助手 + // Regular agents (excluding the ones already identified as default) const regularAgents = agents.filter( - (agent) => agent.agent_type === "regular", + (agent) => + agent.agent_type === "regular" && + !agent.tags?.some((tag) => tag.startsWith("default_")), ); const visibleGraphAgents = agents.filter( (agent) => diff --git a/web/src/components/layouts/XyzenChat.tsx b/web/src/components/layouts/XyzenChat.tsx index d97adee1..6e32a5e5 100644 --- a/web/src/components/layouts/XyzenChat.tsx +++ b/web/src/components/layouts/XyzenChat.tsx @@ -8,7 +8,7 @@ import { useXyzenChat } from "@/hooks/useXyzenChat"; import type { Agent } from "@/types/agents"; import { ArrowPathIcon, ShareIcon } from "@heroicons/react/24/outline"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence } from "framer-motion"; import { useState } from "react"; import ChatBubble from "./components/ChatBubble"; @@ -24,16 +24,7 @@ interface BaseChatProps { } // Theme-specific styling -const getThemeStyles = (theme: "indigo" | "purple") => { - if (theme === "purple") { - return { - agentBorder: "border-purple-100 dark:border-purple-900", - agentName: "text-purple-600 dark:text-purple-400", - responseSpinner: - "bg-purple-50 text-purple-600 ring-purple-100 dark:bg-purple-900/20 dark:text-purple-300 dark:ring-purple-800/40", - scrollButton: "bg-purple-600 hover:bg-purple-700", - }; - } +const getThemeStyles = () => { return { agentBorder: "border-indigo-100 dark:border-indigo-900", agentName: "text-indigo-600 dark:text-indigo-400", @@ -44,126 +35,30 @@ const getThemeStyles = (theme: "indigo" | "purple") => { }; // Empty state component for different themes -const ThemedEmptyState: React.FC<{ config: XyzenChatConfig }> = ({ - config, -}) => { - if (config.theme === "indigo") { - return ; - } - - // Workshop theme with motion animations - return ( -
- -
{config.emptyState.icon}
-
- - -

- {config.emptyState.title} -

-

- {config.emptyState.description} -

-
- - {config.emptyState.features && ( - - {config.emptyState.features.map((feature, _index) => ( - - {feature} - - ))} - - )} -
- ); +const ThemedEmptyState: React.FC<{ config: XyzenChatConfig }> = () => { + return ; }; -// Welcome message component for different themes +// Welcome message component const ThemedWelcomeMessage: React.FC<{ config: XyzenChatConfig; currentAgent?: Agent | null; -}> = ({ config, currentAgent }) => { - if (!config.welcomeMessage || config.theme === "indigo") { - return ( - - ); - } - - const { welcomeMessage } = config; +}> = ({ currentAgent }) => { return ( -
- -
{welcomeMessage.icon}
-
- - -

- {welcomeMessage.title} -

-

- {welcomeMessage.description} -

-
- - {welcomeMessage.tags && ( - - {welcomeMessage.tags.map((tag) => ( - - {tag} - - ))} - - )} -
+ ); }; @@ -214,7 +109,7 @@ function BaseChat({ config, historyEnabled = false }: BaseChatProps) { setShowShareModal(true); }; - const themeStyles = getThemeStyles(config.theme); + const themeStyles = getThemeStyles(); if (!activeChatChannel) { return ( @@ -252,12 +147,9 @@ function BaseChat({ config, historyEnabled = false }: BaseChatProps) { {currentAgent.name} { - // Prefer unique by id; user agents shouldn't duplicate system ids, but guard anyway - const map = new Map(); - systemAgents.forEach((a) => map.set(a.id, a)); - agents.forEach((a) => map.set(a.id, a)); - return Array.from(map.values()); - }, [agents, systemAgents]); + return agents; + }, [agents]); // State for managing input height const [inputHeight, setInputHeight] = useState(() => { @@ -579,7 +574,6 @@ export default function ChatToolbar({ 当前会话的对话主题 state.token); // Helper to convert relative URLs to absolute - const getFullUrl = (url: string | undefined): string => { - if (!url) return ""; - if (url.startsWith("http://") || url.startsWith("https://")) { - return url; - } - // Relative URL - prepend backend URL - const base = backendUrl || window.location.origin; - return `${base}${url.startsWith("/") ? url : `/${url}`}`; - }; + const getFullUrl = useCallback( + (url: string | undefined): string => { + if (!url) return ""; + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + // Relative URL - prepend backend URL + const base = backendUrl || window.location.origin; + return `${base}${url.startsWith("/") ? url : `/${url}`}`; + }, + [backendUrl], + ); // Fetch images with authentication and convert to blob URLs useEffect(() => { @@ -91,6 +94,8 @@ export default function MessageAttachments({ return () => { Object.values(imageBlobUrls).forEach((url) => URL.revokeObjectURL(url)); }; + // Intentionally omitting imageBlobUrls and getFullUrl - we check imageBlobUrls[image.id] to avoid re-fetching + // eslint-disable-next-line react-hooks/exhaustive-deps }, [attachments, backendUrl, token]); // Fetch audio and document files with authentication @@ -140,6 +145,8 @@ export default function MessageAttachments({ return () => { Object.values(fileBlobUrls).forEach((url) => URL.revokeObjectURL(url)); }; + // Intentionally omitting fileBlobUrls and getFullUrl - we check fileBlobUrls[file.id] to avoid re-fetching + // eslint-disable-next-line react-hooks/exhaustive-deps }, [attachments, backendUrl, token]); const getImageUrl = (image: MessageAttachment): string => { diff --git a/web/src/components/layouts/components/SessionHistory.tsx b/web/src/components/layouts/components/SessionHistory.tsx index 861f7279..455ed837 100644 --- a/web/src/components/layouts/components/SessionHistory.tsx +++ b/web/src/components/layouts/components/SessionHistory.tsx @@ -20,14 +20,12 @@ import { import { memo, useEffect, useMemo, useState } from "react"; interface SessionHistoryProps { - context?: "chat" | "workshop"; isOpen: boolean; onClose: () => void; onSelectTopic?: (topicId: string) => void; } function SessionHistory({ - context = "chat", isOpen, onClose, onSelectTopic, @@ -41,58 +39,28 @@ function SessionHistory({ // Select only what we need to minimize re-renders const user = useXyzen((s) => s.user); - const chatHistory = useXyzen((s) => s.chatHistory); - const chatHistoryLoading = useXyzen((s) => s.chatHistoryLoading); - const workshopHistory = useXyzen((s) => s.workshopHistory); - const workshopHistoryLoading = useXyzen((s) => s.workshopHistoryLoading); - - const activateChannel = useXyzen((s) => s.activateChannel); - const activateWorkshopChannel = useXyzen((s) => s.activateWorkshopChannel); - const togglePinChat = useXyzen((s) => s.togglePinChat); - const togglePinWorkshopChat = useXyzen((s) => s.togglePinWorkshopChat); - const fetchChatHistory = useXyzen((s) => s.fetchChatHistory); - const updateTopicName = useXyzen((s) => s.updateTopicName); - const updateWorkshopTopicName = useXyzen((s) => s.updateWorkshopTopicName); - const deleteTopic = useXyzen((s) => s.deleteTopic); - const deleteWorkshopTopic = useXyzen((s) => s.deleteWorkshopTopic); - const clearSessionTopics = useXyzen((s) => s.clearSessionTopics); - const clearWorkshopSessionTopics = useXyzen( - (s) => s.clearWorkshopSessionTopics, - ); + const history = useXyzen((s) => s.chatHistory); + const historyLoading = useXyzen((s) => s.chatHistoryLoading); + + const activateChannelFn = useXyzen((s) => s.activateChannel); + const togglePinFn = useXyzen((s) => s.togglePinChat); + const fetchHistoryFn = useXyzen((s) => s.fetchChatHistory); + const updateTopicFn = useXyzen((s) => s.updateTopicName); + const deleteTopicFn = useXyzen((s) => s.deleteTopic); + const clearSessionFn = useXyzen((s) => s.clearSessionTopics); // Active channel topic id - const activeChatChannel = useXyzen((s) => s.activeChatChannel); - const activeWorkshopChannel = useXyzen((s) => s.activeWorkshopChannel); + const activeChannel = useXyzen((s) => s.activeChatChannel); // Subscribe only to the primitive sessionId of the active channel to avoid message-driven re-renders const activeSessionId = useXyzen((s) => { - const topicId = - context === "workshop" ? s.activeWorkshopChannel : s.activeChatChannel; - const map = context === "workshop" ? s.workshopChannels : s.channels; + const topicId = s.activeChatChannel; + const map = s.channels; return topicId ? (map[topicId]?.sessionId ?? null) : null; }); - - // Use appropriate state based on context - const isWorkshop = context === "workshop"; - const history = isWorkshop ? workshopHistory : chatHistory; - const historyLoading = isWorkshop - ? workshopHistoryLoading - : chatHistoryLoading; - const activeChannel = isWorkshop ? activeWorkshopChannel : activeChatChannel; - const activateChannelFn = isWorkshop - ? activateWorkshopChannel - : activateChannel; - const togglePinFn = isWorkshop ? togglePinWorkshopChat : togglePinChat; - const fetchHistoryFn = fetchChatHistory; // Always use fetchChatHistory - workshop syncs from it - const updateTopicFn = isWorkshop ? updateWorkshopTopicName : updateTopicName; - const deleteTopicFn = isWorkshop ? deleteWorkshopTopic : deleteTopic; - const clearSessionFn = isWorkshop - ? clearWorkshopSessionTopics - : clearSessionTopics; // 当组件打开时获取历史记录 useEffect(() => { if (isOpen) { - // fetch once on open; workshop view syncs from chat void fetchHistoryFn(); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/web/src/components/modals/ChatPreview.tsx b/web/src/components/modals/ChatPreview.tsx index 5a524bfe..ab970bc0 100644 --- a/web/src/components/modals/ChatPreview.tsx +++ b/web/src/components/modals/ChatPreview.tsx @@ -45,10 +45,8 @@ const ChatPreview: React.FC = ({ // AI 机器人头像 const robotAvatarUrl = currentAgent?.avatar || - (currentAgent?.agent_type === "builtin" - ? currentAgent.id === "00000000-0000-0000-0000-000000000001" - ? "/defaults/agents/avatar1.png" - : "/defaults/agents/avatar4.png" + (currentAgent?.tags?.includes("default_chat") + ? "/defaults/agents/avatar1.png" : "/defaults/agents/avatar2.png"); // 用户名 diff --git a/web/src/components/modals/ShareModal.tsx b/web/src/components/modals/ShareModal.tsx index 587c27e1..08b2bfe3 100644 --- a/web/src/components/modals/ShareModal.tsx +++ b/web/src/components/modals/ShareModal.tsx @@ -26,6 +26,7 @@ export interface Agent { name: string; avatar?: string; description?: string; + tags?: string[] | null; } interface ShareModalProps { @@ -205,7 +206,7 @@ export const ShareModal: React.FC = ({ // AI 头像逻辑 const robotAvatarUrl = currentAgent?.avatar || - (currentAgent?.id === "00000000-0000-0000-0000-000000000001" + (currentAgent?.tags && currentAgent.tags.includes("default_chat") ? "/defaults/agents/avatar1.png" : "/defaults/agents/avatar2.png"); diff --git a/web/src/components/modals/ToolTestModal.tsx b/web/src/components/modals/ToolTestModal.tsx index 63b0e1e1..b4aeed63 100644 --- a/web/src/components/modals/ToolTestModal.tsx +++ b/web/src/components/modals/ToolTestModal.tsx @@ -102,6 +102,8 @@ export const ToolTestModal: React.FC = ({ editor.dispose(); editorRef.current = null; }; + // Intentionally using parameters only for initial value, not as a dependency + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Update editor when isOpen changes diff --git a/web/src/components/preview/PreviewModal.tsx b/web/src/components/preview/PreviewModal.tsx index 80e3c1b1..c02f3e0d 100644 --- a/web/src/components/preview/PreviewModal.tsx +++ b/web/src/components/preview/PreviewModal.tsx @@ -1,7 +1,7 @@ import { useXyzen } from "@/store"; import { Dialog, Transition } from "@headlessui/react"; import { ArrowDownTrayIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useState, useCallback } from "react"; import { AudioRenderer } from "./renderers/AudioRenderer"; import { ImageRenderer } from "./renderers/ImageRenderer"; import { PdfRenderer } from "./renderers/PdfRenderer"; @@ -22,14 +22,17 @@ export const PreviewModal = ({ isOpen, onClose, file }: PreviewModalProps) => { const token = useXyzen((state) => state.token); // Helper to ensure URL is absolute - const getFullUrl = (url: string | undefined): string => { - if (!url) return ""; - if (url.startsWith("http://") || url.startsWith("https://")) { - return url; - } - const base = backendUrl || window.location.origin; - return `${base}${url.startsWith("/") ? url : `/${url}`}`; - }; + const getFullUrl = useCallback( + (url: string | undefined): string => { + if (!url) return ""; + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + const base = backendUrl || window.location.origin; + return `${base}${url.startsWith("/") ? url : `/${url}`}`; + }, + [backendUrl], + ); useEffect(() => { if (isOpen && file) { @@ -92,7 +95,9 @@ export const PreviewModal = ({ isOpen, onClose, file }: PreviewModalProps) => { } else { setBlobUrl(null); } - }, [isOpen, file, backendUrl, token]); + // Intentionally omitting blobUrl - it's used in cleanup and would cause infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, file, backendUrl, token, getFullUrl]); const renderContent = () => { if (loading) return
Loading preview...
; diff --git a/web/src/configs/chatThemes.ts b/web/src/configs/chatThemes.ts index 57c7d85f..85f065fb 100644 --- a/web/src/configs/chatThemes.ts +++ b/web/src/configs/chatThemes.ts @@ -1,13 +1,9 @@ -import type { WorkShopChatConfig } from "@/hooks/useWorkShopChat"; import type { XyzenChatConfig } from "@/hooks/useXyzenChat"; -// Union type to handle both chat configurations -type ChatConfig = XyzenChatConfig | WorkShopChatConfig; - // Individual configuration exports for type safety export const XYZEN_CHAT_CONFIG: XyzenChatConfig = { theme: "indigo" as const, - systemAgentId: "00000000-0000-0000-0000-000000000001", // System Chat Agent + systemAgentTag: "default_chat", storageKeys: { inputHeight: "chatInputHeight", historyPinned: "chatHistoryPinned", @@ -38,43 +34,8 @@ export const XYZEN_CHAT_CONFIG: XyzenChatConfig = { }, } as const; -export const WORKSHOP_CHAT_CONFIG: WorkShopChatConfig = { - theme: "purple" as const, - systemAgentId: "00000000-0000-0000-0000-000000000002", // System Workshop Agent - storageKeys: { - inputHeight: "workshopChatInputHeight", - historyPinned: "workshopChatHistoryPinned", - }, - defaultTitle: "新的工作坊会话", - placeholders: { - responding: "AI 正在协助创建中,暂时无法发送…", - default: "描述你想创建的助手...", - }, - connectionMessages: { - connecting: "正在连接工作坊服务...", - retrying: "重试连接", - }, - responseMessages: { - generating: "AI 正在协助创建…", - creating: "AI 正在协助创建…", - }, - emptyState: { - title: "工作坊", - description: "创建和设计新的智能助手", - icon: "🔧", - features: ["🤖 助手创建", "📊 图形设计", "💬 交互聊天"], - }, - welcomeMessage: { - title: "开始在工作坊中创建", - description: "与AI助手协作设计和创建新的智能助手", - icon: "🔧", - tags: ["描述你的想法", "定义功能需求", "设计交互流程"], - }, -} as const; - export const CHAT_THEMES = { xyzen: XYZEN_CHAT_CONFIG, - workshop: WORKSHOP_CHAT_CONFIG, -} as const satisfies Record; +} as const satisfies Record; export type ChatThemeKey = keyof typeof CHAT_THEMES; diff --git a/web/src/constants/defaultMcps.ts b/web/src/constants/defaultMcps.ts index fcc2010f..7269bd87 100644 --- a/web/src/constants/defaultMcps.ts +++ b/web/src/constants/defaultMcps.ts @@ -6,13 +6,11 @@ // System Agent IDs (from the codebase) export const SYSTEM_AGENT_IDS = { CHAT: "00000000-0000-0000-0000-000000000001", // 随便聊聊 (Chat Agent) - WORKSHOP: "00000000-0000-0000-0000-000000000002", // 创作工坊 (Workshop Agent) } as const; // Default MCP server names for each system agent (confirmed from backend) export const SYSTEM_AGENT_DEFAULT_MCPS = { [SYSTEM_AGENT_IDS.CHAT]: ["DynamicMCPServer"], // 随便聊聊 gets DynamicMCPServer - [SYSTEM_AGENT_IDS.WORKSHOP]: ["graph-tools"], // 创作工坊 gets graph-tools } as const; // MCP server name patterns to match against (for finding servers in user's list) @@ -22,7 +20,6 @@ export const MCP_SERVER_PATTERNS = { "dynamic_mcp_server", "/mcp/dynamic_mcp_server", ], - GRAPH_TOOLS: ["graph-tools", "graph_tools", "GraphTools"], } as const; /** @@ -59,8 +56,6 @@ export function findMcpServerIdsByNames( let patterns: string[] = []; if (targetName === "DynamicMCPServer") { patterns = [...MCP_SERVER_PATTERNS.DYNAMIC_MCP]; - } else if (targetName === "graph-tools") { - patterns = [...MCP_SERVER_PATTERNS.GRAPH_TOOLS]; } else { patterns = [targetName]; // Fallback to exact match } diff --git a/web/src/hooks/useWorkShopChat.ts b/web/src/hooks/useWorkShopChat.ts deleted file mode 100644 index e426dead..00000000 --- a/web/src/hooks/useWorkShopChat.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { useXyzen } from "@/store"; -import type { Message } from "@/store/types"; -import { useCallback, useEffect, useRef, useState } from "react"; - -export interface WorkShopChatConfig { - theme: "indigo" | "purple"; - systemAgentId: string; - storageKeys: { - inputHeight: string; - historyPinned?: string; - }; - defaultTitle: string; - placeholders: { - responding: string; - default: string; - }; - connectionMessages: { - connecting: string; - retrying: string; - }; - responseMessages: { - generating: string; - creating: string; - }; - emptyState: { - title: string; - description: string; - icon: string; - features?: string[]; - }; - welcomeMessage?: { - title: string; - description: string; - icon: string; - tags?: string[]; - }; -} - -export function useWorkShopChat(config: WorkShopChatConfig) { - const { - activeWorkshopChannel, - workshopChannels, - agents, - systemAgents, - sendWorkshopMessage, - connectToWorkshopChannel, - updateWorkshopTopicName, - fetchMyProviders, - fetchSystemAgents, - fetchWorkshopHistory, - createDefaultWorkshopChannel, - activateWorkshopChannel, - llmProviders, - notification, - closeNotification, - pendingInput, - setPendingInput, - } = useXyzen(); - - // Refs - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); - - // State - const [autoScroll, setAutoScroll] = useState(true); - const [isRetrying, setIsRetrying] = useState(false); - const [showHistory, setShowHistory] = useState(() => { - if (config.storageKeys.historyPinned) { - const savedHistoryState = localStorage.getItem( - config.storageKeys.historyPinned, - ); - return savedHistoryState === "true"; - } - return false; - }); - const [inputHeight, setInputHeight] = useState(() => { - const savedHeight = localStorage.getItem(config.storageKeys.inputHeight); - return savedHeight ? parseInt(savedHeight, 10) : 80; - }); - const [sendBlocked, setSendBlocked] = useState(false); - - // Computed values - const currentChannel = activeWorkshopChannel - ? workshopChannels[activeWorkshopChannel] - : null; - const currentAgent = currentChannel?.agentId - ? agents.find((a) => a.id === currentChannel.agentId) || - systemAgents.find((a) => a.id === currentChannel.agentId) - : null; - const messages: Message[] = currentChannel?.messages || []; - const connected = currentChannel?.connected || false; - const error = currentChannel?.error || null; - const responding = currentChannel?.responding || false; - - // Scroll management - const scrollToBottom = useCallback( - (force = false) => { - if (!autoScroll && !force) return; - setTimeout(() => { - messagesContainerRef.current?.scrollTo({ - top: messagesContainerRef.current.scrollHeight, - behavior: force ? "auto" : "smooth", - }); - }, 50); - }, - [autoScroll], - ); - - const handleScroll = useCallback(() => { - if (messagesContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = - messagesContainerRef.current; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 80; - setAutoScroll(isNearBottom); - } - }, []); - - // Event handlers - const handleSendMessage = useCallback( - (inputMessage: string) => { - if (!inputMessage.trim() || !activeWorkshopChannel) return false; - if (responding) { - setSendBlocked(true); - // Auto-hide the hint after 2 seconds - window.setTimeout(() => setSendBlocked(false), 2000); - return false; - } - sendWorkshopMessage(inputMessage); - // Clear pending input after sending - if (pendingInput) { - setPendingInput(""); - } - setAutoScroll(true); - setTimeout(() => scrollToBottom(true), 100); - return true; - }, - [ - activeWorkshopChannel, - responding, - sendWorkshopMessage, - pendingInput, - setPendingInput, - scrollToBottom, - ], - ); - - const handleToggleHistory = useCallback(() => { - const newState = !showHistory; - setShowHistory(newState); - if (config.storageKeys.historyPinned) { - localStorage.setItem( - config.storageKeys.historyPinned, - newState.toString(), - ); - } - }, [showHistory, config.storageKeys.historyPinned]); - - const handleCloseHistory = useCallback(() => { - setShowHistory(false); - if (config.storageKeys.historyPinned) { - localStorage.setItem(config.storageKeys.historyPinned, "false"); - } - }, [config.storageKeys.historyPinned]); - - const handleSelectTopic = useCallback((_topicId: string) => { - // Keep history panel open when selecting a topic for better UX - }, []); - - const handleInputHeightChange = useCallback( - (height: number) => { - setInputHeight(height); - localStorage.setItem(config.storageKeys.inputHeight, height.toString()); - }, - [config.storageKeys.inputHeight], - ); - - const handleRetryConnection = useCallback(() => { - if (!currentChannel) return; - setIsRetrying(true); - connectToWorkshopChannel(currentChannel.sessionId, currentChannel.id); - setTimeout(() => { - setIsRetrying(false); - }, 2000); - }, [currentChannel, connectToWorkshopChannel]); - - const handleScrollToBottom = useCallback(() => { - setAutoScroll(true); - scrollToBottom(true); - }, [scrollToBottom]); - - // Effects - useEffect(() => { - if (autoScroll) { - scrollToBottom(); - } - }, [messages.length, autoScroll, scrollToBottom]); - - // Fetch providers on mount if not already loaded - useEffect(() => { - if (llmProviders.length === 0) { - fetchMyProviders().catch((error) => { - console.error("Failed to fetch providers:", error); - }); - } - }, [llmProviders.length, fetchMyProviders]); - - // Fetch system agents on mount if not already loaded - useEffect(() => { - if (systemAgents.length === 0) { - fetchSystemAgents().catch((error) => { - console.error("Failed to fetch system agents:", error); - }); - } - }, [systemAgents.length, fetchSystemAgents]); - - // Fetch workshop history on mount (non-blocking) - useEffect(() => { - // Don't block workshop initialization if history fetching fails - fetchWorkshopHistory().catch((error) => { - console.error("Failed to fetch workshop history:", error); - }); - }, [fetchWorkshopHistory]); - // Auto-switch to correct system agent channel for this panel - useEffect(() => { - if (systemAgents.length > 0) { - const targetSystemAgent = systemAgents.find( - (agent) => agent.id === config.systemAgentId, - ); - if (targetSystemAgent) { - // Check if we need to create/switch to the correct channel for this panel - const needsCorrectChannel = - !activeWorkshopChannel || - (currentChannel && - currentChannel.agentId !== config.systemAgentId && - // Only switch if current agent is a system agent (not user's regular/graph agent) - // This preserves user's regular/graph agent selections while allowing panel switching - (currentChannel.agentId === - "00000000-0000-0000-0000-000000000001" || - currentChannel.agentId === - "00000000-0000-0000-0000-000000000002")); - - if (needsCorrectChannel) { - // Look for existing channel with this system agent first - const existingChannel = Object.values(workshopChannels).find( - (channel) => channel.agentId === config.systemAgentId, - ); - - if (existingChannel) { - // Switch to existing channel for this system agent - console.log( - `Switching to existing channel for system agent: ${config.systemAgentId}`, - ); - activateWorkshopChannel(existingChannel.id).catch( - (error: Error) => { - console.error("Failed to activate existing channel:", error); - }, - ); - } else { - // Create new channel for this system agent - console.log( - `Creating new workshop channel for system agent: ${config.systemAgentId}`, - ); - createDefaultWorkshopChannel(config.systemAgentId).catch( - (error: Error) => { - console.error( - "Failed to create default workshop channel with system agent:", - error, - ); - }, - ); - } - } - } - } - }, [ - systemAgents, - config.systemAgentId, - createDefaultWorkshopChannel, - activeWorkshopChannel, - currentChannel, - workshopChannels, - activateWorkshopChannel, - ]); - - useEffect(() => { - const container = messagesContainerRef.current; - if (container) { - setAutoScroll(true); - // Force scroll to bottom on channel change - setTimeout(() => { - if (messagesContainerRef.current) { - messagesContainerRef.current.scrollTop = - messagesContainerRef.current.scrollHeight; - } - }, 50); - - container.addEventListener("scroll", handleScroll, { passive: true }); - return () => container.removeEventListener("scroll", handleScroll); - } - }, [activeWorkshopChannel, handleScroll]); - - return { - // State - autoScroll, - isRetrying, - showHistory, - inputHeight, - sendBlocked, - - // Computed - currentChannel, - currentAgent, - messages, - connected, - error, - responding, - - // Refs - messagesEndRef, - messagesContainerRef, - - // Handlers - handleSendMessage, - handleToggleHistory, - handleCloseHistory, - handleSelectTopic, - handleInputHeightChange, - handleRetryConnection, - handleScrollToBottom, - handleScroll, - - // Store values - activeChatChannel: activeWorkshopChannel, - notification, - closeNotification, - pendingInput, - updateTopicName: updateWorkshopTopicName, - }; -} diff --git a/web/src/hooks/useXyzenChat.ts b/web/src/hooks/useXyzenChat.ts index f67f9fcb..d3c36597 100644 --- a/web/src/hooks/useXyzenChat.ts +++ b/web/src/hooks/useXyzenChat.ts @@ -3,8 +3,8 @@ import type { Message } from "@/store/types"; import { useCallback, useEffect, useRef, useState } from "react"; export interface XyzenChatConfig { - theme: "indigo" | "purple"; - systemAgentId: string; + theme: "indigo"; + systemAgentTag: string; storageKeys: { inputHeight: string; historyPinned?: string; @@ -41,12 +41,10 @@ export function useXyzenChat(config: XyzenChatConfig) { activeChatChannel, channels, agents, - systemAgents, sendMessage, connectToChannel, updateTopicName, fetchMyProviders, - fetchSystemAgents, createDefaultChannel, activateChannel, llmProviders, @@ -83,8 +81,7 @@ export function useXyzenChat(config: XyzenChatConfig) { // Computed values const currentChannel = activeChatChannel ? channels[activeChatChannel] : null; const currentAgent = currentChannel?.agentId - ? agents.find((a) => a.id === currentChannel.agentId) || - systemAgents.find((a) => a.id === currentChannel.agentId) + ? agents.find((a) => a.id === currentChannel.agentId) : null; const messages: Message[] = currentChannel?.messages || []; const connected = currentChannel?.connected || false; @@ -203,46 +200,35 @@ export function useXyzenChat(config: XyzenChatConfig) { } }, [llmProviders.length, fetchMyProviders]); - // Fetch system agents on mount if not already loaded - useEffect(() => { - if (systemAgents.length === 0) { - fetchSystemAgents().catch((error) => { - console.error("Failed to fetch system agents:", error); - }); - } - }, [systemAgents.length, fetchSystemAgents]); - // Auto-switch to correct system agent channel for this panel useEffect(() => { if (chatHistoryLoading) return; - if (systemAgents.length > 0) { - const targetSystemAgent = systemAgents.find( - (agent) => agent.id === config.systemAgentId, + if (agents.length > 0) { + const targetSystemAgent = agents.find((agent) => + agent.tags?.includes(config.systemAgentTag), ); if (targetSystemAgent) { // Check if we need to create/switch to the correct channel for this panel const needsCorrectChannel = !activeChatChannel || (currentChannel && - currentChannel.agentId !== config.systemAgentId && - // Only switch if current agent is a system agent (not user's regular/graph agent) - // This preserves user's regular/graph agent selections while allowing panel switching - (currentChannel.agentId === - "00000000-0000-0000-0000-000000000001" || - currentChannel.agentId === - "00000000-0000-0000-0000-000000000002")); + currentChannel.agentId !== targetSystemAgent.id && + // Only switch if current agent is a default system agent clone + agents + .find((a) => a.id === currentChannel.agentId) + ?.tags?.some((t) => t.startsWith("default_"))); if (needsCorrectChannel) { // Look for existing channel with this system agent first const existingChannel = Object.values(channels).find( - (channel) => channel.agentId === config.systemAgentId, + (channel) => channel.agentId === targetSystemAgent.id, ); if (existingChannel) { // Switch to existing channel for this system agent console.log( - `Switching to existing channel for system agent: ${config.systemAgentId}`, + `Switching to existing channel for default agent: ${targetSystemAgent.name}`, ); activateChannel(existingChannel.id).catch((error) => { console.error("Failed to activate existing channel:", error); @@ -252,9 +238,9 @@ export function useXyzenChat(config: XyzenChatConfig) { if (isCreatingChannelRef.current) return; isCreatingChannelRef.current = true; console.log( - `Creating new channel for system agent: ${config.systemAgentId}`, + `Creating new channel for default agent: ${targetSystemAgent.name}`, ); - createDefaultChannel(config.systemAgentId) + createDefaultChannel(targetSystemAgent.id) .catch((error) => { console.error( "Failed to create default channel with system agent:", @@ -269,8 +255,8 @@ export function useXyzenChat(config: XyzenChatConfig) { } } }, [ - systemAgents, - config.systemAgentId, + agents, + config.systemAgentTag, createDefaultChannel, activeChatChannel, currentChannel, diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 3fc89fbc..19a4a412 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -39,7 +39,6 @@ export const useXyzen = create()( user: state.user, // 持久化用户数据 backendUrl: state.backendUrl, // 🔥 修复:持久化 backendUrl 避免使用空字符串 activeChatChannel: state.activeChatChannel, - activeWorkshopChannel: state.activeWorkshopChannel, }), }, ), diff --git a/web/src/store/slices/agentSlice.ts b/web/src/store/slices/agentSlice.ts index 0c77e24e..fe850202 100644 --- a/web/src/store/slices/agentSlice.ts +++ b/web/src/store/slices/agentSlice.ts @@ -12,15 +12,11 @@ export interface AgentSlice { officialAgents: Agent[]; officialAgentsLoading: boolean; hiddenGraphAgentIds: string[]; - systemAgents: Agent[]; - systemAgentsLoading: boolean; + fetchAgents: () => Promise; fetchPublishedGraphAgents: () => Promise; fetchOfficialGraphAgents: () => Promise; - fetchSystemAgents: () => Promise; - getSystemChatAgent: () => Promise; - getSystemWorkshopAgent: () => Promise; - syncSystemAgentMcps: () => Promise; + isCreatingAgent: boolean; createAgent: (agent: Omit) => Promise; createGraphAgent: (graphAgent: GraphAgentCreate) => Promise; @@ -40,13 +36,12 @@ export interface AgentSlice { // Helper methods for filtering by type getRegularAgents: () => Agent[]; getGraphAgents: () => Agent[]; - getSystemAgents: () => Agent[]; + // Debug helper getAgentStats: () => { total: number; regular: number; graph: number; - system: number; regularAgents: { id: string; name: string }[]; graphAgents: { id: string; name: string; is_published?: boolean }[]; }; @@ -109,8 +104,7 @@ export const createAgentSlice: StateCreator< officialAgents: [], officialAgentsLoading: false, hiddenGraphAgentIds: loadHiddenGraphAgentIds(), - systemAgents: [], - systemAgentsLoading: false, + isCreatingAgent: false, fetchAgents: async () => { set({ agentsLoading: true }); @@ -490,68 +484,6 @@ export const createAgentSlice: StateCreator< saveHiddenGraphAgentIds(get().hiddenGraphAgentIds); // Note: Don't call fetchAgents() here as it might override our changes }, - fetchSystemAgents: async () => { - set({ systemAgentsLoading: true }); - try { - const response = await fetch( - `${get().backendUrl}/xyzen/api/v1/agents/system/all`, - { - headers: createAuthHeaders(), - }, - ); - if (!response.ok) { - throw new Error("Failed to fetch system agents"); - } - const systemAgents: Agent[] = await response.json(); - // Do not auto-attach any MCP servers; show exactly what backend returns - set({ systemAgents, systemAgentsLoading: false }); - } catch (error) { - console.error("Failed to fetch system agents:", error); - set({ systemAgentsLoading: false }); - throw error; - } - }, - getSystemChatAgent: async () => { - try { - const response = await fetch( - `${get().backendUrl}/xyzen/api/v1/agents/system/chat`, - { - headers: createAuthHeaders(), - }, - ); - if (!response.ok) { - throw new Error("Failed to fetch system chat agent"); - } - const agent = await response.json(); - return agent; - } catch (error) { - console.error("Failed to fetch system chat agent:", error); - throw error; - } - }, - getSystemWorkshopAgent: async () => { - try { - const response = await fetch( - `${get().backendUrl}/xyzen/api/v1/agents/system/workshop`, - { - headers: createAuthHeaders(), - }, - ); - if (!response.ok) { - throw new Error("Failed to fetch system workshop agent"); - } - const agent = await response.json(); - return agent; - } catch (error) { - console.error("Failed to fetch system workshop agent:", error); - throw error; - } - }, - // Sync system agents with their default MCPs in the backend - syncSystemAgentMcps: async () => { - // No-op: we've removed auto-syncing of system agent MCPs - return; - }, // Helper methods for filtering by agent type getRegularAgents: () => { return get().agents.filter((agent) => agent.agent_type === "regular"); @@ -559,24 +491,16 @@ export const createAgentSlice: StateCreator< getGraphAgents: () => { return get().agents.filter((agent) => agent.agent_type === "graph"); }, - getSystemAgents: () => { - return get().systemAgents; - }, // Debug helper to verify agent types and counts getAgentStats: () => { const { agents } = get(); const regular = agents.filter((agent) => agent.agent_type === "regular"); const graph = agents.filter((agent) => agent.agent_type === "graph"); - const system = agents.filter( - (agent) => - agent.agent_type === "builtin" || agent.agent_type === "system", - ); return { total: agents.length, regular: regular.length, graph: graph.length, - system: system.length, regularAgents: regular.map((a) => ({ id: a.id, name: a.name })), graphAgents: graph.map((a) => ({ id: a.id, diff --git a/web/src/store/slices/chatSlice.ts b/web/src/store/slices/chatSlice.ts index 7e1cc7d8..e7183252 100644 --- a/web/src/store/slices/chatSlice.ts +++ b/web/src/store/slices/chatSlice.ts @@ -149,12 +149,6 @@ export interface ChatSlice { chatHistoryLoading: boolean; channels: Record; - // Workshop panel state - activeWorkshopChannel: string | null; - workshopHistory: ChatHistoryItem[]; - workshopHistoryLoading: boolean; - workshopChannels: Record; - // Notification state notification: { isOpen: boolean; @@ -191,19 +185,6 @@ export interface ChatSlice { model: string, ) => Promise; - // Workshop panel methods - setActiveWorkshopChannel: (channelUUID: string | null) => void; - fetchWorkshopHistory: () => Promise; - togglePinWorkshopChat: (chatId: string) => void; - activateWorkshopChannel: (topicId: string) => Promise; - connectToWorkshopChannel: (sessionId: string, topicId: string) => void; - disconnectFromWorkshopChannel: () => void; - sendWorkshopMessage: (message: string) => void; - createDefaultWorkshopChannel: (agentId?: string) => Promise; - updateWorkshopTopicName: (topicId: string, newName: string) => Promise; - deleteWorkshopTopic: (topicId: string) => Promise; - clearWorkshopSessionTopics: (sessionId: string) => Promise; - // Tool call confirmation methods confirmToolCall: (channelId: string, toolCallId: string) => void; cancelToolCall: (channelId: string, toolCallId: string) => void; @@ -236,8 +217,7 @@ export const createChatSlice: StateCreator< if (!agentId) return "通用助理"; const state = get(); - const allAgents = [...state.agents, ...state.systemAgents]; - const agent = allAgents.find((a) => a.id === agentId); + const agent = state.agents.find((a) => a.id === agentId); return agent?.name || "通用助理"; }; @@ -249,12 +229,7 @@ export const createChatSlice: StateCreator< chatHistoryLoading: true, channels: {}, - // Workshop panel state - activeWorkshopChannel: null, - workshopHistory: [], - workshopHistoryLoading: true, - workshopChannels: {}, - + // Notification state notification: null, setActiveChatChannel: (channelId) => set({ activeChatChannel: channelId }), @@ -1082,7 +1057,7 @@ export const createChatSlice: StateCreator< ); try { const state = get(); - const agent = [...state.agents, ...state.systemAgents].find( + const agent = state.agents.find( (a) => a.id === existingSession.agent_id, ); @@ -1182,9 +1157,7 @@ export const createChatSlice: StateCreator< // No existing session found, create a new session // Get agent data to include MCP servers const state = get(); - const agent = [...state.agents, ...state.systemAgents].find( - (a) => a.id === agentId, - ); + const agent = state.agents.find((a) => a.id === agentId); const sessionPayload: Record = { name: "New Session", @@ -1514,20 +1487,6 @@ export const createChatSlice: StateCreator< state.channels[activeChannelId].google_search_enabled = updatedSession.google_search_enabled; } - - // Also update workshop channel if applicable - const activeWorkshopId = state.activeWorkshopChannel; - if ( - activeWorkshopId && - state.workshopChannels[activeWorkshopId]?.sessionId === sessionId - ) { - state.workshopChannels[activeWorkshopId].provider_id = - updatedSession.provider_id; - state.workshopChannels[activeWorkshopId].model = - updatedSession.model; - state.workshopChannels[activeWorkshopId].google_search_enabled = - updatedSession.google_search_enabled; - } }); } catch (error) { console.error("Failed to update session config:", error); @@ -1557,16 +1516,6 @@ export const createChatSlice: StateCreator< state.channels[activeChannelId].model = model; } - // Also update workshop channel if applicable - const activeWorkshopId = state.activeWorkshopChannel; - if ( - activeWorkshopId && - state.workshopChannels[activeWorkshopId]?.sessionId === sessionId - ) { - state.workshopChannels[activeWorkshopId].provider_id = providerId; - state.workshopChannels[activeWorkshopId].model = model; - } - // Update all channels that belong to this session Object.keys(state.channels).forEach((channelId) => { if (state.channels[channelId].sessionId === sessionId) { @@ -1574,13 +1523,6 @@ export const createChatSlice: StateCreator< state.channels[channelId].model = model; } }); - - Object.keys(state.workshopChannels).forEach((channelId) => { - if (state.workshopChannels[channelId].sessionId === sessionId) { - state.workshopChannels[channelId].provider_id = providerId; - state.workshopChannels[channelId].model = model; - } - }); }); } catch (error) { console.error("Failed to update session provider and model:", error); @@ -1592,269 +1534,6 @@ export const createChatSlice: StateCreator< } }, - // Workshop panel methods - setActiveWorkshopChannel: (channelId) => - set({ activeWorkshopChannel: channelId }), - - fetchWorkshopHistory: async () => { - // Reuse the regular fetchChatHistory logic - the backend doesn't have separate workshop endpoints - // We'll filter the results in the UI layer if needed - console.log( - "ChatSlice: Fetching workshop history (reusing chat history endpoints)...", - ); - - try { - set({ workshopHistoryLoading: true }); - - // Call the regular fetchChatHistory which fetches all sessions and topics - await get().fetchChatHistory(); - - // Copy relevant data to workshop state for UI separation - // In practice, workshopChannels and chatChannels can reference the same data - // The separation is maintained through activeWorkshopChannel vs activeChatChannel - const { channels, chatHistory } = get(); - - set({ - workshopChannels: channels, - workshopHistory: chatHistory, - workshopHistoryLoading: false, - }); - - console.log("ChatSlice: Workshop history synced from chat history"); - } catch (error) { - console.error("ChatSlice: Error fetching workshop history:", error); - set({ workshopHistoryLoading: false }); - } - }, - - togglePinWorkshopChat: (chatId: string) => { - set((state: ChatSlice) => { - const historyItem = state.workshopHistory.find( - (item) => item.id === chatId, - ); - if (historyItem) { - historyItem.isPinned = !historyItem.isPinned; - } - }); - }, - - activateWorkshopChannel: async (topicId: string) => { - // Reuse the regular activateChannel logic but set workshop state - const { workshopChannels, activeWorkshopChannel } = get(); - - if ( - topicId === activeWorkshopChannel && - workshopChannels[topicId]?.connected - ) { - return; - } - - console.log(`Activating workshop channel: ${topicId}`); - set({ activeWorkshopChannel: topicId }); - - // Call the regular activateChannel which handles fetching and connecting - // This will update the channels state, which we sync to workshopChannels - await get().activateChannel(topicId); - - // Sync the channel to workshop state - const { channels } = get(); - if (channels[topicId]) { - set((state: ChatSlice) => { - state.workshopChannels[topicId] = channels[topicId]; - }); - } - }, - - connectToWorkshopChannel: (sessionId: string, topicId: string) => { - console.log( - `Connecting to workshop channel: ${topicId} (reusing chat connection logic)`, - ); - - // Reuse the regular connectToChannel which handles WebSocket connection - get().connectToChannel(sessionId, topicId); - - // Sync the channel state to workshop state - const { channels } = get(); - if (channels[topicId]) { - set((state: ChatSlice) => { - state.workshopChannels[topicId] = channels[topicId]; - }); - } - }, - - disconnectFromWorkshopChannel: () => { - const { activeWorkshopChannel } = get(); - if (activeWorkshopChannel) { - console.log( - `Disconnecting from workshop channel: ${activeWorkshopChannel}`, - ); - - // Reuse the regular disconnectFromChannel logic - get().disconnectFromChannel(); - - // Update workshop state - set((state: ChatSlice) => { - if (state.workshopChannels[activeWorkshopChannel]) { - state.workshopChannels[activeWorkshopChannel].connected = false; - } - }); - } - }, - - sendWorkshopMessage: (message: string) => { - const { activeWorkshopChannel } = get(); - if (activeWorkshopChannel) { - // Update workshop channel responding state - set((state: ChatSlice) => { - const channel = state.workshopChannels[activeWorkshopChannel]; - if (channel) channel.responding = true; - }); - - // Reuse the regular sendMessage logic - get().sendMessage(message); - - // 🔧 FIX: Sync messages from chat channel to workshop channel - // This ensures workshop channel gets the same messages as the underlying chat channel - const syncMessages = () => { - const currentState = get(); - const chatChannel = - currentState.channels[ - currentState.activeChatChannel || activeWorkshopChannel - ]; - const workshopChannel = - currentState.workshopChannels[activeWorkshopChannel]; - - if (chatChannel && workshopChannel) { - set((state: ChatSlice) => { - // Sync all messages from chat channel to workshop channel - state.workshopChannels[activeWorkshopChannel].messages = [ - ...chatChannel.messages, - ]; - // Sync responding state - state.workshopChannels[activeWorkshopChannel].responding = - chatChannel.responding; - // Sync connection state - state.workshopChannels[activeWorkshopChannel].connected = - chatChannel.connected; - // Sync error state - state.workshopChannels[activeWorkshopChannel].error = - chatChannel.error; - }); - } - }; - - // Sync immediately and set up periodic syncing during message processing - syncMessages(); - - // Set up periodic syncing to catch real-time message updates - const syncInterval = setInterval(() => { - const state = get(); - if (!state.workshopChannels[activeWorkshopChannel]?.responding) { - // Stop syncing when no longer responding - clearInterval(syncInterval); - return; - } - syncMessages(); - }, 100); // Sync every 100ms while responding - - // Cleanup after 30 seconds as failsafe - setTimeout(() => clearInterval(syncInterval), 30000); - } - }, - - createDefaultWorkshopChannel: async (agentId) => { - try { - const agentIdParam = agentId || "00000000-0000-0000-0000-000000000002"; // Default workshop agent - console.log( - `Creating default workshop channel for agent: ${agentIdParam}`, - ); - - // Reuse the regular createDefaultChannel logic - await get().createDefaultChannel(agentIdParam); - - // After creation, sync the new channel to workshop state - const { activeChatChannel, channels, chatHistory } = get(); - - if (activeChatChannel && channels[activeChatChannel]) { - set((state: XyzenState) => { - // Copy the newly created channel to workshop state - state.workshopChannels[activeChatChannel] = - channels[activeChatChannel]; - state.workshopHistory = chatHistory; - state.activeWorkshopChannel = activeChatChannel; - // Clear the chat active channel since this is for workshop - state.activeChatChannel = null; - }); - - console.log(`Workshop channel created: ${activeChatChannel}`); - } - } catch (error) { - console.error("Failed to create default workshop channel:", error); - get().showNotification( - "创建失败", - "无法创建新的工作坊会话,请稍后重试", - "error", - ); - } - }, - - updateWorkshopTopicName: async (topicId: string, newName: string) => { - // Reuse the regular updateTopicName logic - await get().updateTopicName(topicId, newName); - - // Sync the update to workshop state - set((state: ChatSlice) => { - if (state.workshopChannels[topicId]) { - state.workshopChannels[topicId].title = newName; - } - - const historyItem = state.workshopHistory.find( - (item) => item.id === topicId, - ); - if (historyItem) { - historyItem.title = newName; - } - }); - - console.log(`Workshop topic ${topicId} name updated to: ${newName}`); - }, - - deleteWorkshopTopic: async (topicId: string) => { - // Reuse the regular deleteTopic logic - await get().deleteTopic(topicId); - - // Sync the deletion to workshop state - set((state: XyzenState) => { - delete state.workshopChannels[topicId]; - - state.workshopHistory = state.workshopHistory.filter( - (item) => item.id !== topicId, - ); - - if (state.activeWorkshopChannel === topicId) { - const nextTopic = state.workshopHistory[0]; - if (nextTopic) { - state.activeWorkshopChannel = nextTopic.id; - get().activateWorkshopChannel(nextTopic.id); - } else { - state.activeWorkshopChannel = null; - } - } - }); - - console.log(`Workshop topic ${topicId} deleted`); - }, - - clearWorkshopSessionTopics: async (sessionId: string) => { - // Reuse the regular clearSessionTopics logic - await get().clearSessionTopics(sessionId); - - // Refresh workshop history to sync with chat history - await get().fetchWorkshopHistory(); - - console.log(`Workshop session ${sessionId} topics cleared`); - }, - // Tool call confirmation methods confirmToolCall: (channelId: string, toolCallId: string) => { // Send confirmation to backend via WebSocket @@ -1914,10 +1593,6 @@ export const createChatSlice: StateCreator< if (state.channels[channelId]) { state.channels[channelId].knowledgeContext = context || undefined; } - if (state.workshopChannels[channelId]) { - state.workshopChannels[channelId].knowledgeContext = - context || undefined; - } }); },