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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dashboard/src/api/modules/expertMarket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface CreateMarketExpertResponse {

export interface CreateMarketExpertBody {
name?: string;
agent_id?: string;
description?: string;
providers?: string[];
default_model?: string;
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,10 @@
"mbtiDefault": "默认",
"mbtiViewDetail": "点击查看人格说明",
"agentId": "专家 ID",
"agentIdHelp": "可选。留空将自动生成随机 ID。",
"agentIdPlaceholder": "例如 my-expert-id",
"agentIdInvalid": "仅允许字母、数字、连字符和下划线,且必须以字母或数字开头。",
"agentIdTooLong": "专家 ID 长度为 1-64 个字符。",
"copyAgentId": "点击复制专家 ID",
"table": {
"name": "名称",
Expand All @@ -417,6 +421,8 @@
"marketLoadFailed": "加载专家市场失败",
"marketBackendMissing": "后端还没有加载专家市场接口,请重启 Octop 后端或确认当前运行的是这份新代码。",
"marketSearchPlaceholder": "搜索专家市场",
"marketCreateModalTitle": "从市场创建专家",
"marketCreateModalHint": "可选指定新专家的 ID。留空将自动生成随机 ID。",
"sceneAll": "全部",
"scenes": {
"academic": "学术",
Expand Down
27 changes: 27 additions & 0 deletions dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -265,6 +268,30 @@ export default function CreateFromExpertDrawer({
<Input />
</Form.Item>

<Form.Item
name="agent_id"
label={t("experts.agentId")}
help={t("experts.agentIdHelp")}
rules={[
{
validator: (_, value: string | undefined) => {
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() ?? ""}
>
<Input placeholder={t("experts.agentIdPlaceholder")} />
</Form.Item>

<Form.Item name="description" label={t("experts.agentDescription")}>
<Input.TextArea rows={2} />
</Form.Item>
Expand Down
67 changes: 62 additions & 5 deletions dashboard/src/pages/Experts/components/ExpertMarketTab.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -82,6 +83,9 @@ export default function ExpertMarketTab({
const [selected, setSelected] = useState<MarketExpert | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [creatingSlug, setCreatingSlug] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingExpert, setPendingExpert] = useState<MarketExpert | null>(null);
const [configForm] = Form.useForm<{ agent_id?: string }>();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const fetchMarket = useCallback(
Expand Down Expand Up @@ -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(
Expand All @@ -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],
Expand Down Expand Up @@ -340,7 +366,7 @@ export default function ExpertMarketTab({
disabled={Boolean(creatingSlug)}
onClick={(e) => {
e.stopPropagation();
void createMarketExpert(expert);
handleOpenCreateModal(expert);
}}
>
{installed
Expand Down Expand Up @@ -369,7 +395,7 @@ export default function ExpertMarketTab({
icon={<Download size={14} />}
loading={creatingSlug === selected.slug}
disabled={Boolean(creatingSlug)}
onClick={() => void createMarketExpert(selected)}
onClick={() => handleOpenCreateModal(selected)}
>
{installedExpertIds.has(selected.id)
? t("experts.createAgainFromMarket")
Expand Down Expand Up @@ -501,6 +527,37 @@ export default function ExpertMarketTab({
</div>
)}
</Drawer>

<Modal
title={t("experts.marketCreateModalTitle")}
open={createModalOpen}
onCancel={handleCreateModalClose}
destroyOnClose
confirmLoading={Boolean(creatingSlug)}
onOk={() => void handleConfirmCreate()}
okText={t("common.create")}
cancelText={t("common.cancel")}
>
{pendingExpert && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 13, color: "var(--fn-text-secondary)", marginBottom: 4 }}>
{t("experts.marketCreateModalHint")}
</div>
<Tag style={{ marginTop: 4 }}>
{labelOf(pendingExpert, lang)}
</Tag>
</div>
)}
<Form form={configForm} layout="vertical" size="middle">
<Form.Item
name="agent_id"
label={t("experts.agentId")}
help={t("experts.agentIdHelp")}
>
<Input placeholder={t("experts.agentIdPlaceholder")} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
25 changes: 24 additions & 1 deletion src/octop/api/routers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion src/octop/api/routers/experts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion src/octop/cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading