diff --git a/AGENTS.md b/AGENTS.md index 5b6dff45..2c74dc49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,46 +173,78 @@ interface Message { ## Development Commands +This project uses [just](https://github.com/casey/just) as a command runner. Run `just --list` to see all available commands. + ```bash -# Backend (from service/) -uv run pytest # Run tests -uv run pytest --cov # Run tests with coverage -uv run pyright . # Type checking -uv run ruff check . # Lint code -uv run ruff format . # Format code (auto-applied on save in VSCode) - -# Frontend (from web/) -yarn dev # Dev server -yarn type-check # TypeScript check -yarn lint # ESLint -yarn test # Vitest - -# Full stack (from root) -./launch/dev.sh -d # Start all services +# Development environment +just dev # Start all services in background +just stop # Stop containers (without removing) +just down # Stop and remove all containers + +# Backend (runs in service/ directory) +just test-backend # uv run pytest +just test-backend-cov # uv run pytest --cov +just type-backend # uv run pyright . +just lint-backend # uv run ruff check . +just fmt-backend # uv run ruff format . +just check-backend # Run all backend checks + +# Frontend (runs in web/ directory) +just dev-web # yarn dev +just type-web # yarn type-check +just lint-web # yarn lint +just test-web # yarn test +just check-web # Run all frontend checks + +# Full stack +just lint # Run all linters +just test # Run all tests +just check # Run all checks ``` ## Database Migrations -When creating or running migrations, use `docker exec` to access the container: +Migrations run inside the `sciol-xyzen-service-1` container via `docker exec`: ```bash -# Generate migration -docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic revision --autogenerate -m 'Description'" -# Apply migrations -docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic upgrade head" +just migrate "Description" # alembic revision --autogenerate -m "..." +just migrate-up # alembic upgrade head +just migrate-down # alembic downgrade -1 +just migrate-history # alembic history +just migrate-current # alembic current ``` **Note**: Register new models in `models/__init__.py` before generating migrations. ## Database Queries -Query PostgreSQL directly for debugging (credentials: `postgres/postgres`, database: `postgres`): +Database commands run against `sciol-xyzen-postgresql-1` container (credentials: `postgres/postgres`, database: `postgres`): ```bash -# List tables -docker exec sciol-xyzen-postgresql-1 psql -U postgres -d postgres -c "\dt" +just db-tables # psql -c "\dt" +just db-query "SELECT ..." # psql -c "SELECT ..." +just db-shell # Interactive psql shell ``` +## Docker Commands + +Docker compose uses `docker/docker-compose.base.yaml` + `docker/docker-compose.dev.yaml` with `docker/.env.dev`: + +```bash +# Commonly used - check API server and Celery worker logs +just logs-f service # Follow FastAPI server logs +just logs-f worker # Follow Celery worker logs + +# Other commands +just logs # View all service logs +just ps # Show running containers +just restart # Restart a service +just rebuild # Rebuild and restart service +just exec # Shell into container +``` + +**Container names**: `sciol-xyzen-{service}-1` (e.g., `sciol-xyzen-service-1`, `sciol-xyzen-worker-1`) + ## Code Style **Python**: Use `list[T]`, `dict[K,V]`, `str | None` (not `List`, `Dict`, `Optional`) diff --git a/justfile b/justfile new file mode 100644 index 00000000..029b1e6e --- /dev/null +++ b/justfile @@ -0,0 +1,203 @@ +# Xyzen Development Commands +# Run `just --list` to see all available commands + +# Default recipe: show available commands +default: + @just --list + +# ============================================================================= +# Development Environment +# ============================================================================= + +# Start all services in background (docker) +dev: + ./launch/dev.sh -d + +# Start all services in foreground +dev-fg: + ./launch/dev.sh + +# Stop all containers (without removing) +stop: + ./launch/dev.sh -s + +# Stop and remove all containers +down: + ./launch/dev.sh -e + +# Start only infrastructure services (postgres, redis, etc.) +infra: + ./launch/middleware.sh + +# ============================================================================= +# Backend (service/) +# ============================================================================= + +# Run backend tests +test-backend *args='': + cd service && uv run pytest {{ args }} + +# Run backend tests with coverage +test-backend-cov: + cd service && uv run pytest --cov + +# Type check backend code +type-backend: + cd service && uv run pyright . + +# Lint backend code +lint-backend: + cd service && uv run ruff check . + +# Format backend code +fmt-backend: + cd service && uv run ruff format . + +# Run all backend checks (lint + type + test) +check-backend: lint-backend type-backend test-backend + +# ============================================================================= +# Frontend (web/) +# ============================================================================= + +# Start frontend dev server +dev-web: + cd web && yarn dev + +# Run frontend tests +test-web *args='': + cd web && yarn test {{ args }} + +# Type check frontend code +type-web: + cd web && yarn type-check + +# Lint frontend code +lint-web: + cd web && yarn lint + +# Format frontend code +fmt-web: + cd web && yarn prettier + +# Build frontend for production +build-web: + cd web && yarn build + +# Build frontend as library +build-lib: + cd web && yarn build:lib + +# Run all frontend checks (lint + type + test) +check-web: lint-web type-web test-web + +# ============================================================================= +# Full Stack +# ============================================================================= + +# Run all linters +lint: lint-backend lint-web + +# Run all type checks +type-check: type-backend type-web + +# Run all tests +test: test-backend test-web + +# Run all formatters +fmt: fmt-backend fmt-web + +# Run all checks (lint + type + test) +check: check-backend check-web + +# ============================================================================= +# Database & Migrations +# ============================================================================= + +# Generate a new migration +migrate message: + docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic revision --autogenerate -m '{{ message }}'" + +# Apply all pending migrations +migrate-up: + docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic upgrade head" + +# Rollback one migration +migrate-down: + docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic downgrade -1" + +# Show migration history +migrate-history: + docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic history" + +# Show current migration version +migrate-current: + docker exec -it sciol-xyzen-service-1 sh -c "uv run alembic current" + +# List all database tables +db-tables: + docker exec sciol-xyzen-postgresql-1 psql -U postgres -d postgres -c "\dt" + +# Run a SQL query against the database +db-query query: + docker exec sciol-xyzen-postgresql-1 psql -U postgres -d postgres -c "{{ query }}" + +# Open psql shell +db-shell: + docker exec -it sciol-xyzen-postgresql-1 psql -U postgres -d postgres + +# ============================================================================= +# Docker +# ============================================================================= + +# View service logs (all services) +logs *args='': + docker compose -f docker/docker-compose.base.yaml -f docker/docker-compose.dev.yaml --env-file docker/.env.dev logs {{ args }} + +# View service logs and follow +logs-f *args='': + docker compose -f docker/docker-compose.base.yaml -f docker/docker-compose.dev.yaml --env-file docker/.env.dev logs -f {{ args }} + +# Show running containers +ps: + docker compose -f docker/docker-compose.base.yaml -f docker/docker-compose.dev.yaml --env-file docker/.env.dev ps + +# Restart a specific service +restart service: + docker compose -f docker/docker-compose.base.yaml -f docker/docker-compose.dev.yaml --env-file docker/.env.dev restart {{ service }} + +# Rebuild and restart services +rebuild *services='': + docker compose -f docker/docker-compose.base.yaml -f docker/docker-compose.dev.yaml --env-file docker/.env.dev up -d --build {{ services }} + +# Execute command in service container +exec service *cmd='sh': + docker exec -it sciol-xyzen-{{ service }}-1 {{ cmd }} + +# ============================================================================= +# Git & Maintenance +# ============================================================================= + +# Clean stale local branches (interactive) +clean-branches: + ./launch/clean.sh + +# Run pre-commit on all files +pre-commit: + pre-commit run --all-files + +# Run pre-commit on staged files only +pre-commit-staged: + pre-commit run + +# ============================================================================= +# Build & Release +# ============================================================================= + +# Build development images +build-dev: + ./launch/buildx-dev.sh + +# Build production images +build-prod: + ./launch/buildx-prod.sh 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..20b1e595 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): @@ -144,6 +146,13 @@ async def publish_agent( detail="Agent must have a configuration before publishing to marketplace", ) + # Validate that agent is not a forked agent + if agent.original_source_id is not None: + raise HTTPException( + status_code=400, + detail="Forked agents cannot be published to the marketplace", + ) + # Publish the agent marketplace_service = AgentMarketplaceService(db) listing = await marketplace_service.publish_agent( @@ -151,6 +160,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 +218,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/service/tests/unit/agents/test_factory.py b/service/tests/unit/agents/test_factory.py index 85bfc6da..cf6019ca 100644 --- a/service/tests/unit/agents/test_factory.py +++ b/service/tests/unit/agents/test_factory.py @@ -55,8 +55,8 @@ def test_inject_into_component_node(self) -> None: # Result should have config_overrides with system_prompt assert result["nodes"][0]["component_config"]["config_overrides"]["system_prompt"] == "Custom system prompt" - def test_inject_only_into_first_matching_node(self) -> None: - """Test that system prompt is only injected into the first matching node.""" + def test_inject_into_all_matching_nodes(self) -> None: + """Test that system prompt is injected into all matching nodes.""" config_dict = { "version": "2.0", "nodes": [ @@ -80,10 +80,9 @@ def test_inject_only_into_first_matching_node(self) -> None: result = _inject_system_prompt(config_dict, "Custom system prompt") - # First node should be updated + # Both nodes should be updated (inject into all matching nodes) assert result["nodes"][0]["llm_config"]["prompt_template"] == "Custom system prompt" - # Second node should remain unchanged - assert result["nodes"][1]["llm_config"]["prompt_template"] == "Prompt 2" + assert result["nodes"][1]["llm_config"]["prompt_template"] == "Custom system prompt" def test_llm_node_takes_precedence_over_non_react_component(self) -> None: """Test that LLM nodes are preferred over non-react components.""" 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/hooks/useXyzenChat.ts b/web/src/hooks/useXyzenChat.ts index 260c1265..08b7d41f 100644 --- a/web/src/hooks/useXyzenChat.ts +++ b/web/src/hooks/useXyzenChat.ts @@ -84,6 +84,7 @@ export function useXyzenChat(config: XyzenChatConfig) { const currentAgent = currentChannel?.agentId ? agents.find((a) => a.id === currentChannel.agentId) : null; + const messages: Message[] = currentChannel?.messages || []; const connected = currentChannel?.connected || false; const error = currentChannel?.error || null; diff --git a/web/src/i18n/locales/en/marketplace.json b/web/src/i18n/locales/en/marketplace.json index a45cc85e..a8594108 100644 --- a/web/src/i18n/locales/en/marketplace.json +++ b/web/src/i18n/locales/en/marketplace.json @@ -73,7 +73,9 @@ "model": "Model", "systemPrompt": "System Prompt", "mcpServers": "MCP Servers ({{count}})", - "empty": "No configuration available." + "empty": "No configuration available.", + "hidden": "This agent's configuration is hidden. You can still fork and use it, but you won't be able to view or modify its settings.", + "lockedOwnerNote": "This agent is locked. Users who fork it will not be able to see or modify the configuration." }, "requirements": { "provider": { @@ -160,6 +162,10 @@ "update": "Update Listing", "saveAsDraft": "Save as Draft", "publishing": "Publishing..." + }, + "forkMode": { + "label": "Fork Mode", + "help": "Controls what access users have to their forked copy's configuration" } }, "simplePromptEditor": { @@ -188,6 +194,24 @@ "invalidJson": "Invalid JSON format", "empty": "No configuration available.", "configureNow": "Configure now" + }, + "forkMode": { + "title": "Fork Mode", + "help": "Controls what access users have to their forked copy's configuration", + "saving": "Saving...", + "success": "Fork mode updated successfully!", + "error": "Failed to update fork mode. Please try again." } + }, + "forkMode": { + "editable": "Editable", + "locked": "Locked", + "editableDescription": "Users can view and modify the configuration", + "lockedDescription": "Configuration is hidden and read-only" + }, + "fork": { + "lockedAgent": "Locked Agent", + "lockedAgentDescription": "This agent will be forked in locked mode. You can use it, but the configuration will be hidden and cannot be modified.", + "editableDescription": "Your forked agent will be completely independent. Changes won't affect the original." } } diff --git a/web/src/i18n/locales/ja/marketplace.json b/web/src/i18n/locales/ja/marketplace.json index b6ba1260..c77bd397 100644 --- a/web/src/i18n/locales/ja/marketplace.json +++ b/web/src/i18n/locales/ja/marketplace.json @@ -73,7 +73,9 @@ "model": "モデル", "systemPrompt": "システムプロンプト", "mcpServers": "MCPサーバー({{count}})", - "empty": "設定情報はありません。" + "empty": "設定情報はありません。", + "hidden": "このエージェントの設定は非表示です。フォークして使用することはできますが、設定を表示・変更することはできません。", + "lockedOwnerNote": "このエージェントはロックされています。フォークしたユーザーは設定を表示・変更できません。" }, "requirements": { "provider": { @@ -160,6 +162,10 @@ "update": "リストを更新", "saveAsDraft": "下書きとして保存", "publishing": "公開中..." + }, + "forkMode": { + "label": "フォークモード", + "help": "ユーザーがフォークしたコピーの設定にアクセスできる範囲を制御します" } }, "simplePromptEditor": { @@ -188,6 +194,24 @@ "invalidJson": "無効なJSON形式", "empty": "設定情報はありません。", "configureNow": "今すぐ設定" + }, + "forkMode": { + "title": "フォークモード", + "help": "ユーザーがフォークしたコピーの設定にアクセスできる範囲を制御します", + "saving": "保存中...", + "success": "フォークモードが更新されました!", + "error": "フォークモードの更新に失敗しました。もう一度お試しください。" } + }, + "forkMode": { + "editable": "編集可能", + "locked": "ロック", + "editableDescription": "ユーザーは設定を表示・編集できます", + "lockedDescription": "設定は非表示で読み取り専用です" + }, + "fork": { + "lockedAgent": "ロックされたエージェント", + "lockedAgentDescription": "このエージェントはロックモードでフォークされます。使用はできますが、設定は非表示で変更できません。", + "editableDescription": "フォークしたエージェントは完全に独立しています。変更は元のエージェントに影響しません。" } } diff --git a/web/src/i18n/locales/zh/marketplace.json b/web/src/i18n/locales/zh/marketplace.json index 84afd8c0..aa54a33e 100644 --- a/web/src/i18n/locales/zh/marketplace.json +++ b/web/src/i18n/locales/zh/marketplace.json @@ -73,7 +73,9 @@ "model": "模型", "systemPrompt": "系统提示词", "mcpServers": "MCP 服务 ({{count}})", - "empty": "暂无配置信息。" + "empty": "暂无配置信息。", + "hidden": "此助手的配置已隐藏。你仍可以复刻和使用它,但无法查看或修改其设置。", + "lockedOwnerNote": "此助手已锁定。复刻它的用户将无法查看或修改配置。" }, "requirements": { "provider": { @@ -160,6 +162,10 @@ "update": "更新列表", "saveAsDraft": "保存为草稿", "publishing": "发布中..." + }, + "forkMode": { + "label": "复刻模式", + "help": "控制用户对其复刻副本配置的访问权限" } }, "simplePromptEditor": { @@ -188,6 +194,24 @@ "invalidJson": "无效的 JSON 格式", "empty": "暂无配置信息。", "configureNow": "立即配置" + }, + "forkMode": { + "title": "复刻模式", + "help": "控制用户对其复刻副本配置的访问权限", + "saving": "保存中...", + "success": "复刻模式更新成功!", + "error": "更新复刻模式失败,请重试。" } + }, + "forkMode": { + "editable": "可编辑", + "locked": "锁定", + "editableDescription": "用户可以查看和修改配置", + "lockedDescription": "配置已隐藏且为只读" + }, + "fork": { + "lockedAgent": "锁定的助手", + "lockedAgentDescription": "此助手将以锁定模式复刻。你可以使用它,但配置将被隐藏且无法修改。", + "editableDescription": "你复刻的助手将完全独立。修改不会影响原始助手。" } } diff --git a/web/src/service/marketplaceService.ts b/web/src/service/marketplaceService.ts index c58ed64c..5d585f66 100644 --- a/web/src/service/marketplaceService.ts +++ b/web/src/service/marketplaceService.ts @@ -7,6 +7,8 @@ import { useXyzen } from "@/store"; * Handles all API interactions for the agent marketplace feature. */ +export type ForkMode = "editable" | "locked"; + export interface MarketplaceListing { id: string; agent_id: string; @@ -20,6 +22,7 @@ export interface MarketplaceListing { forks_count: number; views_count: number; is_published: boolean; + fork_mode: ForkMode; created_at: string; updated_at: string; first_published_at: string | null; @@ -79,6 +82,7 @@ export interface PublishRequest { commit_message: string; is_published?: boolean; readme?: string | null; + fork_mode?: ForkMode; } export interface PublishResponse { @@ -92,6 +96,7 @@ export interface PublishResponse { export interface UpdateListingRequest { is_published?: boolean; readme?: string | null; + fork_mode?: ForkMode; } export interface ForkRequest { diff --git a/web/src/types/agents.ts b/web/src/types/agents.ts index 1ccc29e7..dd3570ca 100644 --- a/web/src/types/agents.ts +++ b/web/src/types/agents.ts @@ -1,5 +1,8 @@ // Agent type definitions and type guards +// Configuration access control types +export type ConfigVisibility = "visible" | "hidden"; + // Spatial/layout primitives (used by spatial chat UI) export type XYPosition = { x: number; y: number }; export type GridSize = { w: number; h: number }; @@ -125,6 +128,14 @@ export interface Agent { // Graph configuration for agent behavior graph_config?: Record | null; + + // 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; } /** diff --git a/web/src/types/graphConfig.ts b/web/src/types/graphConfig.ts index c22da7d4..cc857f5d 100644 --- a/web/src/types/graphConfig.ts +++ b/web/src/types/graphConfig.ts @@ -213,6 +213,73 @@ export interface GraphEdgeConfig { priority?: number; } +// ============================================================================= +// Prompt Configuration Types +// ============================================================================= + +export interface IdentityConfig { + name?: string; + description?: string; + persona?: string | null; +} + +export interface BrandingConfig { + mask_provider?: boolean; + mask_model?: boolean; + branded_name?: string; + forbidden_reveals?: string[]; +} + +export interface SecurityConfig { + injection_defense?: boolean; + refuse_prompt_reveal?: boolean; + refuse_instruction_override?: boolean; + confidential_sections?: string[]; +} + +export interface SafetyConfig { + content_safety?: boolean; + refuse_illegal?: boolean; + refuse_harmful?: boolean; + refuse_explicit?: boolean; + refuse_violence?: boolean; + refuse_hate?: boolean; + refuse_self_harm?: boolean; +} + +export interface FormattingConfig { + use_markdown?: boolean; + code_blocks?: boolean; + language_identifiers?: boolean; + custom_blocks?: string[]; +} + +export interface ContextConfig { + include_date?: boolean; + include_time?: boolean; + date_format?: string; + custom_context?: string | null; +} + +export interface OverridesConfig { + meta_instruction?: string | null; + persona_instruction?: string | null; + tool_instruction?: string | null; + format_instruction?: string | null; +} + +export interface PromptConfig { + version?: string; + identity?: IdentityConfig; + branding?: BrandingConfig; + security?: SecurityConfig; + safety?: SafetyConfig; + formatting?: FormattingConfig; + context?: ContextConfig; + custom_instructions?: string | null; + overrides?: OverridesConfig; +} + // ============================================================================= // Complete Graph Configuration // ============================================================================= @@ -241,6 +308,9 @@ export interface GraphConfig { max_parallel?: number; }; + // Prompt configuration (system prompt settings) + prompt_config?: PromptConfig; + // Reusable prompt templates prompt_templates?: Record; diff --git a/web/src/utils/agentConfigMapper.ts b/web/src/utils/agentConfigMapper.ts index 0be85071..4e48def5 100644 --- a/web/src/utils/agentConfigMapper.ts +++ b/web/src/utils/agentConfigMapper.ts @@ -68,8 +68,9 @@ function findMainLLMNode(graphConfig: GraphConfig): GraphNodeConfig | null { /** * Extract simple configuration from a graph_config. * - * This reads the main LLM node's settings and converts them - * to the simple format for display in the form. + * This reads the prompt from prompt_config.custom_instructions (preferred) + * or falls back to llm_config.prompt_template (legacy). + * Other settings come from the main LLM node. * * @param graphConfig - The full graph configuration (can be null) * @param fallbackPrompt - Fallback prompt if not found in graph_config @@ -89,11 +90,16 @@ export function extractSimpleConfig( const llmNode = findMainLLMNode(graphConfig); const llmConfig = llmNode?.llm_config; + // Read prompt from prompt_config.custom_instructions (preferred) + // Fall back to llm_config.prompt_template for legacy configs + const prompt = + graphConfig.prompt_config?.custom_instructions || + llmConfig?.prompt_template || + fallbackPrompt || + DEFAULT_SIMPLE_CONFIG.prompt; + return { - prompt: - llmConfig?.prompt_template || - fallbackPrompt || - DEFAULT_SIMPLE_CONFIG.prompt, + prompt, model: llmConfig?.model_override || null, temperature: llmConfig?.temperature_override ?? null, toolsEnabled: llmConfig?.tools_enabled ?? true, @@ -104,8 +110,8 @@ export function extractSimpleConfig( /** * Update a graph_config with values from simple config. * - * This finds the main LLM node and updates its configuration - * while preserving all other nodes and graph structure. + * This writes the prompt to prompt_config.custom_instructions (the correct location) + * and updates the main LLM node's other settings (model, temperature, etc.) * * @param graphConfig - The existing graph configuration * @param simple - The simple configuration from the form @@ -117,6 +123,11 @@ export function updateGraphConfigFromSimple( ): GraphConfig { return { ...graphConfig, + // Store prompt in prompt_config.custom_instructions + prompt_config: { + ...graphConfig.prompt_config, + custom_instructions: simple.prompt, + }, nodes: graphConfig.nodes.map((node) => { // Only update the main LLM node if (node.type === "llm" && node.id === "agent" && node.llm_config) { @@ -124,7 +135,7 @@ export function updateGraphConfigFromSimple( ...node, llm_config: { ...node.llm_config, - prompt_template: simple.prompt, + // Don't store prompt here - it goes in prompt_config model_override: simple.model, temperature_override: simple.temperature, tools_enabled: simple.toolsEnabled, @@ -140,7 +151,7 @@ export function updateGraphConfigFromSimple( ...node, llm_config: { ...node.llm_config, - prompt_template: simple.prompt, + // Don't store prompt here - it goes in prompt_config model_override: simple.model, temperature_override: simple.temperature, tools_enabled: simple.toolsEnabled, @@ -168,6 +179,7 @@ export function updateGraphConfigFromSimple( * - No state_schema (messages field is automatic) * - Tool node uses execute_all instead of tool_name="__all__" * - Edges use condition type strings instead of EdgeCondition objects + * - Prompt stored in prompt_config.custom_instructions (NOT llm_config.prompt_template) * * @param simple - Simple configuration from the form * @returns Complete v2 graph configuration matching ReActAgent structure @@ -177,6 +189,10 @@ export function createGraphConfigFromSimple( ): GraphConfig { return { version: "2.0", + // Store prompt in prompt_config.custom_instructions + prompt_config: { + custom_instructions: simple.prompt, + }, nodes: [ { id: "agent", @@ -185,7 +201,8 @@ export function createGraphConfigFromSimple( description: "Reasons about the task and decides whether to use tools or respond", llm_config: { - prompt_template: simple.prompt, + // prompt_template is intentionally omitted - backend will inject from prompt_config + prompt_template: "", output_key: "response", tools_enabled: simple.toolsEnabled, model_override: simple.model,