From 8bad8be6034f2d47933e981593c4e95c69fbf650 Mon Sep 17 00:00:00 2001 From: "xinquiry(SII)" <100398322+xinquiry@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:11:57 +0800 Subject: [PATCH 1/2] Feature/better agent community (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature/literature mcp (#192) * feat: literature-MCP 完整功能 * refactor: improve boolean parsing and logging in literature search functions * feat: enhance literature search functionality with improved query validation and detailed results formatting * refactor: rename oa_url to access_url in LiteratureWork model and related tests * feat: remove test-build workflow and update README for development setup * feat: tool cost system and PPTX image handling fixes (#193) * fix: prompt, factory * feat: enhanced ppt generation with image slides mode - Add image_slides mode for PPTX with full-bleed AI-generated images - Add ImageBlock.image_id field for referencing generated images - Add ImageSlideSpec for image-only slides - Add ImageFetcher service for fetching images from various sources - Reorganize knowledge module from single file to module structure - Move document utilities from app/mcp/ to app/tools/utils/documents/ - Resolve image_ids to storage URLs in async layer (operations.py) - Fix type errors and move tests to proper location Co-Authored-By: Claude * feat: implement the tool cost --------- Co-authored-by: Claude * fix: fix the first time calling knowledge tool error (#194) * fix: fix the wrong cache for second call of agent tools (#195) * feat: several improvements (#196) * fix: jump to latest topic when click agent * feat: allow more than one image for generate image * feat: allow user directly edit mcp in the chat-toolbar * feat: improve the frontend perf * feat: multiple UI improvements and fixes (#198) * fix: jump to latest topic when click agent * feat: allow more than one image for generate image * feat: allow user directly edit mcp in the chat-toolbar * feat: improve the frontend perf * fix: restore previous active topic when clicking agent Instead of always jumping to the latest topic, now tracks and restores the previously active topic for each agent when switching between them. Co-Authored-By: Claude * feat: add context menu to FocusedView agents and download button to lightbox - Add right-click context menu (edit/delete) to compact AgentListItem variant - Render context menu via portal to escape overflow:hidden containers - Add edit/delete handlers to FocusedView with AgentSettingsModal and ConfirmationModal - Add download button to image lightbox with smart filename detection Co-Authored-By: Claude * feat: add web_fetch tool bundled with web_search - Add web_fetch tool using Trafilatura for content extraction - Bundle web_fetch with web_search in frontend toolConfig - Group WEB_SEARCH_TOOLS for unified toggle behavior - Only load web_fetch when web_search is available (SearXNG enabled) - Update tool capabilities mapping for web_fetch Co-Authored-By: Claude --------- Co-authored-by: Claude * feat: fix the fork issue and implement the locked fork --------- Co-authored-by: Meng Junxing Co-authored-by: Harvey Co-authored-by: Claude --- service/app/agents/builtin/react.py | 4 + service/app/agents/factory.py | 77 ++++---- service/app/api/v1/agents.py | 24 ++- service/app/api/v1/marketplace.py | 6 +- .../marketplace/agent_marketplace_service.py | 36 +++- service/app/models/agent.py | 33 ++++ service/app/models/agent_marketplace.py | 17 ++ service/app/repos/agent.py | 9 + service/app/schemas/graph_config.py | 16 +- ...60144_add_config_visibility_and_config_.py | 66 +++++++ .../marketplace/AgentMarketplaceDetail.tsx | 187 +++++++++++------- .../marketplace/AgentMarketplaceManage.tsx | 116 ++++++++++- .../components/features/ForkAgentModal.tsx | 35 +++- .../components/features/PublishAgentModal.tsx | 71 +++++++ .../components/modals/AgentSettingsModal.tsx | 9 +- web/src/hooks/useXyzenChat.ts | 1 + web/src/i18n/locales/en/marketplace.json | 26 ++- web/src/i18n/locales/ja/marketplace.json | 26 ++- web/src/i18n/locales/zh/marketplace.json | 26 ++- web/src/service/marketplaceService.ts | 5 + web/src/types/agents.ts | 7 + web/src/types/graphConfig.ts | 70 +++++++ web/src/utils/agentConfigMapper.ts | 39 ++-- 23 files changed, 762 insertions(+), 144 deletions(-) create mode 100644 service/migrations/versions/90e892e60144_add_config_visibility_and_config_.py diff --git a/service/app/agents/builtin/react.py b/service/app/agents/builtin/react.py index 5031512b..717e95c2 100644 --- a/service/app/agents/builtin/react.py +++ b/service/app/agents/builtin/react.py @@ -24,6 +24,7 @@ NodeType, ToolNodeConfig, ) +from app.schemas.prompt_config import PromptConfig # ReAct Agent configuration using direct LLM and TOOL nodes (NOT subgraph) # This ensures proper streaming support through LangGraph's messages mode @@ -62,6 +63,9 @@ GraphEdgeConfig(from_node="tools", to_node="agent"), ], entry_point="agent", + prompt_config=PromptConfig( + custom_instructions="", # User should set their instructions here + ), metadata={ "builtin_key": "react", "display_name": "ReAct Agent", diff --git a/service/app/agents/factory.py b/service/app/agents/factory.py index 53dcc82c..382232f9 100644 --- a/service/app/agents/factory.py +++ b/service/app/agents/factory.py @@ -11,10 +11,13 @@ 3. Return CompiledStateGraph + AgentEventContext Config Resolution Order: -1. agent_config.graph_config exists → use it directly -2. agent_config.graph_config.metadata.builtin_key exists → use builtin config -3. agent_config.graph_config.metadata.system_agent_key exists → use builtin config (legacy) -4. No config → fall back to "react" builtin +1. agent_config.graph_config exists → use it as the source of truth + (builtin_key in metadata is ONLY used for analytics/UI, not to replace the config) +2. No config → fall back to "react" builtin + +IMPORTANT: The graph_config is always the single source of truth. This ensures +forked agents retain their customizations (custom prompts, tools, etc.) rather +than being replaced with the generic builtin config. The default agent is the "react" builtin agent. """ @@ -154,44 +157,37 @@ def _resolve_agent_config( Resolve which GraphConfig to use for an agent. Resolution order: - 1. agent_config has graph_config → use it, check for builtin_key/system_agent_key + 1. agent_config has graph_config → use it (graph_config is the source of truth) 2. agent_config is None or has no graph_config → use default builtin (react) + The builtin_key in metadata is ONLY used for analytics/UI purposes, NOT to replace + the agent's actual graph_config. This ensures forked agents retain their customizations. + Args: agent_config: Agent configuration from database (may be None) - system_prompt: System prompt to inject if using react agent + system_prompt: System prompt to inject into the config Returns: Tuple of (raw_config_dict, agent_type_key) - raw_config_dict: GraphConfig as dict (for version detection) - - agent_type_key: Agent type for events (e.g., "react", "deep_research") + - agent_type_key: Agent type for events (e.g., "react", "deep_research", "graph") """ from app.agents.builtin import get_builtin_config if agent_config and agent_config.graph_config: - # Agent has a graph_config + # Agent has a graph_config - use it as the source of truth raw_config = agent_config.graph_config metadata = raw_config.get("metadata", {}) - # Check for builtin_key or system_agent_key (legacy) - builtin_key = metadata.get("builtin_key") or metadata.get("system_agent_key") - - if builtin_key: - # This config references a builtin - use the builtin config - builtin_config = get_builtin_config(builtin_key) - if builtin_config: - # Apply system_prompt override for react - config_dict = builtin_config.model_dump() - if builtin_key == "react" and system_prompt: - config_dict = _inject_system_prompt(config_dict, system_prompt) - return config_dict, builtin_key + # Extract agent type key for analytics/UI, but DON'T replace the config + # This fixes the bug where forked agents lost their customizations + agent_type_key = metadata.get("builtin_key") or metadata.get("system_agent_key") or "graph" - # Builtin not found, use the provided config as-is - logger.warning(f"Builtin '{builtin_key}' not found, using provided config") - return raw_config, builtin_key or "graph" + # Inject system_prompt into the agent's actual config (not a builtin replacement) + if system_prompt: + raw_config = _inject_system_prompt(raw_config, system_prompt) - # Pure user-defined graph config - return raw_config, "graph" + return raw_config, agent_type_key # No agent config or no graph_config - use default builtin (react) builtin_config = get_builtin_config(DEFAULT_BUILTIN_AGENT) @@ -209,9 +205,12 @@ def _inject_system_prompt(config_dict: dict[str, Any], system_prompt: str) -> di """ Inject system_prompt into a graph config. - Handles both: - 1. Component nodes with stdlib:react - updates config_overrides - 2. LLM nodes - updates prompt_template + Handles ALL nodes that support system_prompt: + 1. Component nodes - updates config_overrides.system_prompt + 2. LLM nodes - updates llm_config.prompt_template + + This injects into ALL matching nodes (not just the first), ensuring + forked agents with multiple components all receive the custom prompt. Args: config_dict: GraphConfig as dict @@ -220,29 +219,23 @@ def _inject_system_prompt(config_dict: dict[str, Any], system_prompt: str) -> di Returns: Modified config dict with system_prompt injected """ - # Deep copy to avoid mutating original import copy config = copy.deepcopy(config_dict) - # Find nodes and inject system_prompt (first matching node only) for node in config.get("nodes", []): - # Handle component nodes (existing behavior) + # Inject into ALL component nodes that support system_prompt if node.get("type") == "component": - comp_config = node.get("component_config", {}) - comp_ref = comp_config.get("component_ref", {}) - - # Only inject into react components - if comp_ref.get("key") == "react": - overrides = comp_config.setdefault("config_overrides", {}) - overrides["system_prompt"] = system_prompt - break + comp_config = node.setdefault("component_config", {}) + overrides = comp_config.setdefault("config_overrides", {}) + overrides["system_prompt"] = system_prompt + # Continue - don't break (inject into all components) - # Handle LLM nodes + # Inject into ALL LLM nodes elif node.get("type") == "llm": - llm_config = node.get("llm_config", {}) + llm_config = node.setdefault("llm_config", {}) llm_config["prompt_template"] = system_prompt - break + # Continue - don't break (inject into all LLM nodes) return config diff --git a/service/app/api/v1/agents.py b/service/app/api/v1/agents.py index 70f4660f..bdcd0b38 100644 --- a/service/app/api/v1/agents.py +++ b/service/app/api/v1/agents.py @@ -24,7 +24,7 @@ from app.core.system_agent import SystemAgentManager from app.infra.database import get_session from app.middleware.auth import get_current_user -from app.models.agent import AgentCreate, AgentRead, AgentReadWithDetails, AgentScope, AgentUpdate +from app.models.agent import AgentCreate, AgentRead, AgentReadWithDetails, AgentScope, AgentUpdate, ConfigVisibility from app.models.session_stats import AgentStatsAggregated, DailyStatsResponse, YesterdaySummary from app.repos import AgentRepository, KnowledgeSetRepository, ProviderRepository from app.repos.agent_marketplace import AgentMarketplaceRepository @@ -198,6 +198,13 @@ async def create_agent_from_template( # Export config for forking graph_config_dict = builtin_config.model_dump() + # Simplify prompt_config to only show custom_instructions + # (hide verbose PromptConfig defaults from user) + if graph_config_dict.get("prompt_config"): + graph_config_dict["prompt_config"] = { + "custom_instructions": graph_config_dict["prompt_config"].get("custom_instructions", "") + } + # Add builtin_key to metadata so the agent can reference the builtin at runtime if "metadata" not in graph_config_dict: graph_config_dict["metadata"] = {} @@ -431,9 +438,17 @@ async def get_agent( agent_repo = AgentRepository(db) mcp_servers = await agent_repo.get_agent_mcp_servers(agent.id) + # Check if user is the owner + is_owner = agent.user_id == user_id + # Create agent dict with MCP servers agent_dict = agent.model_dump() agent_dict["mcp_servers"] = mcp_servers + + # Hide config for non-owners when visibility is hidden + if not is_owner and agent.config_visibility == ConfigVisibility.HIDDEN: + agent_dict["graph_config"] = None + return AgentReadWithDetails(**agent_dict) except ErrCodeError as e: raise handle_auth_error(e) @@ -472,6 +487,13 @@ async def update_agent( if agent.scope == AgentScope.SYSTEM: raise HTTPException(status_code=403, detail="Cannot modify system agents") + # Block config editing for non-editable agents + if not agent.config_editable and agent_data.graph_config is not None: + raise HTTPException( + status_code=403, + detail="This agent's configuration cannot be edited", + ) + if agent_data.provider_id is not None: provider_repo = ProviderRepository(db) provider = await provider_repo.get_provider_by_id(agent_data.provider_id) diff --git a/service/app/api/v1/marketplace.py b/service/app/api/v1/marketplace.py index 650f19b4..c2a99262 100644 --- a/service/app/api/v1/marketplace.py +++ b/service/app/api/v1/marketplace.py @@ -23,7 +23,7 @@ from app.core.marketplace import AgentMarketplaceService from app.infra.database import get_session from app.middleware.auth import get_current_user -from app.models.agent import AgentRead +from app.models.agent import AgentRead, ForkMode from app.models.agent_marketplace import ( AgentMarketplaceRead, AgentMarketplaceReadWithSnapshot, @@ -42,6 +42,7 @@ class PublishRequest(BaseModel): commit_message: str is_published: bool = True readme: str | None = None + fork_mode: ForkMode = ForkMode.EDITABLE class PublishResponse(BaseModel): @@ -59,6 +60,7 @@ class UpdateListingRequest(BaseModel): readme: str | None = None is_published: bool | None = None + fork_mode: ForkMode | None = None class PublishVersionRequest(BaseModel): @@ -151,6 +153,7 @@ async def publish_agent( commit_message=request.commit_message, is_published=request.is_published, readme=request.readme, + fork_mode=request.fork_mode, ) if not listing: raise HTTPException(status_code=404, detail="Marketplace listing not found") @@ -208,6 +211,7 @@ async def update_listing( update_data = AgentMarketplaceUpdate( readme=request.readme, is_published=request.is_published, + fork_mode=request.fork_mode, ) updated_listing = await marketplace_service.update_listing_details(marketplace_id, update_data) diff --git a/service/app/core/marketplace/agent_marketplace_service.py b/service/app/core/marketplace/agent_marketplace_service.py index 941094b6..25736038 100644 --- a/service/app/core/marketplace/agent_marketplace_service.py +++ b/service/app/core/marketplace/agent_marketplace_service.py @@ -6,7 +6,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.storage import FileScope -from app.models.agent import Agent, AgentCreate, AgentScope +from app.models.agent import Agent, AgentCreate, AgentScope, ConfigVisibility, ForkMode from app.models.agent_marketplace import AgentMarketplace, AgentMarketplaceCreate, AgentMarketplaceUpdate from app.models.agent_snapshot import AgentSnapshot, AgentSnapshotCreate from app.models.file import FileCreate @@ -102,7 +102,12 @@ async def create_snapshot_from_agent(self, agent: Agent, commit_message: str) -> return snapshot async def publish_agent( - self, agent: Agent, commit_message: str, is_published: bool = True, readme: str | None = None + self, + agent: Agent, + commit_message: str, + is_published: bool = True, + readme: str | None = None, + fork_mode: ForkMode = ForkMode.EDITABLE, ) -> AgentMarketplace | None: """ Publishes an agent to the marketplace or updates an existing listing. @@ -112,6 +117,7 @@ async def publish_agent( commit_message: Description of changes. is_published: Whether to set the listing as published. readme: Optional markdown README content. + fork_mode: Access mode for forked agents. Returns: The marketplace listing. @@ -134,6 +140,7 @@ async def publish_agent( tags=agent.tags or [], is_published=is_published, readme=readme, + fork_mode=fork_mode, ) listing = await self.marketplace_repo.update_listing(existing_listing.id, update_data) @@ -154,6 +161,7 @@ async def publish_agent( avatar=agent.avatar, tags=agent.tags or [], readme=readme, + fork_mode=fork_mode, ) listing = await self.marketplace_repo.create_listing(listing_data) @@ -245,12 +253,25 @@ async def fork_agent(self, marketplace_id: UUID, user_id: str, fork_name: str | final_name = f"{base_name} ({counter})" counter += 1 + # Filter out default_* tags - those are for system agents only + # Forked agents should not be treated as system defaults + original_tags = config.get("tags", []) + forked_tags = [t for t in original_tags if not t.startswith("default_")] + + # Determine config access based on listing's fork_mode + if listing.fork_mode == ForkMode.EDITABLE: + config_visibility = ConfigVisibility.VISIBLE + config_editable = True + else: # LOCKED + config_visibility = ConfigVisibility.HIDDEN + config_editable = False + agent_create = AgentCreate( scope=AgentScope.USER, name=final_name, description=config.get("description"), avatar=config.get("avatar"), - tags=config.get("tags", []), + tags=forked_tags, model=config.get("model"), temperature=config.get("temperature"), prompt=config.get("prompt"), # Legacy field for backward compat @@ -259,6 +280,8 @@ async def fork_agent(self, marketplace_id: UUID, user_id: str, fork_name: str | knowledge_set_id=None, # Create empty knowledge set mcp_server_ids=[], # Will link compatible MCPs below graph_config=config.get("graph_config"), # Restore from snapshot + config_visibility=config_visibility, + config_editable=config_editable, ) # Create the forked agent @@ -630,12 +653,17 @@ async def pull_listing_update(self, agent_id: UUID, user_id: str) -> Agent: # We preserve the user's provider_id and knowledge_set_id if possible # but update core logic fields + # Filter out default_* tags - those are for system agents only + # Forked agents should not become system defaults when pulling updates + original_tags = config.get("tags", []) + filtered_tags = [t for t in original_tags if not t.startswith("default_")] + from app.models.agent import AgentUpdate update_data = AgentUpdate( description=config.get("description"), avatar=config.get("avatar"), - tags=config.get("tags", []), + tags=filtered_tags, model=config.get("model"), temperature=config.get("temperature"), prompt=config.get("prompt"), # Legacy field for backward compat diff --git a/service/app/models/agent.py b/service/app/models/agent.py index adcb80f6..7b295e9c 100644 --- a/service/app/models/agent.py +++ b/service/app/models/agent.py @@ -16,6 +16,20 @@ class AgentScope(StrEnum): USER = "user" +class ConfigVisibility(StrEnum): + """Controls whether the agent's graph_config is visible to users.""" + + VISIBLE = "visible" # Users can view graph_config + HIDDEN = "hidden" # graph_config is hidden from users + + +class ForkMode(StrEnum): + """Controls what access forked agents get.""" + + EDITABLE = "editable" # Forked agents get visible + editable config + LOCKED = "locked" # Forked agents get hidden + non-editable config + + class AgentBase(SQLModel): scope: AgentScope = Field( sa_column=sa.Column( @@ -50,6 +64,21 @@ class AgentBase(SQLModel): # Tools are configured via graph_config.tool_config.tool_filter graph_config: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + # Configuration access control + config_visibility: ConfigVisibility = Field( + default=ConfigVisibility.VISIBLE, + sa_column=sa.Column( + sa.Enum(*(v.value for v in ConfigVisibility), name="configvisibility", native_enum=True), + nullable=False, + server_default="visible", + ), + description="Whether the graph_config is visible to the user", + ) + config_editable: bool = Field( + default=True, + description="Whether the user can edit graph_config", + ) + class Agent(AgentBase, table=True): id: UUID = Field(default_factory=uuid4, primary_key=True, index=True) @@ -77,6 +106,8 @@ class AgentCreate(SQLModel): knowledge_set_id: UUID | None = Field(default=None) mcp_server_ids: list[UUID] = [] graph_config: dict[str, Any] | None = None + config_visibility: ConfigVisibility = ConfigVisibility.VISIBLE + config_editable: bool = True class AgentRead(AgentBase): @@ -101,3 +132,5 @@ class AgentUpdate(SQLModel): knowledge_set_id: UUID | None = None mcp_server_ids: list[UUID] | None = None graph_config: dict[str, Any] | None = None + config_visibility: ConfigVisibility | None = None + config_editable: bool | None = None diff --git a/service/app/models/agent_marketplace.py b/service/app/models/agent_marketplace.py index 07805e17..e536194f 100644 --- a/service/app/models/agent_marketplace.py +++ b/service/app/models/agent_marketplace.py @@ -2,9 +2,12 @@ from typing import TYPE_CHECKING from uuid import UUID, uuid4 +import sqlalchemy as sa from sqlalchemy import TIMESTAMP, Column from sqlmodel import JSON, Field, SQLModel +from app.models.agent import ForkMode + if TYPE_CHECKING: from .agent_snapshot import AgentSnapshotRead @@ -34,6 +37,17 @@ class AgentMarketplace(SQLModel, table=True): # Visibility control is_published: bool = Field(default=False, index=True) + # Fork access control + fork_mode: ForkMode = Field( + default=ForkMode.EDITABLE, + sa_column=sa.Column( + sa.Enum(*(v.value for v in ForkMode), name="forkmode", native_enum=True), + nullable=False, + server_default="editable", + ), + description="Access mode for forked agents", + ) + # Timestamps created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), @@ -60,6 +74,7 @@ class AgentMarketplaceCreate(SQLModel): avatar: str | None = None tags: list[str] = [] readme: str | None = None + fork_mode: ForkMode = ForkMode.EDITABLE class AgentMarketplaceRead(SQLModel): @@ -78,6 +93,7 @@ class AgentMarketplaceRead(SQLModel): forks_count: int views_count: int is_published: bool + fork_mode: ForkMode created_at: datetime updated_at: datetime first_published_at: datetime | None @@ -94,6 +110,7 @@ class AgentMarketplaceUpdate(SQLModel): tags: list[str] | None = None readme: str | None = None is_published: bool | None = None + fork_mode: ForkMode | None = None class AgentMarketplaceReadWithSnapshot(AgentMarketplaceRead): diff --git a/service/app/repos/agent.py b/service/app/repos/agent.py index 10236e47..921cd845 100644 --- a/service/app/repos/agent.py +++ b/service/app/repos/agent.py @@ -215,6 +215,15 @@ async def create_agent(self, agent_data: AgentCreate, user_id: str) -> Agent: graph_config = builtin_config.model_dump() + # Simplify prompt_config to only show custom_instructions + # (hide verbose PromptConfig defaults from user) + if graph_config.get("prompt_config"): + logger.info(f"Simplifying prompt_config. Before: {list(graph_config['prompt_config'].keys())}") + graph_config["prompt_config"] = { + "custom_instructions": graph_config["prompt_config"].get("custom_instructions", "") + } + logger.info(f"After simplification: {graph_config['prompt_config']}") + # Add builtin_key to metadata so the agent uses the builtin at runtime # This ensures consistent behavior between custom agents and template-based agents if "metadata" not in graph_config: diff --git a/service/app/schemas/graph_config.py b/service/app/schemas/graph_config.py index e467f6da..1cd3fc0f 100644 --- a/service/app/schemas/graph_config.py +++ b/service/app/schemas/graph_config.py @@ -17,6 +17,8 @@ from pydantic import BaseModel, Field +from app.schemas.prompt_config import PromptConfig + # --- Enums --- @@ -133,7 +135,13 @@ class StructuredOutputSchema(BaseModel): class LLMNodeConfig(BaseModel): """Configuration for LLM nodes.""" - prompt_template: str = Field(description="Jinja2 template for the prompt. Access state via {{ state.field_name }}") + prompt_template: str = Field( + default="", + description=( + "INTERNAL: Overwritten at runtime by system prompt builder. " + "Set your prompt in graph_config.prompt_config.custom_instructions instead." + ), + ) output_key: str = Field( default="response", description="State key to store the LLM response", @@ -342,6 +350,12 @@ class GraphConfig(BaseModel): description="Global tool configuration", ) + # Prompt configuration + prompt_config: PromptConfig | None = Field( + default=None, + description="System prompt configuration including custom_instructions", + ) + # Metadata metadata: dict[str, Any] = Field( default_factory=dict, diff --git a/service/migrations/versions/90e892e60144_add_config_visibility_and_config_.py b/service/migrations/versions/90e892e60144_add_config_visibility_and_config_.py new file mode 100644 index 00000000..98c2934f --- /dev/null +++ b/service/migrations/versions/90e892e60144_add_config_visibility_and_config_.py @@ -0,0 +1,66 @@ +"""Add config_visibility and config_editable to Agent, fork_mode to Marketplace + +Revision ID: 90e892e60144 +Revises: f5e0d3529c12 +Create Date: 2026-01-24 22:08:02.944553 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "90e892e60144" +down_revision: Union[str, Sequence[str], None] = "f5e0d3529c12" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Create enum types first (PostgreSQL requires this) + config_visibility_enum = sa.Enum("visible", "hidden", name="configvisibility") + config_visibility_enum.create(op.get_bind(), checkfirst=True) + + fork_mode_enum = sa.Enum("editable", "locked", name="forkmode") + fork_mode_enum.create(op.get_bind(), checkfirst=True) + + # Add columns to agent table + op.add_column( + "agent", + sa.Column( + "config_visibility", + config_visibility_enum, + server_default="visible", + nullable=False, + ), + ) + op.add_column( + "agent", + sa.Column("config_editable", sa.Boolean(), server_default="true", nullable=False), + ) + + # Add column to agentmarketplace table + op.add_column( + "agentmarketplace", + sa.Column( + "fork_mode", + fork_mode_enum, + server_default="editable", + nullable=False, + ), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # Drop columns first + op.drop_column("agentmarketplace", "fork_mode") + op.drop_column("agent", "config_editable") + op.drop_column("agent", "config_visibility") + + # Then drop enum types + sa.Enum(name="forkmode").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="configvisibility").drop(op.get_bind(), checkfirst=True) diff --git a/web/src/app/marketplace/AgentMarketplaceDetail.tsx b/web/src/app/marketplace/AgentMarketplaceDetail.tsx index 47a1b05d..d4e7098f 100644 --- a/web/src/app/marketplace/AgentMarketplaceDetail.tsx +++ b/web/src/app/marketplace/AgentMarketplaceDetail.tsx @@ -17,6 +17,7 @@ import { EyeIcon, HeartIcon, InformationCircleIcon, + LockClosedIcon, PencilIcon, } from "@heroicons/react/24/outline"; import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid"; @@ -177,9 +178,17 @@ export default function AgentMarketplaceDetail({ )}
-

- {listing.name} -

+
+

+ {listing.name} +

+ {listing.fork_mode === "locked" && ( + + + {t("marketplace.forkMode.locked")} + + )} +

{t("marketplace.detail.publishedBy")}{" "} @@ -330,80 +339,119 @@ export default function AgentMarketplaceDetail({ {/* Configuration Tab */} {activeTab === "config" && (

- {listing.snapshot ? ( - <> -
- - v{listing.snapshot.version} - - - {getAgentType( - listing.snapshot.configuration.graph_config, - )} - - - {listing.snapshot.commit_message} - + {/* Locked agent - hide config for non-owners */} + {listing.fork_mode === "locked" && !isOwner ? ( +
+
+
- - {/* Model */} - {listing.snapshot.configuration.model && ( -
-

- {t("marketplace.detail.config.model")} -

-

- {listing.snapshot.configuration.model} -

+

+ {t("marketplace.fork.lockedAgent")} +

+

+ {t("marketplace.detail.config.hidden")} +

+
+ ) : ( + <> + {/* Locked agent warning for owner */} + {listing.fork_mode === "locked" && isOwner && ( +
+
+ +
+

+ {t("marketplace.fork.lockedAgent")} +

+

+ {t( + "marketplace.detail.config.lockedOwnerNote", + )} +

+
+
)} - - {/* System Prompt */} - {getDisplayPrompt(listing.snapshot.configuration) && ( -
-

- {t("marketplace.detail.config.systemPrompt")} -

-
-
-                                {getDisplayPrompt(
-                                  listing.snapshot.configuration,
+                        {listing.snapshot ? (
+                          <>
+                            
+ + v{listing.snapshot.version} + + + {getAgentType( + listing.snapshot.configuration.graph_config, )} -
+ + + {listing.snapshot.commit_message} +
-
- )} - {/* MCP Servers in Configuration */} - {listing.snapshot.mcp_server_configs && - listing.snapshot.mcp_server_configs.length > 0 && ( -
-

- {t("marketplace.detail.config.mcpServers", { - count: - listing.snapshot.mcp_server_configs.length, - })} -

-
- {listing.snapshot.mcp_server_configs.map( - (mcp, index) => ( - - {mcp.name} - - ), - )} + {/* Model */} + {listing.snapshot.configuration.model && ( +
+

+ {t("marketplace.detail.config.model")} +

+

+ {listing.snapshot.configuration.model} +

-
- )} + )} + + {/* System Prompt */} + {getDisplayPrompt( + listing.snapshot.configuration, + ) && ( +
+

+ {t("marketplace.detail.config.systemPrompt")} +

+
+
+                                    {getDisplayPrompt(
+                                      listing.snapshot.configuration,
+                                    )}
+                                  
+
+
+ )} + + {/* MCP Servers in Configuration */} + {listing.snapshot.mcp_server_configs && + listing.snapshot.mcp_server_configs.length > + 0 && ( +
+

+ {t("marketplace.detail.config.mcpServers", { + count: + listing.snapshot.mcp_server_configs + .length, + })} +

+
+ {listing.snapshot.mcp_server_configs.map( + (mcp, index) => ( + + {mcp.name} + + ), + )} +
+
+ )} + + ) : ( +
+ +

{t("marketplace.detail.config.empty")}

+
+ )} - ) : ( -
- -

{t("marketplace.detail.config.empty")}

-
)}
)} @@ -653,6 +701,7 @@ export default function AgentMarketplaceDetail({ agentName={listing.name} agentDescription={listing.description || undefined} requirements={requirements} + forkMode={listing.fork_mode} onForkSuccess={handleForkSuccess} /> )} diff --git a/web/src/app/marketplace/AgentMarketplaceManage.tsx b/web/src/app/marketplace/AgentMarketplaceManage.tsx index d8bf0ada..b6a58bcd 100644 --- a/web/src/app/marketplace/AgentMarketplaceManage.tsx +++ b/web/src/app/marketplace/AgentMarketplaceManage.tsx @@ -5,6 +5,7 @@ import { PlateReadmeViewer } from "@/components/editor/PlateReadmeViewer"; import { AgentGraphEditor } from "@/components/editors/AgentGraphEditor"; import { JsonEditor } from "@/components/editors/JsonEditor"; import ConfirmationModal from "@/components/modals/ConfirmationModal"; +import { toast } from "sonner"; import { useListingHistory, useMarketplaceListing, @@ -12,7 +13,10 @@ import { useUnpublishAgent, } from "@/hooks/useMarketplace"; import type { AgentSnapshot } from "@/service/marketplaceService"; -import { marketplaceService } from "@/service/marketplaceService"; +import { + marketplaceService, + type ForkMode, +} from "@/service/marketplaceService"; import type { GraphConfig } from "@/types/graphConfig"; import { ArrowLeftIcon, @@ -26,6 +30,8 @@ import { EyeIcon, GlobeAltIcon, HeartIcon, + LockClosedIcon, + LockOpenIcon, PencilIcon, TrashIcon, } from "@heroicons/react/24/outline"; @@ -33,7 +39,6 @@ import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; interface AgentMarketplaceManageProps { marketplaceId: string; @@ -67,6 +72,7 @@ export default function AgentMarketplaceManage({ const [graphConfigError, setGraphConfigError] = useState(null); const [activeEditorTab, setActiveEditorTab] = useState(0); const [isSavingConfig, setIsSavingConfig] = useState(false); + const [isSavingForkMode, setIsSavingForkMode] = useState(false); const queryClient = useQueryClient(); @@ -177,6 +183,26 @@ export default function AgentMarketplaceManage({ ); }; + const handleForkModeChange = async (newForkMode: ForkMode) => { + if (!listing) return; + try { + setIsSavingForkMode(true); + await marketplaceService.updateListing(listing.id, { + fork_mode: newForkMode, + }); + // Invalidate queries to refresh data + queryClient.invalidateQueries({ + queryKey: ["marketplace", "listing", listing.id], + }); + toast.success(t("marketplace.manage.forkMode.success")); + } catch (error) { + console.error("Failed to update fork mode:", error); + toast.error(t("marketplace.manage.forkMode.error")); + } finally { + setIsSavingForkMode(false); + } + }; + // Configuration editing handlers const handleGraphConfigChange = useCallback((config: GraphConfig) => { setGraphConfig(config); @@ -246,13 +272,17 @@ export default function AgentMarketplaceManage({ pattern: "react", display_name: "ReAct Agent", }, + // Prompt stored in prompt_config.custom_instructions (not llm_config.prompt_template) + prompt_config: { + custom_instructions: "You are a helpful assistant.", + }, nodes: [ { id: "agent", name: "ReAct Agent", type: "llm", llm_config: { - prompt_template: "You are a helpful assistant.", + prompt_template: "", // Backend will inject from prompt_config tools_enabled: true, output_key: "response", }, @@ -814,6 +844,86 @@ export default function AgentMarketplaceManage({
+ {/* Fork Mode Settings Card */} +
+

+ {t("marketplace.manage.forkMode.title")} +

+
+ + + {isSavingForkMode && ( +
+ + {t("marketplace.manage.forkMode.saving")} +
+ )} +

+ {t("marketplace.manage.forkMode.help")} +

+
+
+ {/* Metadata Card */}

diff --git a/web/src/components/features/ForkAgentModal.tsx b/web/src/components/features/ForkAgentModal.tsx index 6c781dd9..b7b7d06b 100644 --- a/web/src/components/features/ForkAgentModal.tsx +++ b/web/src/components/features/ForkAgentModal.tsx @@ -9,8 +9,11 @@ import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, + LockClosedIcon, } from "@heroicons/react/24/outline"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ForkMode } from "@/service/marketplaceService"; interface ForkAgentModalProps { open: boolean; @@ -23,6 +26,7 @@ interface ForkAgentModalProps { knowledge_base: { name: string; file_count: number } | null; provider_needed: boolean; }; + forkMode: ForkMode; onForkSuccess?: (agentId: string) => void; } @@ -39,8 +43,10 @@ export default function ForkAgentModal({ agentName, agentDescription, requirements, + forkMode, onForkSuccess, }: ForkAgentModalProps) { + const { t } = useTranslation(); const [customName, setCustomName] = useState(`${agentName} (Fork)`); const [currentStep, setCurrentStep] = useState< "name" | "requirements" | "confirm" @@ -160,15 +166,30 @@ export default function ForkAgentModal({ {/* Step 1: Name */} {currentStep === "name" && (
-
-
- -
- Your forked agent will be completely independent. Changes - won't affect the original. + {forkMode === "locked" ? ( +
+
+ +
+

+ {t("marketplace.fork.lockedAgent")} +

+

+ {t("marketplace.fork.lockedAgentDescription")} +

+
-
+ ) : ( +
+
+ +
+ {t("marketplace.fork.editableDescription")} +
+
+
+ )} + {/* Fork Mode Selector */} + + +
+ + +
+

+ {t("marketplace.publish.forkMode.help")} +

+
+ {/* Preview Toggle */}
- {/* Publish to Marketplace Button - only show if agent exists */} - {agent && ( + {/* Publish to Marketplace Button - only show if agent exists and is not forked */} + {agent && !isForked && (
{/* Footer - Actions */} -
- setShowPublishModal(true)} - disabled={!agentToEdit.graph_config} - className="inline-flex items-center gap-2 rounded-md bg-purple-100 py-2 px-4 text-sm font-medium text-purple-700 hover:bg-purple-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 transition-colors" - title={ - !agentToEdit.graph_config - ? t("agents.actions.publishTooltip") - : t("agents.actions.publish") - } - > - - {t("agents.actions.publish")} - +
+ {!isForked && ( + setShowPublishModal(true)} + disabled={!agentToEdit.graph_config} + className="inline-flex items-center gap-2 rounded-md bg-purple-100 py-2 px-4 text-sm font-medium text-purple-700 hover:bg-purple-200 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 transition-colors" + title={ + !agentToEdit.graph_config + ? t("agents.actions.publishTooltip") + : t("agents.actions.publish") + } + > + + {t("agents.actions.publish")} + + )}
- {/* Publish to Marketplace Modal */} - ({ - id: s.id, - name: s.name, - description: s.description || undefined, - }))} - onPublishSuccess={(marketplaceId) => { - console.log("Agent published to marketplace:", marketplaceId); - setShowPublishModal(false); - }} - /> + {/* Publish to Marketplace Modal - only render for non-forked agents */} + {!isForked && ( + ({ + id: s.id, + name: s.name, + description: s.description || undefined, + }))} + onPublishSuccess={(marketplaceId) => { + console.log("Agent published to marketplace:", marketplaceId); + setShowPublishModal(false); + }} + /> + )} ); } diff --git a/web/src/types/agents.ts b/web/src/types/agents.ts index 678d39db..dd3570ca 100644 --- a/web/src/types/agents.ts +++ b/web/src/types/agents.ts @@ -132,6 +132,10 @@ export interface Agent { // Configuration access control (defaults handled by API: visible=true, editable=true) config_visibility?: ConfigVisibility; config_editable?: boolean; + + // Fork tracking (set when agent was forked from marketplace) + original_source_id?: string | null; + source_version?: number | null; } /**