diff --git a/dashboard/src/api/modules/expertMarket.ts b/dashboard/src/api/modules/expertMarket.ts index 61be8555..5881664f 100644 --- a/dashboard/src/api/modules/expertMarket.ts +++ b/dashboard/src/api/modules/expertMarket.ts @@ -57,6 +57,7 @@ export interface CreateMarketExpertResponse { export interface CreateMarketExpertBody { name?: string; + agent_id?: string; description?: string; providers?: string[]; default_model?: string; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 4c9a49dd..be389efb 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -395,6 +395,10 @@ "mbtiDefault": "Default", "mbtiViewDetail": "Click to view personality details", "agentId": "Expert ID", + "agentIdHelp": "Optional. If left empty, a random ID will be auto-generated.", + "agentIdPlaceholder": "e.g. my-expert-id", + "agentIdInvalid": "Only letters, numbers, hyphens, and underscores allowed. Must start with a letter or number.", + "agentIdTooLong": "Agent ID must be 1-64 characters.", "copyAgentId": "Click to copy expert ID", "table": { "name": "Name", @@ -417,6 +421,8 @@ "marketLoadFailed": "Failed to load the expert market", "marketBackendMissing": "The backend has not loaded the Expert Market API yet. Restart Octop or confirm it is running this updated code.", "marketSearchPlaceholder": "Search expert market", + "marketCreateModalTitle": "Create Expert from Market", + "marketCreateModalHint": "Specify an optional Expert ID for the new agent. If left empty, a random ID will be generated automatically.", "sceneAll": "All", "scenes": { "academic": "Academic", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 5bee04af..51cb0728 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -395,6 +395,10 @@ "mbtiDefault": "默认", "mbtiViewDetail": "点击查看人格说明", "agentId": "专家 ID", + "agentIdHelp": "可选。留空将自动生成随机 ID。", + "agentIdPlaceholder": "例如 my-expert-id", + "agentIdInvalid": "仅允许字母、数字、连字符和下划线,且必须以字母或数字开头。", + "agentIdTooLong": "专家 ID 长度为 1-64 个字符。", "copyAgentId": "点击复制专家 ID", "table": { "name": "名称", @@ -417,6 +421,8 @@ "marketLoadFailed": "加载专家市场失败", "marketBackendMissing": "后端还没有加载专家市场接口,请重启 Octop 后端或确认当前运行的是这份新代码。", "marketSearchPlaceholder": "搜索专家市场", + "marketCreateModalTitle": "从市场创建专家", + "marketCreateModalHint": "可选指定新专家的 ID。留空将自动生成随机 ID。", "sceneAll": "全部", "scenes": { "academic": "学术", diff --git a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx index 477b688d..36611dc7 100644 --- a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx +++ b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx @@ -64,6 +64,7 @@ export default function CreateFromExpertDrawer({ const skillSlugDisplayName = useSkillSlugDisplayName(); const [form] = Form.useForm<{ name: string; + agent_id?: string; description: string; default_model: string; backend_choice: string; @@ -93,6 +94,7 @@ export default function CreateFromExpertDrawer({ setPathMappings([]); form.setFieldsValue({ name: pickLocale(expert.label, lang) || expert.id, + agent_id: undefined, description: pickLocale(expert.description, lang), default_model: MODEL_AUTO_VALUE, backend_choice: DEFAULT_BACKEND, @@ -173,6 +175,7 @@ export default function CreateFromExpertDrawer({ method: "POST", body: JSON.stringify({ name: values.name, + agent_id: values.agent_id || undefined, description: values.description || undefined, default_model: defaultModelFromForm(values.default_model) ?? undefined, @@ -265,6 +268,30 @@ export default function CreateFromExpertDrawer({ + { + if (!value || !value.trim()) return Promise.resolve(); + const trimmed = value.trim(); + if (trimmed.length > 64) { + return Promise.reject(new Error(t("experts.agentIdTooLong"))); + } + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(trimmed)) { + return Promise.reject(new Error(t("experts.agentIdInvalid"))); + } + return Promise.resolve(); + }, + }, + ]} + getValueFromEvent={(e) => e.target.value?.trimStart() ?? ""} + > + + + diff --git a/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx b/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx index f5ee4270..91ca9d84 100644 --- a/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx +++ b/dashboard/src/pages/Experts/components/ExpertMarketTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; -import { Button, Drawer, Input, Segmented, Spin, Tag } from "antd"; +import { Button, Drawer, Form, Input, Modal, Segmented, Spin, Tag } from "antd"; import { message } from "@/utils/antdMessage"; import { @@ -15,6 +15,7 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { expertMarketApi, + type CreateMarketExpertBody, type ExpertMarketQuickPrompt, type MarketExpert, } from "../../../api/modules/expertMarket"; @@ -82,6 +83,9 @@ export default function ExpertMarketTab({ const [selected, setSelected] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [creatingSlug, setCreatingSlug] = useState(null); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [pendingExpert, setPendingExpert] = useState(null); + const [configForm] = Form.useForm<{ agent_id?: string }>(); const debounceRef = useRef | null>(null); const fetchMarket = useCallback( @@ -137,11 +141,11 @@ export default function ExpertMarketTab({ ); const createMarketExpert = useCallback( - async (expert: MarketExpert) => { + async (expert: MarketExpert, body: CreateMarketExpertBody = {}) => { if (creatingSlug) return; setCreatingSlug(expert.slug); try { - const result = await expertMarketApi.install(expert.slug); + const result = await expertMarketApi.install(expert.slug, body); const enrichment = result.market?.welcome_enrichment; if (enrichment === "pending") { message.success( @@ -163,6 +167,28 @@ export default function ExpertMarketTab({ [creatingSlug, onCreated, t], ); + const handleOpenCreateModal = useCallback((expert: MarketExpert) => { + setPendingExpert(expert); + setCreateModalOpen(true); + configForm.resetFields(); + }, [configForm]); + + const handleConfirmCreate = useCallback(async () => { + if (!pendingExpert) return; + const values = await configForm.validateFields(); + const body: CreateMarketExpertBody = {}; + if (values.agent_id) body.agent_id = values.agent_id; + await createMarketExpert(pendingExpert, body); + setCreateModalOpen(false); + setPendingExpert(null); + }, [pendingExpert, configForm, createMarketExpert]); + + const handleCreateModalClose = useCallback(() => { + if (creatingSlug) return; + setCreateModalOpen(false); + setPendingExpert(null); + }, [creatingSlug]); + const totalText = useMemo( () => t("experts.totalMarket", { count: items.length }), [items.length, t], @@ -340,7 +366,7 @@ export default function ExpertMarketTab({ disabled={Boolean(creatingSlug)} onClick={(e) => { e.stopPropagation(); - void createMarketExpert(expert); + handleOpenCreateModal(expert); }} > {installed @@ -369,7 +395,7 @@ export default function ExpertMarketTab({ icon={} loading={creatingSlug === selected.slug} disabled={Boolean(creatingSlug)} - onClick={() => void createMarketExpert(selected)} + onClick={() => handleOpenCreateModal(selected)} > {installedExpertIds.has(selected.id) ? t("experts.createAgainFromMarket") @@ -501,6 +527,37 @@ export default function ExpertMarketTab({ )} + + void handleConfirmCreate()} + okText={t("common.create")} + cancelText={t("common.cancel")} + > + {pendingExpert && ( +
+
+ {t("experts.marketCreateModalHint")} +
+ + {labelOf(pendingExpert, lang)} + +
+ )} +
+ + + +
+
); } diff --git a/src/octop/api/routers/agents.py b/src/octop/api/routers/agents.py index bf4585c9..da4dbf3c 100644 --- a/src/octop/api/routers/agents.py +++ b/src/octop/api/routers/agents.py @@ -4,10 +4,11 @@ import json import logging +import re from typing import Any, Literal from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from octop.api.common.agent import assert_agent_owner from octop.api.deps import current_user, get_server @@ -17,9 +18,13 @@ router = APIRouter() +# Agent ID validation pattern: starts with letter/number, then letters/numbers/hyphens/underscores +_AGENT_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") + class AgentCreateBody(BaseModel): name: str + agent_id: str | None = None description: str | None = None persona_mbti: str | None = None default_model: str | None = None @@ -28,6 +33,23 @@ class AgentCreateBody(BaseModel): icon: str | None = None template_name: str | None = None + @field_validator("agent_id") + @classmethod + def _validate_agent_id(cls, v: str | None) -> str | None: + if v is None: + return v + stripped = v.strip() + if not stripped: + raise ValueError("Agent ID cannot be empty or whitespace only") + if len(stripped) > 64: + raise ValueError("Agent ID must be 1-64 characters") + if not _AGENT_ID_RE.match(stripped): + raise ValueError( + "Agent ID can only contain letters, numbers, hyphens, and underscores, " + "and must start with a letter or number" + ) + return stripped + class AgentPatchBody(BaseModel): name: str | None = None @@ -148,6 +170,7 @@ async def create_agent( assert server.app_runtime is not None spec = AgentCreateSpec( name=body.name, + agent_id=body.agent_id, user_id=user.id, description=body.description, persona_mbti=body.persona_mbti, diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py index 20a213ce..95fce2a9 100644 --- a/src/octop/api/routers/experts.py +++ b/src/octop/api/routers/experts.py @@ -12,10 +12,11 @@ from __future__ import annotations import asyncio +import re from typing import Any from fastapi import APIRouter, Depends -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from octop.api.deps import current_user, get_server from octop.infra.agents.experts.catalog import ( @@ -53,12 +54,30 @@ class FromExpertBody(BaseModel): name: str | None = None + agent_id: str | None = None description: str | None = None providers: list[str] | None = None default_model: str | None = None backend: dict[str, Any] | None = None skill_package_ids: list[str] | None = None + @field_validator("agent_id") + @classmethod + def _validate_agent_id(cls, v: str | None) -> str | None: + if v is None: + return v + stripped = v.strip() + if not stripped: + raise ValueError("Agent ID cannot be empty or whitespace only") + if len(stripped) > 64: + raise ValueError("Agent ID must be 1-64 characters") + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", stripped): + raise ValueError( + "Agent ID can only contain letters, numbers, hyphens, and underscores, " + "and must start with a letter or number" + ) + return stripped + class LocalizedTextResponse(BaseModel): zh: str = "" @@ -262,6 +281,7 @@ async def install_expert_hub_item( slug=slug, options=SkillHubMarketAgentCreateOptions( name=body.name, + agent_id=body.agent_id, description=body.description, providers=body.providers, default_model=body.default_model, @@ -344,6 +364,7 @@ async def create_agent_from_expert( expert=expert, user_id=user.id, name=body.name, + agent_id=body.agent_id, description=body.description, locale=locale, default_model=body.default_model, diff --git a/src/octop/cli/commands/agent.py b/src/octop/cli/commands/agent.py index 35b63b8b..c2595bf7 100644 --- a/src/octop/cli/commands/agent.py +++ b/src/octop/cli/commands/agent.py @@ -19,12 +19,16 @@ def agent() -> None: @agent.command("create") @click.argument("name") +@click.option( + "--agent-id", "agent_id", default=None, help="Custom agent ID (short string, optional)." +) @click.option("--persona-mbti", "persona_mbti", default=None) @click.option("--default-model", "default_model", default=None) @click.option("--template", "template_name", default=None, help="Agent template name.") @click.option("--user", "as_user", default=None, help="Create for another user (admin)") def create( name: str, + agent_id: str | None, persona_mbti: str | None, default_model: str | None, template_name: str | None, @@ -47,6 +51,7 @@ async def _run() -> Any: assert server.app_runtime is not None spec = AgentCreateSpec( name=name, + agent_id=agent_id, user_id=uid, persona_mbti=persona_mbti, default_model=default_model, @@ -64,8 +69,13 @@ async def _run() -> Any: @agent.command("from-expert") @click.argument("expert_id") @click.option("--name", default=None) +@click.option( + "--agent-id", "agent_id", default=None, help="Custom agent ID (short string, optional)." +) @click.option("--user", "as_user", default=None) -def from_expert(expert_id: str, name: str | None, as_user: str | None) -> None: +def from_expert( + expert_id: str, name: str | None, agent_id: str | None, as_user: str | None +) -> None: """Create an agent from a bundled expert template (embedded server).""" import asyncio @@ -93,6 +103,7 @@ async def _run() -> Any: expert=expert, user_id=uid, name=name, + agent_id=agent_id, locale=locale, ) return await server.app_runtime.agent_registry.create(spec, defer_bootstrap=True) diff --git a/src/octop/infra/agents/experts/market_creation.py b/src/octop/infra/agents/experts/market_creation.py index dae08ecb..34a52b5d 100644 --- a/src/octop/infra/agents/experts/market_creation.py +++ b/src/octop/infra/agents/experts/market_creation.py @@ -39,6 +39,7 @@ @dataclass(frozen=True) class SkillHubMarketAgentCreateOptions: name: str | None = None + agent_id: str | None = None description: str | None = None providers: list[str] | None = None default_model: str | None = None @@ -284,6 +285,7 @@ async def create_agent_from_skillhub_skillset( expert=expert, user_id=user.id, name=options.name, + agent_id=options.agent_id, description=options.description, locale=locale, default_model=options.default_model,