diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 2603cf9..f481425 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -71,6 +71,20 @@ export interface ConnectorProbeResult { status_code?: number; } +export type CustomMcpTransport = "streamable_http" | "stdio"; + +export interface CustomMcpServerSpec { + transport: CustomMcpTransport; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; + enabled?: boolean; +} + +export type CustomMcpServers = Record; + export const connectorsApi = { catalog: () => request("/connectors/catalog"), @@ -145,4 +159,28 @@ export const connectorsApi = { method: "POST", body: JSON.stringify(body), }), + + getCustomMcp: () => + request<{ servers: CustomMcpServers }>("/connectors/custom-mcp"), + + putCustomMcp: (servers: CustomMcpServers) => + request<{ servers: CustomMcpServers }>("/connectors/custom-mcp", { + method: "PUT", + body: JSON.stringify({ servers }), + }), + + patchCustomMcpServer: (name: string, body: { enabled: boolean }) => + request<{ servers: CustomMcpServers }>( + `/connectors/custom-mcp/servers/${encodeURIComponent(name)}`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ), + + testCustomMcp: (body: { name?: string; server?: CustomMcpServerSpec }) => + request("/connectors/custom-mcp/test", { + method: "POST", + body: JSON.stringify(body), + }), }; diff --git a/dashboard/src/api/modules/expertMarket.ts b/dashboard/src/api/modules/expertMarket.ts new file mode 100644 index 0000000..61be855 --- /dev/null +++ b/dashboard/src/api/modules/expertMarket.ts @@ -0,0 +1,91 @@ +import { request } from "../request"; + +export interface LocalizedText { + zh?: string; + en?: string; +} + +export interface ExpertMarketQuickPrompt { + title: LocalizedText; + description: LocalizedText; + prompt: LocalizedText; + color?: string; + icon_name?: string | null; +} + +export interface MarketExpert { + id: string; + slug: string; + label: LocalizedText; + description: LocalizedText; + scene?: string; + sub_scene?: string; + icon_url?: string | null; + icon_name?: string | null; + color?: string | null; + skill_slugs?: string[]; + skill_count?: number; + source?: string; + content?: LocalizedText; + quick_prompts?: ExpertMarketQuickPrompt[]; +} + +export interface ExpertHubListResponse { + items: MarketExpert[]; + scenes: string[]; +} + +export interface CreateMarketExpertResponse { + id: number | string; + agent_id: string; + user_id: number; + name: string; + description?: string | null; + default_model?: string | null; + state: string; + expert_id: string; + icon_name?: string | null; + color?: string | null; + market: { + source: string; + kind: string; + slug: string; + welcome_enrichment: "pending" | "skipped" | "succeeded" | "failed" | string; + }; + bootstrap_pending: boolean; +} + +export interface CreateMarketExpertBody { + name?: string; + description?: string; + providers?: string[]; + default_model?: string; + backend?: Record; +} + +function hubListPath(query: string, scene: string): string { + const params = new URLSearchParams(); + const q = query.trim(); + const s = scene.trim(); + if (q) params.set("q", q); + if (s) params.set("scene", s); + const qs = params.toString(); + return qs ? `/experts/hub?${qs}` : "/experts/hub"; +} + +export const expertMarketApi = { + list: (query: string, scene = "") => + request(hubListPath(query, scene)), + + get: (slug: string) => + request(`/experts/hub/${encodeURIComponent(slug)}`), + + install: (slug: string, body: CreateMarketExpertBody = {}) => + request( + `/experts/hub/${encodeURIComponent(slug)}/install`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), +}; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 7c1a392..930ea17 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -77,6 +77,7 @@ "CONNECTOR_KIND_UNSUPPORTED": "Unsupported connector type.", "CONNECTOR_NOT_BOUND": "Connector is not bound to this agent.", "CONNECTOR_ALREADY_BOUND": "Connector is already bound.", + "CONNECTOR_MCP_LOAD_FAILED": "Failed to load tools for MCP server(s): {servers}", "CRON_TRIGGER_INVALID": "Invalid cron trigger.", "SLASH_UNKNOWN": "Unknown slash command.", "SLASH_BAD_ARGS": "Invalid arguments.", @@ -92,6 +93,7 @@ "SKILL_IMPORT_UNSUPPORTED_URL": "This skill URL source is not supported.", "SKILL_IMPORT_FAILED": "Skill import failed: {reason}", "SKILL_ALREADY_EXISTS": "Skill {name} already exists. Enable overwrite to replace it.", + "EXPERT_MARKET_FAILED": "Expert market is temporarily unavailable. Please try again later.", "DESKTOP_SESSION_LIMIT": "Too many concurrent remote desktop streams (max {limit}). Close other connections and try again.", "DESKTOP_CAPTURE_FAILED": "Screen capture failed repeatedly. On Linux servers restart the virtual desktop; on macOS grant Screen Recording and Accessibility, then reconnect." }, @@ -333,7 +335,8 @@ "proactive": "Proactive Push" }, "myExperts": "My Experts", - "expertLibrary": "Expert List", + "expertLibrary": "Built-in Experts", + "expertMarket": "Expert Market", "viewCard": "Cards", "viewTable": "Table", "mbtiDefault": "Default", @@ -350,13 +353,52 @@ "actions": "Actions" }, "totalAgents": "{{count}} expert(s)", - "totalLibrary": "{{count}} template(s)", - "addFromLibrary": "+ Add from Expert List", + "totalLibrary": "{{count}} built-in expert(s)", + "totalMarket": "{{count}} market expert(s)", + "addFromLibrary": "+ Add from Built-in", "emptyMyExperts": "No experts yet", - "emptyMyExpertsHint": "Pick a template from the Expert List to create your first AI expert.", - "goToLibrary": "Go to Expert List", - "emptyLibrary": "No expert templates available", - "emptyLibraryHint": "Please ask your administrator to configure the expert list.", + "emptyMyExpertsHint": "Pick a template from Built-in Experts or the Expert Market to create your first AI expert.", + "goToLibrary": "Go to Built-in Experts", + "emptyLibrary": "No built-in experts available", + "emptyLibraryHint": "Please ask your administrator to configure built-in experts.", + "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", + "sceneAll": "All", + "scenes": { + "academic": "Academic", + "content-creation": "Content Creation", + "design": "Design", + "ecommerce": "E-commerce", + "education": "Education", + "finance": "Finance", + "healthcare": "Healthcare", + "hr": "HR & Admin", + "legal": "Legal", + "lifestyle": "Lifestyle", + "marketing": "Marketing", + "media": "Media", + "mysticism": "Mysticism", + "tech": "Tech" + }, + "emptyMarket": "No market experts available", + "emptyMarketHint": "Try another keyword or check again later.", + "noMarketDescription": "This market expert has no description yet.", + "marketSkillCount": "{{count}} skill(s)", + "createFromMarket": "Create from Market", + "createAgainFromMarket": "Create another", + "marketCreateSuccess": "Created from Expert Market: {{name}}", + "marketCreateSuccessEnriching": "Created {{name}}. Welcome cards are being refined in the background and will update when you open the chat.", + "marketDetailLoadFailed": "Failed to load expert details", + "marketSource": "SkillHub", + "marketIncludedSkills": "Included Skills", + "marketQuickPrompts": "Quick-start cards", + "marketQuickPromptsEmpty": "No quick-start card preview yet", + "marketWorkflowPrompt": "Workflow overview", + "marketWorkflowMeta": "Package info", + "marketWorkflowVersion": "v{{version}}", + "marketWorkflowChildren": "Orchestrated skills", + "marketWorkflowEmpty": "No workflow overview available", "installedBadge": "Installed", "installAgain": "Install Again", "createFromTemplate": "+ Create from this template", @@ -2848,6 +2890,33 @@ "tabMine": "My connections", "loadFailed": "Failed to load connectors", "tabCatalog": "Catalog", + "tabBuiltin": "Built-in connectors", + "tabCustom": "Custom connectors", + "customMcp": { + "introTitle": "MCP server configuration (JSON). Refer to the following format:", + "modeVisual": "Visual", + "modeJson": " JSON", + "addHttp": "Add HTTP Server", + "addStdio": "Add Stdio Server", + "listTitle": "Configured servers", + "emptyList": "No custom MCP yet — use the buttons above to add one", + "transportHttp": "HTTP", + "transportStdio": "Stdio", + "headers": "Headers (one Key: Value per line; optional Bearer)", + "args": "Args (one argument per line)", + "env": "Env (one KEY=VALUE per line)", + "loadFailed": "Failed to load custom MCP", + "saveSuccess": "Custom MCP saved", + "saveFailed": "Failed to save", + "jsonInvalid": "Invalid JSON", + "visualInvalid": "Fix names and required fields before switching", + "duplicateName": "Server names must be unique", + "emptyName": "Server name is required", + "probeNeedConfig": "Fill in the configuration before probing", + "probeOk": "Probe ok — found {{count}} tools", + "deleteConfirm": "Delete MCP server \"{{name}}\"?", + "deleteConfirmHint": "Changes take effect after you click Save." + }, "configuredBadge": "Connected", "clickToConnect": "Click to connect", "noInstancesHint": "Pick a service in Catalog and complete authorization", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 878d205..fa40a51 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -77,6 +77,7 @@ "CONNECTOR_KIND_UNSUPPORTED": "不支持的连接器类型。", "CONNECTOR_NOT_BOUND": "连接器未绑定到此 Agent。", "CONNECTOR_ALREADY_BOUND": "连接器已绑定。", + "CONNECTOR_MCP_LOAD_FAILED": "无法加载 MCP 工具:{servers}", "CRON_TRIGGER_INVALID": "无效的定时触发器。", "SLASH_UNKNOWN": "未知的斜杠指令。", "SLASH_BAD_ARGS": "参数无效。", @@ -92,6 +93,7 @@ "SKILL_IMPORT_UNSUPPORTED_URL": "不支持该技能 URL 来源。", "SKILL_IMPORT_FAILED": "技能导入失败:{reason}", "SKILL_ALREADY_EXISTS": "技能 {name} 已存在,如需覆盖请启用 overwrite。", + "EXPERT_MARKET_FAILED": "专家市场暂时不可用,请稍后重试。", "DESKTOP_SESSION_LIMIT": "远程桌面并发连接已达上限(最多 {limit} 路),请关闭其他连接后重试。", "DESKTOP_CAPTURE_FAILED": "连续抓屏失败。Linux 服务器请重启虚拟桌面;macOS 请在系统设置中授予屏幕录制与辅助功能权限后重连。" }, @@ -333,7 +335,8 @@ "proactive": "主动推送" }, "myExperts": "我的专家", - "expertLibrary": "专家列表", + "expertLibrary": "内置专家", + "expertMarket": "专家市场", "viewCard": "卡片", "viewTable": "表格", "mbtiDefault": "默认", @@ -350,13 +353,52 @@ "actions": "操作" }, "totalAgents": "共 {{count}} 个专家", - "totalLibrary": "共 {{count}} 个模板", - "addFromLibrary": "+ 从专家列表新建", + "totalLibrary": "共 {{count}} 个内置专家", + "totalMarket": "共 {{count}} 个市场专家", + "addFromLibrary": "+ 从内置专家新建", "emptyMyExperts": "你还没有专家", - "emptyMyExpertsHint": "从专家列表选择模板,快速创建你的第一个 AI 专家。", - "goToLibrary": "去专家列表", - "emptyLibrary": "暂无可用的专家模板", - "emptyLibraryHint": "请联系管理员配置专家列表。", + "emptyMyExpertsHint": "从内置专家或专家市场选择模板,快速创建你的第一个 AI 专家。", + "goToLibrary": "去内置专家", + "emptyLibrary": "暂无可用的内置专家", + "emptyLibraryHint": "请联系管理员配置内置专家。", + "marketLoadFailed": "加载专家市场失败", + "marketBackendMissing": "后端还没有加载专家市场接口,请重启 Octop 后端或确认当前运行的是这份新代码。", + "marketSearchPlaceholder": "搜索专家市场", + "sceneAll": "全部", + "scenes": { + "academic": "学术", + "content-creation": "内容创作", + "design": "设计", + "ecommerce": "电商", + "education": "教育", + "finance": "金融", + "healthcare": "医疗健康", + "hr": "人力行政", + "legal": "法律", + "lifestyle": "生活健康", + "marketing": "营销", + "media": "影视媒体", + "mysticism": "玄学命理", + "tech": "技术" + }, + "emptyMarket": "暂无可用的市场专家", + "emptyMarketHint": "可以换个关键词,或稍后再试。", + "noMarketDescription": "该市场专家暂未提供简介。", + "marketSkillCount": "{{count}} 个技能", + "createFromMarket": "从市场创建", + "createAgainFromMarket": "再创建一个", + "marketCreateSuccess": "已从专家市场创建:{{name}}", + "marketCreateSuccessEnriching": "已创建 {{name}}。欢迎卡片正在后台优化,稍后进入对话即可看到更新。", + "marketDetailLoadFailed": "加载专家详情失败", + "marketSource": "SkillHub", + "marketIncludedSkills": "包含技能", + "marketQuickPrompts": "快速启动卡片", + "marketQuickPromptsEmpty": "暂无快速启动卡片预览", + "marketWorkflowPrompt": "工作流说明", + "marketWorkflowMeta": "技能包信息", + "marketWorkflowVersion": "v{{version}}", + "marketWorkflowChildren": "编排技能", + "marketWorkflowEmpty": "暂无工作流说明", "installedBadge": "已安装", "installAgain": "再次安装", "createFromTemplate": "+ 从此模板新建", @@ -3012,6 +3054,33 @@ "tabMine": "我的连接", "loadFailed": "加载连接器失败", "tabCatalog": "服务目录", + "tabBuiltin": "内置连接器", + "tabCustom": "自定义连接器", + "customMcp": { + "introTitle": "MCP 服务器配置(JSON 格式)。参考以下格式:", + "modeVisual": "可视化", + "modeJson": " JSON", + "addHttp": "添加 HTTP Server", + "addStdio": "添加 Stdio Server", + "listTitle": "已添加的服务器", + "emptyList": "尚未添加自定义 MCP,点击上方按钮开始配置", + "transportHttp": "HTTP", + "transportStdio": "Stdio", + "headers": "Headers(每行 Key: Value,可选 Bearer)", + "args": "Args(一行一个参数)", + "env": "Env(每行 KEY=VALUE)", + "loadFailed": "加载自定义 MCP 失败", + "saveSuccess": "自定义 MCP 已保存", + "saveFailed": "保存失败", + "jsonInvalid": "JSON 格式无效", + "visualInvalid": "可视化配置不完整,请先修正名称与必填项", + "duplicateName": "服务器名称不能重复", + "emptyName": "请填写服务器名称", + "probeNeedConfig": "请先填写完整配置再探测", + "probeOk": "探测成功,发现 {{count}} 个工具", + "deleteConfirm": "确定删除 MCP 服务器「{{name}}」?", + "deleteConfirmHint": "删除后需点击保存才会生效。" + }, "configuredBadge": "已连接", "clickToConnect": "点击连接", "noInstancesHint": "在「服务目录」中选择服务并完成授权", diff --git a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx new file mode 100644 index 0000000..67ece4a --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx @@ -0,0 +1,288 @@ +import type { CSSProperties } from "react"; +import { Button, Input, Modal, Select, Switch } from "antd"; +import { + Activity, + Cable, + CheckCircle2, + ChevronDown, + ChevronUp, + Trash2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import type { CustomMcpTransport } from "../../../api/modules/connectors"; +import { accentForServerName, type ServerCardState } from "./customMcpUtils"; +import styles from "./index.module.less"; + +interface CustomMcpServerCardProps { + card: ServerCardState; + probing: boolean; + probeTools?: { name: string; description: string }[]; + transportOptions: { value: string; label: string }[]; + onUpdate: (key: string, patch: Partial) => void; + onToggleEnabled: (enabled: boolean) => void; + onRemove: () => void; + onProbe: () => void; +} + +export function CustomMcpServerCard({ + card, + probing, + probeTools, + transportOptions, + onUpdate, + onToggleEnabled, + onRemove, + onProbe, +}: CustomMcpServerCardProps) { + const { t } = useTranslation(); + const isHttp = card.transport === "streamable_http"; + const displayName = + card.name.trim() || (isHttp ? "http-server" : "stdio-server"); + const accent = accentForServerName(displayName); + const summary = isHttp + ? card.url.trim() || "https://…" + : [ + card.command.trim(), + ...card.argsText + .split("\n") + .map((s) => s.trim()) + .filter(Boolean), + ] + .filter(Boolean) + .join(" ") || "npx / uvx / python"; + + const handleRemove = () => { + Modal.confirm({ + title: t("connectors.customMcp.deleteConfirm", { + name: displayName, + defaultValue: `确定删除 MCP 服务器「${displayName}」?`, + }), + content: t( + "connectors.customMcp.deleteConfirmHint", + "删除后需点击保存才会生效。", + ), + okText: t("common.delete"), + okButtonProps: { danger: true }, + cancelText: t("common.cancel"), + onOk: onRemove, + }); + }; + + return ( +
+
+
+ + + +
+ + {displayName} + + + {isHttp + ? t("connectors.customMcp.transportHttp", "HTTP") + : t("connectors.customMcp.transportStdio", "Stdio")} + +
+ {summary} +
+
+
+
+ +
+
+ + {!card.collapsed ? ( +
+
+ + onUpdate(card.key, { name: e.target.value })} + placeholder={isHttp ? "http-server" : "stdio-server"} + /> +
+
+ + onUpdate(card.key, { url: e.target.value })} + placeholder="https://mcp.example.com/mcp" + /> +
+
+ + + onUpdate(card.key, { headersText: e.target.value }) + } + autoSize={{ minRows: 2, maxRows: 6 }} + placeholder={"Authorization: Bearer sk-..."} + /> +
+ + ) : ( + <> +
+ + + onUpdate(card.key, { command: e.target.value }) + } + placeholder="npx / uvx / python" + /> +
+
+ + + onUpdate(card.key, { argsText: e.target.value }) + } + autoSize={{ minRows: 3, maxRows: 8 }} + placeholder={"-y\nsome-mcp-package"} + /> +
+
+ + + onUpdate(card.key, { envText: e.target.value }) + } + autoSize={{ minRows: 2, maxRows: 6 }} + placeholder={"API_KEY=..."} + /> +
+ + )} + +
+ +
+ + {probeTools !== undefined ? ( +
+
+ +
+
+ {t("connectors.probeToolsTitle", "探测成功")} +
+
+ {probeTools.length > 0 + ? t("connectors.probeToolsHint", { + count: probeTools.length, + defaultValue: `连接正常,获取以下工具列表(共 ${probeTools.length} 个)`, + }) + : t( + "connectors.probeToolsEmpty", + "连接正常,但未发现可用工具", + )} +
+
+
+ {probeTools.length > 0 ? ( +
    + {probeTools.map((tool, index) => ( +
  • + {index + 1} +
    +
    {tool.name}
    + {tool.description ? ( +
    + {tool.description} +
    + ) : null} +
    +
  • + ))} +
+ ) : null} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx b/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx new file mode 100644 index 0000000..6f5aaf7 --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx @@ -0,0 +1,401 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input, Segmented, Spin, message } from "antd"; +import { Globe, Plus, Terminal } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { + connectorsApi, + type CustomMcpServerSpec, + type CustomMcpServers, + type CustomMcpTransport, +} from "../../../api/modules/connectors"; +import { apiErrorMessage } from "../../../utils/apiError"; +import { CustomMcpServerCard } from "./CustomMcpServerCard"; +import { + EXAMPLE_JSON, + cardsToServers, + newCard, + notifyConnectorsChanged, + serversToCards, + type EditorMode, + type ServerCardState, +} from "./customMcpUtils"; +import styles from "./index.module.less"; + +export function CustomMcpTab() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [probingKey, setProbingKey] = useState(null); + const [probeResults, setProbeResults] = useState< + Record + >({}); + const [mode, setMode] = useState("visual"); + const [cards, setCards] = useState([]); + const [jsonText, setJsonText] = useState("{}"); + const [jsonError, setJsonError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const { servers } = await connectorsApi.getCustomMcp(); + const nextCards = serversToCards(servers); + setCards(nextCards); + setProbeResults({}); + setJsonText(JSON.stringify(servers, null, 2)); + setJsonError(null); + } catch (e) { + console.error(e); + message.error( + apiErrorMessage( + e, + t("connectors.customMcp.loadFailed", "加载自定义 MCP 失败"), + t, + ), + ); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const syncJsonFromCards = useCallback((nextCards: ServerCardState[]) => { + try { + const servers = cardsToServers(nextCards); + setJsonText(JSON.stringify(servers, null, 2)); + setJsonError(null); + } catch { + // keep previous json while visual has incomplete names + } + }, []); + + const updateCard = (key: string, patch: Partial) => { + setCards((prev) => { + const next = prev.map((card) => + card.key === key ? { ...card, ...patch } : card, + ); + syncJsonFromCards(next); + return next; + }); + }; + + const handleModeChange = (nextMode: EditorMode) => { + if (nextMode === mode) return; + if (nextMode === "json") { + try { + const servers = cardsToServers(cards); + setJsonText(JSON.stringify(servers, null, 2)); + setJsonError(null); + } catch (e) { + message.warning( + t( + "connectors.customMcp.visualInvalid", + "可视化配置不完整,请先修正名称与必填项", + ), + ); + console.error(e); + return; + } + } else { + try { + const parsed = JSON.parse(jsonText) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("root must be object"); + } + const servers = parsed as CustomMcpServers; + setCards(serversToCards(servers)); + setJsonError(null); + } catch { + setJsonError( + t( + "connectors.customMcp.jsonInvalid", + "JSON 格式无效,无法切换到可视化", + ), + ); + message.error( + t( + "connectors.customMcp.jsonInvalid", + "JSON 格式无效,无法切换到可视化", + ), + ); + return; + } + } + setMode(nextMode); + }; + + const handleAdd = (transport: CustomMcpTransport) => { + setCards((prev) => { + const next = [...prev, newCard(transport, prev.length)]; + syncJsonFromCards(next); + return next; + }); + setMode("visual"); + }; + + const handleRemove = (key: string) => { + setCards((prev) => { + const next = prev.filter((card) => card.key !== key); + syncJsonFromCards(next); + return next; + }); + setProbeResults((prev) => { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const clearProbeResult = (key: string) => { + setProbeResults((prev) => { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const resolveServersForSave = (): CustomMcpServers | null => { + if (mode === "json") { + try { + const parsed = JSON.parse(jsonText) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("root must be object"); + } + setJsonError(null); + return parsed as CustomMcpServers; + } catch { + setJsonError(t("connectors.customMcp.jsonInvalid", "JSON 格式无效")); + message.error(t("connectors.customMcp.jsonInvalid", "JSON 格式无效")); + return null; + } + } + try { + return cardsToServers(cards); + } catch (e) { + const code = e instanceof Error ? e.message : ""; + if (code === "duplicate_name") { + message.warning( + t("connectors.customMcp.duplicateName", "服务器名称不能重复"), + ); + } else if (code === "empty_name") { + message.warning( + t("connectors.customMcp.emptyName", "请填写服务器名称"), + ); + } else { + message.warning( + apiErrorMessage( + e, + t("connectors.customMcp.visualInvalid", "请检查配置后重试"), + t, + ), + ); + } + return null; + } + }; + + const handleSave = async () => { + const servers = resolveServersForSave(); + if (!servers) return; + setSaving(true); + try { + const saved = await connectorsApi.putCustomMcp(servers); + const nextCards = serversToCards(saved.servers); + setCards(nextCards); + setJsonText(JSON.stringify(saved.servers, null, 2)); + notifyConnectorsChanged(); + message.success( + t("connectors.customMcp.saveSuccess", "自定义 MCP 已保存"), + ); + } catch (e) { + console.error(e); + message.error( + apiErrorMessage(e, t("connectors.customMcp.saveFailed", "保存失败"), t), + ); + } finally { + setSaving(false); + } + }; + + const handleProbe = async (card: ServerCardState) => { + let server: CustomMcpServerSpec; + try { + const map = cardsToServers([card]); + server = map[card.name.trim()]; + } catch { + message.warning( + t("connectors.customMcp.probeNeedConfig", "请先填写完整配置再探测"), + ); + return; + } + setProbingKey(card.key); + clearProbeResult(card.key); + try { + const result = await connectorsApi.testCustomMcp({ server }); + if (result.ok) { + const tools = result.tools ?? []; + setProbeResults((prev) => ({ ...prev, [card.key]: tools })); + updateCard(card.key, { collapsed: false }); + if (tools.length === 0) { + message.success( + t("connectors.probeToolsEmpty", "连接正常,但未发现可用工具"), + ); + } + } else { + message.error(result.error ?? t("connectors.probeFailed", "探测失败")); + } + } catch (e) { + console.error(e); + message.error( + apiErrorMessage(e, t("connectors.probeFailed", "探测失败"), t), + ); + } finally { + setProbingKey(null); + } + }; + + const transportOptions = useMemo( + () => [ + { value: "streamable_http", label: "streamable_http" }, + { value: "stdio", label: "stdio" }, + ], + [], + ); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ {t( + "connectors.customMcp.introTitle", + "MCP 服务器配置(JSON 格式)。参考以下格式:", + )} +
+
{EXAMPLE_JSON}
+
+ +
+ handleModeChange(value as EditorMode)} + options={[ + { + value: "visual", + label: t("connectors.customMcp.modeVisual", "可视化"), + }, + { + value: "json", + label: t("connectors.customMcp.modeJson", " JSON"), + }, + ]} + /> +
+ + {mode === "json" ? ( +
+ { + setJsonText(e.target.value); + setJsonError(null); + }} + autoSize={{ minRows: 16, maxRows: 32 }} + className={styles.customMcpJsonArea} + spellCheck={false} + /> + {jsonError ? ( +
{jsonError}
+ ) : null} +
+ ) : ( + <> +
+ + +
+ +
+
+ {t("connectors.customMcp.listTitle", "已添加的服务器")} + {cards.length > 0 ? ( + + {cards.length} + + ) : null} +
+ + {cards.length === 0 ? ( +
+ {t( + "connectors.customMcp.emptyList", + "尚未添加自定义 MCP,点击上方按钮开始配置", + )} +
+ ) : ( +
+ {cards.map((card) => ( + + updateCard(card.key, { enabled }) + } + onRemove={() => handleRemove(card.key)} + onProbe={() => void handleProbe(card)} + /> + ))} +
+ )} +
+ + )} + +
+ +
+
+ ); +} diff --git a/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx b/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx index 534eea1..3729cd5 100644 --- a/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx +++ b/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx @@ -1,4 +1,4 @@ -import { Cloud } from "lucide-react"; +import { Cable } from "lucide-react"; import { getConnectorLogo } from "../../../assets/connectors"; import type { ConnectorCatalogEntry } from "../../../api/modules/connectors"; @@ -67,7 +67,13 @@ export function ConnectorLogo({ }) { const src = getConnectorLogo(kind); if (!src) { - return ; + return ( + + ); } return ( >> 0; + } + return MCP_ACCENT_PALETTE[hash % MCP_ACCENT_PALETTE.length]; +} + +export function headersToText(headers?: Record): string { + if (!headers) return ""; + return Object.entries(headers) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); +} + +export function parseHeadersText(text: string): Record { + const out: Record = {}; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf(":"); + if (idx <= 0) { + throw new Error(`invalid header line: ${trimmed}`); + } + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!key) throw new Error(`invalid header line: ${trimmed}`); + out[key] = value; + } + return out; +} + +export function envToText(env?: Record): string { + if (!env) return ""; + return Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); +} + +export function parseEnvText(text: string): Record { + const out: Record = {}; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf("="); + if (idx <= 0) { + throw new Error(`invalid env line: ${trimmed}`); + } + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1); + if (!key) throw new Error(`invalid env line: ${trimmed}`); + out[key] = value; + } + return out; +} + +export function serversToCards(servers: CustomMcpServers): ServerCardState[] { + return Object.entries(servers).map(([name, spec], index) => ({ + key: `${name}-${index}`, + name, + transport: spec.transport === "stdio" ? "stdio" : "streamable_http", + url: spec.url ?? "", + headersText: headersToText(spec.headers), + command: spec.command ?? "", + argsText: (spec.args ?? []).join("\n"), + envText: envToText(spec.env), + enabled: spec.enabled !== false, + collapsed: true, + })); +} + +export function cardsToServers(cards: ServerCardState[]): CustomMcpServers { + const servers: CustomMcpServers = {}; + for (const card of cards) { + const name = card.name.trim(); + if (!name) { + throw new Error("empty_name"); + } + if (servers[name]) { + throw new Error("duplicate_name"); + } + const spec: CustomMcpServerSpec = { + transport: card.transport, + enabled: card.enabled, + }; + if (card.transport === "streamable_http") { + spec.url = card.url.trim(); + const headers = parseHeadersText(card.headersText); + if (Object.keys(headers).length > 0) { + spec.headers = headers; + } + } else { + spec.command = card.command.trim(); + const args = card.argsText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if (args.length > 0) { + spec.args = args; + } + const env = parseEnvText(card.envText); + if (Object.keys(env).length > 0) { + spec.env = env; + } + } + servers[name] = spec; + } + return servers; +} + +export function newCard( + transport: CustomMcpTransport, + index: number, +): ServerCardState { + const base = transport === "stdio" ? "stdio-server" : "http-server"; + return { + key: `${base}-${Date.now()}-${index}`, + name: `${base}-${index + 1}`, + transport, + url: "", + headersText: "", + command: "", + argsText: "", + envText: "", + enabled: true, + collapsed: false, + }; +} + +/** Notify chat composer to refresh connector list after custom MCP save. */ +export const CONNECTORS_CHANGED_EVENT = "octop:connectors-changed"; + +export function notifyConnectorsChanged(): void { + window.dispatchEvent(new CustomEvent(CONNECTORS_CHANGED_EVENT)); +} diff --git a/dashboard/src/pages/Agent/Connectors/index.module.less b/dashboard/src/pages/Agent/Connectors/index.module.less index 4da4edb..945b04e 100644 --- a/dashboard/src/pages/Agent/Connectors/index.module.less +++ b/dashboard/src/pages/Agent/Connectors/index.module.less @@ -280,6 +280,11 @@ border-radius: 10px; } +.connectorMcpFallbackIcon { + color: #0d9488; + flex-shrink: 0; +} + /* ── Auth drawer ──────────────────────────────────────────────── */ .drawerBody { @@ -503,3 +508,351 @@ color: var(--fn-text-secondary); } } + +/* ── Tabs ──────────────────────────────────────────────────────── */ + +.tabBar { + display: flex; + gap: 0; + margin-bottom: 4px; + border-bottom: 1px solid var(--fn-border-secondary); + flex-shrink: 0; +} + +.tab { + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: var(--fn-text-secondary); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: + color 0.15s ease, + border-color 0.15s ease; + margin-bottom: -1px; + + &:hover { + color: var(--fn-text-primary); + } + + &.active { + color: var(--fn-color-brand); + border-bottom-color: var(--fn-color-brand); + } +} + +/* ── Custom MCP tab ────────────────────────────────────────────── */ + +.customMcpTab { + display: flex; + flex-direction: column; + gap: 16px; +} + +.customMcpIntro { + display: flex; + flex-direction: column; + gap: 8px; +} + +.customMcpIntroTitle { + font-size: 13px; + color: var(--fn-text-secondary); + line-height: 1.5; +} + +.customMcpExample { + margin: 0; + padding: 12px 14px; + font-size: 12px; + line-height: 1.5; + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + color: var(--fn-text-secondary); + background: var(--fn-surface-sunken, var(--fn-bg-secondary)); + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-md); + overflow-x: auto; + white-space: pre-wrap; +} + +.customMcpToolbar { + display: flex; + justify-content: flex-start; +} + +.customMcpJsonEditor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.customMcpJsonArea { + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); + font-size: 13px; +} + +.customMcpJsonError { + font-size: 13px; + color: var(--fn-color-danger, #cf1322); +} + +.customMcpAddRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.customMcpAddBtn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 52px; + padding: 12px 16px; + border: 1.5px dashed var(--fn-border-secondary); + border-radius: var(--fn-radius-lg); + background: transparent; + color: var(--fn-text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s, + color 0.15s, + background 0.15s; + + &:hover { + border-color: var(--fn-color-brand); + color: var(--fn-color-brand); + background: color-mix(in srgb, var(--fn-color-brand) 4%, transparent); + } +} + +.customMcpListSection { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 4px; +} + +.customMcpListTitle { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.customMcpListCount { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: var(--fn-text-secondary); + background: var(--fn-bg-secondary); +} + +.customMcpEmpty { + padding: 28px 16px; + text-align: center; + font-size: 13px; + color: var(--fn-text-tertiary); + border: 1px dashed var(--fn-border-secondary); + border-radius: var(--fn-radius-lg); + background: var(--fn-surface-sunken, var(--fn-bg-secondary)); +} + +.customMcpGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +.customMcpServerCard { + --mcp-accent: #0d9488; + display: flex; + flex-direction: column; + gap: 0; + border: 2px solid var(--fn-card-border-normal, var(--fn-border-secondary)); + border-radius: var(--fn-radius-lg); + background: var(--fn-bg-primary); + overflow: hidden; + transition: + border-color 0.15s, + box-shadow 0.15s, + transform 0.15s; + + &:hover { + border-color: var(--mcp-accent); + transform: translateY(-1px); + box-shadow: 0 6px 16px + color-mix(in srgb, var(--mcp-accent) 16%, transparent); + } +} + +.customMcpServerCardOpen { + border-color: var(--mcp-accent); + box-shadow: 0 4px 16px color-mix(in srgb, var(--mcp-accent) 14%, transparent); + grid-column: 1 / -1; +} + +.customMcpServerCardDisabled { + opacity: 0.72; +} + +.customMcpServerTop { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px; +} + +.customMcpServerIdentity { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; + flex: 1; +} + +.customMcpServerIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + color: var(--mcp-accent); + background: color-mix(in srgb, var(--mcp-accent) 14%, transparent); + flex-shrink: 0; +} + +.customMcpServerMeta { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 0; + flex: 1; +} + +.customMcpServerName { + font-size: 15px; + font-weight: 600; + line-height: 1.35; + color: var(--fn-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.customMcpTransportBadge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.customMcpTransportHttp { + color: #0369a1; + background: color-mix(in srgb, #0ea5e9 14%, transparent); +} + +.customMcpTransportStdio { + color: #a16207; + background: color-mix(in srgb, #eab308 16%, transparent); +} + +.customMcpServerSummary { + font-size: 12px; + line-height: 1.4; + color: var(--fn-text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + monospace + ); +} + +.customMcpServerControls { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.customMcpCardBody { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 16px 16px; + border-top: 1px solid var(--fn-border-secondary); + padding-top: 14px; + + .probeResult { + margin-top: 4px; + } +} + +.customMcpField { + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 13px; + font-weight: 500; + color: var(--fn-text-secondary); + } +} + +.requiredMark { + color: var(--fn-color-danger, #cf1322); +} + +.customMcpCardActions { + display: flex; + justify-content: flex-end; +} + +.customMcpFooter { + display: flex; + justify-content: flex-end; + padding-top: 4px; +} diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index 40f9fe0..82000fb 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -21,6 +21,7 @@ import { type ConnectorInstanceDetail, } from "../../../api/modules/connectors"; import { ConnectorCard } from "./ConnectorCard"; +import { CustomMcpTab } from "./CustomMcpTab"; import { INLINE_CREDENTIAL_GUIDE_KINDS, HIDE_INLINE_FIELD_GUIDE_KINDS, @@ -933,6 +934,7 @@ function ConnectorConfigDrawer({ export default function ConnectorsPage() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); + const [activeTab, setActiveTab] = useState<"builtin" | "custom">("builtin"); const [drawerEntry, setDrawerEntry] = useState( null, ); @@ -1012,7 +1014,30 @@ export default function ConnectorsPage() { subtitle={t("pageShell.connectors.subtitle")} >
- {loading ? ( +
+ + +
+ + {activeTab === "custom" ? ( + + ) : loading ? (
diff --git a/dashboard/src/pages/Agent/Memory/Overview.test.tsx b/dashboard/src/pages/Agent/Memory/Overview.test.tsx index d851cc3..2f1795e 100644 --- a/dashboard/src/pages/Agent/Memory/Overview.test.tsx +++ b/dashboard/src/pages/Agent/Memory/Overview.test.tsx @@ -43,6 +43,10 @@ vi.mock("../../../api/modules/memoryDashboard", () => ({ a.deprecated_at != null, })); +vi.mock("./MigrateMemory", () => ({ + default: () => null, +})); + import { memoryDashboardApi } from "../../../api/modules/memoryDashboard"; import Overview from "./Overview"; @@ -74,17 +78,16 @@ describe("", () => { expect(screen.getByText("概览")).toBeInTheDocument(); }); - expect(screen.getByText(/条原始事件/)).toBeInTheDocument(); + expect(screen.getByText(/条记忆/)).toBeInTheDocument(); expect(screen.getByText("记忆类型分布")).toBeInTheDocument(); - expect(screen.getByText("近 7 天新增趋势")).toBeInTheDocument(); - // Closing block: memory composition by confidence, importance, and status. + expect(screen.getByText("近 14 天新增趋势")).toBeInTheDocument(); expect(screen.getByText("记忆构成")).toBeInTheDocument(); expect(screen.getByText("置信度")).toBeInTheDocument(); expect(screen.getByText("重要度")).toBeInTheDocument(); expect(api.statsCounts).toHaveBeenCalledWith("ZYWZTD"); expect(api.statsAtomKinds).toHaveBeenCalledWith("ZYWZTD"); - expect(api.statsGrowth).toHaveBeenCalledWith("ZYWZTD", 7); + expect(api.statsGrowth).toHaveBeenCalledWith("ZYWZTD", 14); }); it("renders KPI strip without Journal content", async () => { @@ -110,9 +113,10 @@ describe("", () => { expect(screen.getAllByText("18").length).toBeGreaterThan(0); expect(screen.getAllByText("34").length).toBeGreaterThan(0); - expect(screen.getAllByText("891").length).toBeGreaterThan(0); - expect(screen.getByText("待处理")).toBeInTheDocument(); - expect(screen.getByText("待刷新主题")).toBeInTheDocument(); + expect(screen.getByText("待沉淀")).toBeInTheDocument(); + expect(screen.getByText("记忆条目")).toBeInTheDocument(); + expect(screen.getByText("关键人事物")).toBeInTheDocument(); + expect(screen.queryByText("891")).not.toBeInTheDocument(); expect( screen.queryByText(/Promoted via dashboard/), ).not.toBeInTheDocument(); @@ -130,25 +134,28 @@ describe("", () => { expect(screen.getByText("概览")).toBeInTheDocument(); }); - expect(screen.queryByText(/条原始事件/)).not.toBeInTheDocument(); + expect(screen.queryByText(/条记忆/)).not.toBeInTheDocument(); expect( - screen.getAllByText(/暂无记忆类型数据|近 7 天暂无新增/).length, + screen.getAllByText(/暂无记忆类型数据|近 14 天暂无新增|暂无记忆构成数据/) + .length, ).toBeGreaterThanOrEqual(2); }); it("renders subtitle when stats_counts returns custom values", async () => { api.statsCounts.mockResolvedValue( - statsCountsFixture({ atoms: 147, raw_events: 234 }), + statsCountsFixture({ atoms: 147, entities: 22, raw_events: 234 }), ); api.statsAtomKinds.mockResolvedValue({ series: [] }); api.statsGrowth.mockResolvedValue({ series: [] }); + api.listAtoms.mockResolvedValue(listAtomsResp([])); render(); await waitFor(() => { expect(screen.getAllByText("147").length).toBeGreaterThan(0); }); - expect(screen.getAllByText("234").length).toBeGreaterThan(0); + expect(screen.getAllByText("22").length).toBeGreaterThan(0); + expect(screen.queryByText("234")).not.toBeInTheDocument(); }); it("does not crash when agentId is empty (no fan-out)", () => { diff --git a/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx b/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx index c766049..e5604b8 100644 --- a/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx +++ b/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx @@ -108,7 +108,7 @@ describe("", () => { await waitFor(() => { expect(screen.getAllByText(/用户画像/).length).toBeGreaterThan(0); }); - expect(screen.getByText(/agent 还在了解你/)).toBeInTheDocument(); + expect(screen.getByText(/Octop 还在了解你/)).toBeInTheDocument(); }); it("caps a section at 6 rows and shows 查看全部 that calls onViewAll", async () => { diff --git a/dashboard/src/pages/Chat/components/ContextWindowRing.tsx b/dashboard/src/pages/Chat/components/ContextWindowRing.tsx index df156af..999be97 100644 --- a/dashboard/src/pages/Chat/components/ContextWindowRing.tsx +++ b/dashboard/src/pages/Chat/components/ContextWindowRing.tsx @@ -6,6 +6,7 @@ import { type ContextUsageBreakdown, type ContextUsageSegmentKey, } from "../../../api/modules/octopThreads"; +import { isPendingThread } from "../hooks/useSessions"; import styles from "../index.module.less"; const DEFAULT_MAX = 128_000; @@ -81,7 +82,7 @@ export default function ContextWindowRing({ const loadBreakdown = useCallback( async (opts?: { silent?: boolean; force?: boolean }) => { - if (!agentId || !threadId) return; + if (!agentId || !threadId || isPendingThread(threadId)) return; const cached = cacheRef.current; if ( !opts?.force && @@ -120,7 +121,7 @@ export default function ContextWindowRing({ // Prefetch when the thread / cap / filters change — not when stream hint ticks. useEffect(() => { - if (!agentId || !threadId) { + if (!agentId || !threadId || isPendingThread(threadId)) { setBreakdown(null); cacheRef.current = null; lastHintRefreshRef.current = null; @@ -135,7 +136,9 @@ export default function ContextWindowRing({ // After a turn finishes, ``usedTokens`` settles on last_input_tokens — // soft-refresh once per distinct hint (debounced) for a fresh harness stamp. useEffect(() => { - if (!agentId || !threadId || hintUsed <= 0) return; + if (!agentId || !threadId || isPendingThread(threadId) || hintUsed <= 0) { + return; + } if (lastHintRefreshRef.current === hintUsed) return; const timer = window.setTimeout(() => { lastHintRefreshRef.current = hintUsed; diff --git a/dashboard/src/pages/Chat/components/WelcomeQuickCards.tsx b/dashboard/src/pages/Chat/components/WelcomeQuickCards.tsx index ebc5390..a8f1367 100644 --- a/dashboard/src/pages/Chat/components/WelcomeQuickCards.tsx +++ b/dashboard/src/pages/Chat/components/WelcomeQuickCards.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { iconForName } from "../../Experts/components/iconForName"; +import { pastelIconBackground } from "../../../utils/pastelIconBackground"; import type { WelcomeQuickCard } from "./WelcomeScreen"; import styles from "../index.module.less"; @@ -40,7 +41,10 @@ export default function WelcomeQuickCards({ >
{iconForName(card.icon_name, 18)}
diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index 67f64ad..e048815 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -3,6 +3,7 @@ import { connectorsApi } from "../../../api/modules/connectors"; import { providerApi } from "../../../api/modules/provider"; import type { ResolvedModel } from "../../../api/types"; import type { SkillSpec } from "../../Agent/Skills/useSkills"; +import { CONNECTORS_CHANGED_EVENT } from "../../Agent/Connectors/customMcpUtils"; import { loadSavedConnectors, loadSavedSkills, @@ -53,9 +54,11 @@ export function useChatComposerResources( loadConnectors(); const onFocus = () => loadConnectors(); window.addEventListener("focus", onFocus); + window.addEventListener(CONNECTORS_CHANGED_EVENT, loadConnectors); return () => { cancelled = true; window.removeEventListener("focus", onFocus); + window.removeEventListener(CONNECTORS_CHANGED_EVENT, loadConnectors); }; }, [resolvedAgentId]); diff --git a/dashboard/src/pages/Chat/hooks/useExpertQuickCards.ts b/dashboard/src/pages/Chat/hooks/useExpertQuickCards.ts index c5fb470..6f7a087 100644 --- a/dashboard/src/pages/Chat/hooks/useExpertQuickCards.ts +++ b/dashboard/src/pages/Chat/hooks/useExpertQuickCards.ts @@ -49,7 +49,10 @@ export function useExpertChatWelcome(agent: OctopAgent | null): { .then((data) => { if (cancelled) return; setQuickCards(mapQuickPrompts(data.quick_prompts, locale)); - setWelcomeSuffix(pickLocale(data.welcome_message, locale) || null); + setWelcomeSuffix( + pickLocale(data.welcome_message, locale, { crossFallback: false }) || + null, + ); }) .catch(() => { if (!cancelled) { diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 03af949..41ba190 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -37,6 +37,7 @@ import { ChatFilePreviewProvider } from "./ChatFilePreviewContext"; import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatComposerChrome from "./components/ChatComposerChrome"; import { isAgentChatReady } from "../../utils/agentError"; +import { promptNeedsUserInput } from "../../utils/quickInputPrefill"; import styles from "./index.module.less"; export default function ChatPage() { @@ -400,6 +401,11 @@ function ChatPageInner() { (text: string) => { const trimmed = text.trim(); if (!trimmed) return; + if (promptNeedsUserInput(text)) { + prefillInputRef.current = trimmed; + chatInputRef.current?.setPrefillText(trimmed); + return; + } wrappedHandleSend(trimmed); }, [wrappedHandleSend], diff --git a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx index 36849de..83c2ea7 100644 --- a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx +++ b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx @@ -14,9 +14,11 @@ import { FolderOpen, MessageSquare, Bot, + Sparkles, } from "lucide-react"; import WorkspaceDrawer from "../../Agent/Workspace/components/WorkspaceDrawer"; import SubagentCatalogDrawer from "./SubagentCatalogDrawer"; +import SkillCatalogDrawer from "./SkillCatalogDrawer"; import { request } from "../../../api/request"; import type { OctopAgent } from "../../../context/AgentContext"; import { useAgent } from "../../../context/AgentContext"; @@ -61,6 +63,9 @@ export default function AgentExpertsTable({ const [localStates, setLocalStates] = useState>({}); const [actionLoadingId, setActionLoadingId] = useState(null); const [workspaceAgentId, setWorkspaceAgentId] = useState(null); + const [skillCatalogAgentId, setSkillCatalogAgentId] = useState( + null, + ); const [subagentCatalogAgentId, setSubagentCatalogAgentId] = useState< string | null >(null); @@ -308,7 +313,7 @@ export default function AgentExpertsTable({ { title: t("experts.table.actions", "操作"), key: "actions", - width: 300, + width: 340, fixed: "right", render: (_v, row) => { const state = localStates[row.agent_id] ?? row.state; @@ -317,22 +322,22 @@ export default function AgentExpertsTable({ const chatReady = isAgentChatReady(state); return (
- void handleToggle(row, checked)} - /> - + + void handleToggle(row, checked)} + /> + + + - + setWorkspaceAgentId(null)} /> + setSkillCatalogAgentId(null)} + /> ; + onCreated: (agentId: string) => void; +} + +const SCENE_ALL = ""; + +function descOf(expert: MarketExpert, lang: "zh" | "en"): string { + return pickLocale(expert.description, lang) || ""; +} + +function labelOf(expert: MarketExpert, lang: "zh" | "en"): string { + return pickLocale(expert.label, lang) || expert.slug; +} + +function marketErrorMessage(err: unknown, fallback: string): string { + const raw = err instanceof Error ? err.message : String(err || ""); + if (raw.includes("404") || raw.includes("Not Found")) { + return fallback; + } + return raw || fallback; +} + +function sceneLabel( + scene: string, + t: (key: string, options?: string | { defaultValue?: string }) => string, +): string { + if (!scene) return t("experts.sceneAll", "全部"); + return t(`experts.scenes.${scene}`, { defaultValue: scene }); +} + +function promptText( + prompt: ExpertMarketQuickPrompt, + lang: "zh" | "en", + field: "title" | "description", +): string { + return pickLocale(prompt[field], lang) || ""; +} + +export default function ExpertMarketTab({ + lang, + installedExpertIds, + onCreated, +}: ExpertMarketTabProps) { + const { t } = useTranslation(); + const [items, setItems] = useState([]); + const [scenes, setScenes] = useState([]); + const [activeScene, setActiveScene] = useState(SCENE_ALL); + const [keyword, setKeyword] = useState(""); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [selected, setSelected] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [creatingSlug, setCreatingSlug] = useState(null); + const debounceRef = useRef | null>(null); + + const fetchMarket = useCallback( + async (query = keyword, scene = activeScene, force = false) => { + if (force) setRefreshing(true); + else setLoading(true); + try { + const resp = await expertMarketApi.list(query, scene); + setItems(resp?.items ?? []); + if (Array.isArray(resp?.scenes) && resp.scenes.length > 0) { + setScenes(resp.scenes); + } + setErrorMessage(null); + } catch (err) { + const msg = marketErrorMessage(err, t("experts.marketBackendMissing")); + setErrorMessage(msg); + message.error(msg); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [activeScene, keyword, t], + ); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + const scene = keyword.trim() ? SCENE_ALL : activeScene; + void fetchMarket(keyword, scene); + }, 300); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [keyword, activeScene, fetchMarket]); + + const openDetail = useCallback( + async (expert: MarketExpert) => { + setSelected(expert); + setDetailLoading(true); + try { + const detail = await expertMarketApi.get(expert.slug); + setSelected(detail); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.marketDetailLoadFailed"), t), + ); + } finally { + setDetailLoading(false); + } + }, + [t], + ); + + const createMarketExpert = useCallback( + async (expert: MarketExpert) => { + if (creatingSlug) return; + setCreatingSlug(expert.slug); + try { + const result = await expertMarketApi.install(expert.slug); + const enrichment = result.market?.welcome_enrichment; + if (enrichment === "pending") { + message.success( + t("experts.marketCreateSuccessEnriching", { name: result.name }), + ); + } else { + message.success( + t("experts.marketCreateSuccess", { name: result.name }), + ); + } + setSelected(null); + onCreated(result.agent_id); + } catch (err) { + message.error(apiErrorMessage(err, t("experts.createFailed"), t)); + } finally { + setCreatingSlug(null); + } + }, + [creatingSlug, onCreated, t], + ); + + const totalText = useMemo( + () => t("experts.totalMarket", { count: items.length }), + [items.length, t], + ); + + const sceneOptions = useMemo( + () => [ + { value: SCENE_ALL, label: sceneLabel(SCENE_ALL, t) }, + ...scenes.map((scene) => ({ + value: scene, + label: sceneLabel(scene, t), + })), + ], + [scenes, t], + ); + + const workflowDoc = useMemo(() => { + const raw = + selected?.content?.[lang] || + selected?.content?.zh || + selected?.content?.en || + ""; + if (!raw.trim()) { + return { + body: "", + meta: parseWorkflowFrontmatterMeta(null), + }; + } + const split = splitMarkdownFrontmatter(raw); + return { + body: split.body, + meta: parseWorkflowFrontmatterMeta(split.raw), + }; + }, [lang, selected]); + + if (loading && items.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {totalText} +
+ } + allowClear + value={keyword} + placeholder={t("experts.marketSearchPlaceholder")} + onChange={(e) => setKeyword(e.target.value)} + /> + +
+
+ + {!keyword && sceneOptions.length > 1 && ( +
+ setActiveScene(String(v))} + options={sceneOptions} + className={styles.marketSceneTabs} + /> +
+ )} + + {items.length === 0 ? ( +
+ +
+ {errorMessage + ? t("experts.marketLoadFailed") + : t("experts.emptyMarket")} +
+
+ {errorMessage || t("experts.emptyMarketHint")} +
+ {errorMessage && ( +
+ +
+ )} +
+ ) : ( +
+ {items.map((expert) => { + const installed = installedExpertIds.has(expert.id); + const label = labelOf(expert, lang); + const desc = descOf(expert, lang); + const accent = expert.color || "#6366f1"; + return ( +
void openDetail(expert)} + style={ + { + "--expert-accent": accent, + } as CSSProperties + } + > + {installed && ( +
+ +
+ )} +
+
+ {expert.icon_url ? ( + + ) : ( + iconForName(expert.icon_name || "zap", 20) + )} +
+
+
{label}
+ {installed && ( +
+ {t("experts.installedBadge")} +
+ )} +
+
+
+ {desc || t("experts.noMarketDescription")} +
+
+ + + {t("experts.marketSkillCount", { + count: + expert.skill_count ?? expert.skill_slugs?.length ?? 0, + })} + + +
+
+ ); + })} +
+ )} + + setSelected(null)} + title={selected ? labelOf(selected, lang) : ""} + footer={ + selected ? ( + + ) : null + } + > + {selected && ( +
+

+ {descOf(selected, lang) || t("experts.noMarketDescription")} +

+
+
+ {t("experts.marketQuickPrompts")} +
+ {detailLoading ? ( + + ) : selected.quick_prompts && + selected.quick_prompts.length > 0 ? ( +
+ {selected.quick_prompts.map((card, idx) => ( +
+
+ {iconForName(card.icon_name || "zap", 16)} +
+
+
+ {promptText(card, lang, "title")} +
+
+ {promptText(card, lang, "description")} +
+
+
+ ))} +
+ ) : ( +
+ {t("experts.marketQuickPromptsEmpty")} +
+ )} +
+
+
+ {t("experts.marketWorkflowPrompt")} +
+ {detailLoading ? ( +
+ +
+ ) : workflowDoc.body || + workflowDoc.meta.version || + workflowDoc.meta.children.length > 0 ? ( +
+ {(workflowDoc.meta.displayName || + workflowDoc.meta.version || + workflowDoc.meta.packageType || + workflowDoc.meta.children.length > 0) && ( +
+
+ {t("experts.marketWorkflowMeta")} +
+
+ {workflowDoc.meta.displayName && ( + + {workflowDoc.meta.displayName} + + )} + {workflowDoc.meta.version && ( + + {t("experts.marketWorkflowVersion", { + version: workflowDoc.meta.version, + })} + + )} + {workflowDoc.meta.packageType && ( + + {workflowDoc.meta.packageType} + + )} +
+ {workflowDoc.meta.children.length > 0 && ( +
+
+ {t("experts.marketWorkflowChildren")} +
+
+ {workflowDoc.meta.children.map((slug) => ( + {slug} + ))} +
+
+ )} +
+ )} + {workflowDoc.body ? ( +
+ +
+ ) : ( +
+ + {t("experts.marketWorkflowEmpty")} + +
+ )} +
+ ) : ( +
+ + {t("experts.marketWorkflowEmpty")} + +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/dashboard/src/pages/Experts/components/iconForName.tsx b/dashboard/src/pages/Experts/components/iconForName.tsx index bd52932..17a7e4b 100644 --- a/dashboard/src/pages/Experts/components/iconForName.tsx +++ b/dashboard/src/pages/Experts/components/iconForName.tsx @@ -38,6 +38,10 @@ import { Activity, HardDrive, Bell, + BarChart3, + Network, + ShieldCheck, + RefreshCw, } from "lucide-react"; const iconMap: Record ReactNode> = { @@ -67,6 +71,10 @@ const iconMap: Record ReactNode> = { presentation: (size) => , activity: (size) => , "hard-drive": (size) => , + "bar-chart-3": (size) => , + network: (size) => , + "shield-check": (size) => , + "refresh-cw": (size) => , }; export function iconForName( diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less index bdc34d0..a8efdce 100644 --- a/dashboard/src/pages/Experts/index.module.less +++ b/dashboard/src/pages/Experts/index.module.less @@ -1,6 +1,7 @@ /* ================================================================ Experts page — redesigned as Agents Management Centre. - Two-tab layout: 我的专家 (agent grid) + 专家库 (template grid). + Three-tab layout: 我的专家 (agent grid) + 内置专家 (template grid) + + 专家市场 (SkillHub skillset grid). ================================================================ */ /* ── Card Grid ─────────────────────────────────────────────────── */ @@ -590,6 +591,317 @@ } } +/* ── Expert Market ─────────────────────────────────────────────── */ + +.marketContainer { + min-width: 0; +} + +.marketSceneTabsWrap { + width: 100%; + margin-top: 16px; + margin-bottom: 16px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding-bottom: 2px; +} + +.marketSceneTabs { + width: 100%; + min-width: max-content; + + :global(.ant-segmented-item) { + min-width: 4.75em; + } + + :global(.ant-segmented-item-label) { + font-weight: 500; + white-space: nowrap; + } +} + +.marketCardHeader { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.marketSearch { + width: 280px; + min-width: 0; + + @media (max-width: 640px) { + width: auto; + flex: 1 1 0; + } +} + +.marketToolbarRight { + flex: 1; + min-width: 0; + flex-wrap: nowrap; + justify-content: flex-end; + + .toolbarIconBtn { + flex-shrink: 0; + } +} + +.marketIconImg { + display: block; + width: 22px; + height: 22px; + object-fit: contain; +} + +.marketCardFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: auto; + min-width: 0; + + @media (max-width: 640px) { + align-items: stretch; + flex-direction: column; + } +} + +.marketMeta { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + font-size: 12px; + color: var(--fn-text-tertiary); + white-space: nowrap; +} + +.marketDrawerBody { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.marketTagRow { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.marketDrawerDesc { + margin: 0; + color: var(--fn-text-secondary); + font-size: 14px; + line-height: 1.7; + word-break: break-word; +} + +.marketSectionTitle { + margin-bottom: 8px; + color: var(--fn-text-tertiary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.marketSkillList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.marketQuickPromptList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.marketQuickPromptCard { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: var(--fn-radius-md); + min-width: 0; + background: var(--fn-bg-secondary); + border: 1px solid var(--fn-border-secondary, var(--fn-border-primary)); +} + +.marketQuickPromptIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 8px; + color: rgba(15, 23, 42, 0.62); +} + +:global(html[data-theme="dark"]) .marketQuickPromptIcon { + color: rgba(15, 23, 42, 0.72); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); +} + +.marketQuickPromptTitle { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.marketQuickPromptDesc { + margin-top: 2px; + font-size: 12px; + color: var(--fn-text-secondary); + line-height: 1.45; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.marketWorkflowPanel { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.marketWorkflowMeta { + padding: 12px 14px; + border-radius: var(--fn-radius-md); + background: var(--fn-bg-secondary); + border: 1px solid var(--fn-border-secondary); +} + +.marketWorkflowMetaTitle { + margin-bottom: 8px; + color: var(--fn-text-tertiary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.marketWorkflowChildren { + margin-top: 10px; +} + +.marketWorkflowChildrenLabel { + margin-bottom: 6px; + color: var(--fn-text-tertiary); + font-size: 12px; +} + +.marketWorkflowPreview { + min-height: 120px; + max-height: 360px; + overflow: auto; + padding: 14px 16px; + color: var(--fn-text-primary); + background: var(--fn-bg-secondary); + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-md); +} + +.marketWorkflowEmpty { + color: var(--fn-text-tertiary); + font-size: 13px; + line-height: 1.6; +} + +.marketWorkflowMarkdown { + font-size: 13px; + line-height: 1.7; + color: var(--fn-text-primary); + word-break: break-word; + + :global(h1), + :global(h2), + :global(h3), + :global(h4) { + margin: 0.85em 0 0.4em; + color: var(--fn-text-primary); + font-weight: 600; + line-height: 1.35; + } + + :global(h1) { + font-size: 16px; + } + + :global(h2) { + font-size: 14px; + } + + :global(h3), + :global(h4) { + font-size: 13px; + } + + :global(h1:first-child), + :global(h2:first-child), + :global(h3:first-child), + :global(p:first-child) { + margin-top: 0; + } + + :global(p) { + margin: 0.45em 0; + color: var(--fn-text-secondary); + } + + :global(ul), + :global(ol) { + margin: 0.4em 0 0.6em; + padding-left: 1.35em; + color: var(--fn-text-secondary); + } + + :global(li) { + margin: 0.2em 0; + } + + :global(li::marker) { + color: var(--fn-text-tertiary); + } + + :global(strong) { + color: var(--fn-text-primary); + font-weight: 600; + } + + :global(code) { + padding: 0.1em 0.35em; + border-radius: 4px; + background: color-mix(in srgb, var(--fn-text-primary) 8%, transparent); + font-size: 12px; + } + + :global(hr) { + margin: 12px 0; + border: none; + border-top: 1px solid var(--fn-border-secondary); + } + + :global(blockquote) { + margin: 0.6em 0; + padding: 0.2em 0 0.2em 0.85em; + border-left: 3px solid var(--fn-border-primary); + color: var(--fn-text-tertiary); + } +} + /* ── Toolbar row above grid ─────────────────────────────────────── */ .gridToolbar { diff --git a/dashboard/src/pages/Experts/index.tsx b/dashboard/src/pages/Experts/index.tsx index 978f5b9..32c87f7 100644 --- a/dashboard/src/pages/Experts/index.tsx +++ b/dashboard/src/pages/Experts/index.tsx @@ -2,10 +2,14 @@ * Experts page — redesigned as Agents Management Centre. * * Tab A: user's experts, shown as a card grid with start/stop/edit/delete. - * Tab B: expert templates, shown as a card grid with create-from-template drawer. + * Tab B: built-in expert templates, shown as a card grid with create-from-template drawer. + * Tab C: SkillHub expert market, shown as remote skillset cards. * * API (all via request() which already prefixes /api): * GET /experts → ExpertSummary[] + * GET /experts/hub → SkillHub market cards (+ scenes) + * GET /experts/hub/{slug} → market detail + quick prompts + * POST /experts/hub/{slug}/install → create agent from market * GET /agents → via AgentContext * POST /agents/from-expert/{id} → create agent (via CreateFromExpertDrawer) * POST /agents/{id}/start|stop → lifecycle (via AgentCard) @@ -28,9 +32,10 @@ import type { ExpertSummary } from "./components/ExpertCard"; import EditAgentDrawer from "./components/EditAgentDrawer"; import CreateFromExpertDrawer from "./components/CreateFromExpertDrawer"; import AgentExpertsTable from "./components/AgentExpertsTable"; +import ExpertMarketTab from "./components/ExpertMarketTab"; import styles from "./index.module.less"; -type TabKey = "my" | "library"; +type TabKey = "my" | "library" | "market"; type ViewMode = "card" | "table"; const VIEW_STORAGE_KEY = "octop:experts-view"; @@ -89,7 +94,7 @@ export default function ExpertsPage() { localStorage.setItem(VIEW_STORAGE_KEY, mode); }; - // ── Expert library ───────────────────────────────────────────── + // ── Built-in expert library ──────────────────────────────────── const [experts, setExperts] = useState([]); const [expertLoading, setExpertLoading] = useState(false); @@ -161,16 +166,19 @@ export default function ExpertsPage() { [refreshAgents], ); - // ── Create-from-expert Drawer ────────────────────────────────── + // ── Create-from-expert Drawer / Market create success ────────── const [createExpert, setCreateExpert] = useState(null); - const handleCreated = (agentId: string) => { - setCreateExpert(null); - void refreshAgents({ silent: true }); - setActiveTab("my"); - setNewAgentId(agentId); - setTimeout(() => setNewAgentId(null), 1000); - }; + const handleCreated = useCallback( + (agentId: string) => { + setCreateExpert(null); + void refreshAgents({ silent: true }); + setActiveTab("my"); + setNewAgentId(agentId); + setTimeout(() => setNewAgentId(null), 1000); + }, + [refreshAgents], + ); const openExpertLibrary = useCallback(() => { setActiveTab("library"); @@ -180,7 +188,7 @@ export default function ExpertsPage() { const [agentExpertIds, setAgentExpertIds] = useState>(new Set()); useEffect(() => { - if (activeTab !== "library") return; + if (activeTab !== "library" && activeTab !== "market") return; let cancelled = false; fetchInstalledExpertIds() .then((ids) => { @@ -369,6 +377,17 @@ export default function ExpertsPage() { ); }, [experts, expertLoading, lang, agentExpertIds, refreshButton, t]); + const marketContent = useMemo( + () => ( + + ), + [agentExpertIds, handleCreated, lang], + ); + return ( diff --git a/dashboard/src/utils/localizedText.ts b/dashboard/src/utils/localizedText.ts index 05eb9f3..39e3772 100644 --- a/dashboard/src/utils/localizedText.ts +++ b/dashboard/src/utils/localizedText.ts @@ -9,8 +9,14 @@ export interface LocalizedText { export function pickLocale( node: LocalizedText | undefined, locale: UiLocale, + options?: { crossFallback?: boolean }, ): string { if (!node) return ""; - if (locale === "zh") return node.zh || node.en || ""; - return node.en || node.zh || ""; + const crossFallback = options?.crossFallback !== false; + if (locale === "zh") { + if (node.zh) return node.zh; + return crossFallback ? node.en || "" : ""; + } + if (node.en) return node.en; + return crossFallback ? node.zh || "" : ""; } diff --git a/dashboard/src/utils/markdown.ts b/dashboard/src/utils/markdown.ts index 79ae994..54d83b8 100644 --- a/dashboard/src/utils/markdown.ts +++ b/dashboard/src/utils/markdown.ts @@ -1,9 +1,139 @@ +/** + * Markdown helpers for workspace / SkillHub documents that often carry a + * leading YAML frontmatter block (``---`` ... ``---``). + */ + +export interface SplitMarkdownFrontmatter { + /** Raw YAML between the delimiters, or null when absent. */ + raw: string | null; + /** Markdown body with frontmatter removed. */ + body: string; +} + +/** Fields we surface separately in the expert-market workflow drawer. */ +export interface WorkflowFrontmatterMeta { + name: string | null; + displayName: string | null; + version: string | null; + packageType: string | null; + children: string[]; +} + +/** + * Split a markdown string into YAML frontmatter and body. + * + * When no valid frontmatter exists, ``raw`` is null and ``body`` is the + * original text (leading whitespace trimmed). + */ +export function splitMarkdownFrontmatter(s: string): SplitMarkdownFrontmatter { + const match = s.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) { + return { raw: null, body: s.replace(/^\uFEFF/, "").trimStart() }; + } + return { + raw: match[1] ?? "", + body: s + .slice(match[0].length) + .replace(/^\uFEFF/, "") + .trimStart(), + }; +} + /** * Strip YAML frontmatter from the beginning of a markdown string. * * Many .md files start with a YAML header wrapped in `---` delimiters. - * marked / XMarkdown renders `---` as
and the YAML body as plain text. - * This helper removes the frontmatter block before passing content to the renderer. + * Markdown renderers treat `---` as
and dump the YAML as plain text. */ export const stripFrontmatter = (s: string): string => - s.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ""); + splitMarkdownFrontmatter(s).body; + +function yamlTopLevelScalar(block: string, key: string): string | null { + const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"); + const match = block.match(re); + if (!match) return null; + const value = (match[1] || "").trim(); + if ( + !value || + value === ">" || + value === ">-" || + value === "|" || + value === "|-" + ) { + return null; + } + return value.replace(/^["']|["']$/g, "").trim() || null; +} + +function yamlIndentedScalar( + block: string, + parentKey: string, + childKey: string, +): string | null { + const parentRe = new RegExp(`^${parentKey}:\\s*$`, "m"); + const parentMatch = parentRe.exec(block); + if (!parentMatch || parentMatch.index == null) return null; + const after = block.slice(parentMatch.index + parentMatch[0].length); + const childRe = new RegExp(`^[ \\t]+${childKey}:\\s*(.+?)\\s*$`, "m"); + const childMatch = after.match(childRe); + if (!childMatch) return null; + // Stop if we left the parent indent scope (another top-level key). + const between = after.slice(0, childMatch.index ?? 0); + if ( + /^[A-Za-z_][\w-]*:\s*$/m.test(between) || + /^[A-Za-z_][\w-]*:\s+\S/m.test(between) + ) { + // another top-level-looking key appeared before the child — still ok if + // those lines are indented; reject only unindented keys. + const lines = between.split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + if (/^[A-Za-z_][\w-]*:/.test(line)) return null; + } + } + const value = (childMatch[1] || "").trim(); + if ( + !value || + value === ">" || + value === ">-" || + value === "|" || + value === "|-" + ) { + return null; + } + return value.replace(/^["']|["']$/g, "").trim() || null; +} + +function yamlChildList(block: string): string[] { + const marker = block.match(/^orchestration:\s*$/m); + if (!marker || marker.index == null) return []; + const afterOrch = block.slice(marker.index + marker[0].length); + const childrenMarker = afterOrch.match(/^[ \t]+children:\s*$/m); + if (!childrenMarker || childrenMarker.index == null) return []; + const afterChildren = afterOrch.slice( + childrenMarker.index + childrenMarker[0].length, + ); + const items: string[] = []; + for (const line of afterChildren.split(/\r?\n/)) { + if (!line.trim()) continue; + const item = line.match(/^[ \t]+-\s+(.+?)\s*$/); + if (!item) break; + const value = (item[1] || "").replace(/^["']|["']$/g, "").trim(); + if (value) items.push(value); + } + return items; +} + +/** Extract a few UI-friendly fields from SkillHub workflow frontmatter. */ +export function parseWorkflowFrontmatterMeta( + raw: string | null | undefined, +): WorkflowFrontmatterMeta { + const block = raw ?? ""; + return { + name: yamlTopLevelScalar(block, "name"), + displayName: yamlIndentedScalar(block, "metadata", "display_name"), + version: yamlTopLevelScalar(block, "version"), + packageType: yamlIndentedScalar(block, "metadata", "package_type"), + children: yamlChildList(block), + }; +} diff --git a/dashboard/src/utils/pastelIconBackground.ts b/dashboard/src/utils/pastelIconBackground.ts new file mode 100644 index 0000000..c584989 --- /dev/null +++ b/dashboard/src/utils/pastelIconBackground.ts @@ -0,0 +1,42 @@ +/** Built-in expert quick-card chip colors (keep market icons on the same look). */ +export const QUICK_CARD_PASTEL_COLORS = [ + "#e8f4ff", + "#dcfce7", + "#fef3c7", + "#fce7f3", + "#f1f5f9", + "#eef2ff", + "#ecfeff", + "#fff1f2", +] as const; + +function isSoftPastel(color: string): boolean { + const match = /^#([0-9a-fA-F]{6})$/.exec(color.trim()); + if (!match) return false; + const value = Number.parseInt(match[1], 16); + const r = (value >> 16) & 255; + const g = (value >> 8) & 255; + const b = value & 255; + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + return luminance >= 0.82 && Math.min(r, g, b) >= 190; +} + +/** + * Use built-in pastel chips when possible. + * Dark/saturated market colors are snapped onto the shared pastel palette. + */ +export function pastelIconBackground( + color: string | null | undefined, + index = 0, + fallback = QUICK_CARD_PASTEL_COLORS[0], +): string { + const raw = (color || "").trim(); + if (isSoftPastel(raw)) { + return `#${raw.slice(1).toLowerCase()}`; + } + const palette = QUICK_CARD_PASTEL_COLORS; + if (Number.isFinite(index) && index >= 0) { + return palette[index % palette.length]; + } + return fallback; +} diff --git a/src/octop/api/common/validators.py b/src/octop/api/common/validators.py index 543c347..7f4d2b3 100644 --- a/src/octop/api/common/validators.py +++ b/src/octop/api/common/validators.py @@ -15,9 +15,16 @@ async def validate_chat_mcp_servers( ) -> list[str] | None: if not names: return None - repo = server.services.repos.connector_repo + from octop.infra.connectors.service import ConnectorService + + svc = ConnectorService( + repo=server.services.repos.connector_repo, + secret_repo=server.services.secret_repo, + settings_repo=server.services.settings_repo, + config=server.services.config, + ) try: - return list(repo.validate_mcp_servers_for_user(user_id, names)) + return list(svc.validate_mcp_servers_for_user(user_id, names)) except ValueError as exc: raise OctopError(ErrorCode.CONNECTOR_NOT_BOUND, str(exc)) from exc diff --git a/src/octop/api/routers/chat/routes.py b/src/octop/api/routers/chat/routes.py index 23b69e1..814dadb 100644 --- a/src/octop/api/routers/chat/routes.py +++ b/src/octop/api/routers/chat/routes.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from collections.abc import AsyncIterator from typing import Any @@ -13,7 +12,6 @@ from octop.api.common.agent import assert_agent_access from octop.api.deps import current_user, get_server from octop.api.routers.chat.models import HitlResumeBody, PolishBody -from octop.api.routers.chat.serialize import _llm_text_content from octop.api.routers.chat.sse import format_sse from octop.infra.agents.experts.catalog import ( default_welcome_payload, @@ -22,6 +20,7 @@ welcome_payload_has_content, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.llm_text import ainvoke_text router = APIRouter() logger = logging.getLogger(__name__) @@ -114,13 +113,12 @@ async def polish_prompt( model_ref = (body.default_model or "").strip() or harness.config.pick_default_model_ref() llm = harness.model_factory.get(model_ref) try: - result = await asyncio.wait_for( - llm.ainvoke( - [ - SystemMessage(content=_POLISH_SYSTEM_PROMPT), - HumanMessage(content=draft), - ] - ), + polished = await ainvoke_text( + llm, + [ + SystemMessage(content=_POLISH_SYSTEM_PROMPT), + HumanMessage(content=draft), + ], timeout=30.0, ) except TimeoutError: @@ -129,7 +127,6 @@ async def polish_prompt( logger.exception("polish failed agent=%s model=%s", agent_id, model_ref) raise OctopError(ErrorCode.INTERNAL_ERROR, str(exc)) from exc - polished = _llm_text_content(result) if not polished: raise OctopError(ErrorCode.INTERNAL_ERROR, "model returned empty polish result") return {"text": polished} diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index 6f56465..2aca2a8 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -14,9 +14,15 @@ COMPOSER_CTX_KEY, INBOUND_ATTACHMENTS_KEY, ) +from octop.infra.utils.llm_text import strip_thinking as _strip_thinking logger = logging.getLogger(__name__) +_THINKING_CAPTURE_RE = re.compile( + r"([\s\S]*?)\s*", + re.IGNORECASE, +) + # Must match harness_agent.agent.CHECKPOINT_TS_KEY (epoch-ms in additional_kwargs). CHECKPOINT_TS_KEY = "checkpoint_ts" @@ -539,42 +545,3 @@ def _message_content(msg: Any) -> str: parts.append(str(block.get("text", ""))) return _strip_thinking("\n".join(p for p in parts if p)) return _strip_thinking(str(content) if content else "") - - -def _llm_text_content(result: Any) -> str: - content = getattr(result, "content", result) - if isinstance(content, str): - return _strip_thinking(content) - if isinstance(content, list): - parts: list[str] = [] - for block in content: - if isinstance(block, dict): - block_type = str(block.get("type") or "").lower() - if block_type in ("thinking", "reasoning"): - continue - if block_type == "text": - parts.append(str(block.get("text") or "")) - elif isinstance(block, str): - parts.append(block) - return _strip_thinking("".join(parts)) - extra = getattr(result, "additional_kwargs", None) - if isinstance(extra, dict): - reasoning = extra.get("reasoning_content") - if isinstance(reasoning, str) and reasoning.strip(): - pass - return _strip_thinking(str(content or "")) - - -_THINKING_RE = re.compile( - r"[\s\S]*?\s*", - re.IGNORECASE, -) - -_THINKING_CAPTURE_RE = re.compile( - r"([\s\S]*?)\s*", - re.IGNORECASE, -) - - -def _strip_thinking(text: str) -> str: - return _THINKING_RE.sub("", text).strip() diff --git a/src/octop/api/routers/chat/turn.py b/src/octop/api/routers/chat/turn.py index ec0f0cb..af72214 100644 --- a/src/octop/api/routers/chat/turn.py +++ b/src/octop/api/routers/chat/turn.py @@ -262,11 +262,18 @@ async def prepare_dashboard_turn( names=turn.skills, ) if mcp_servers: - await server.app_runtime.agent_registry.prepare_chat_mcp( + failed = await server.app_runtime.agent_registry.prepare_chat_mcp( agent_id, mcp_servers, connector_user_id=user.id, ) + if failed: + locale = getattr(user, "locale", None) or "zh" + raise OctopError.localized( + ErrorCode.CONNECTOR_MCP_LOAD_FAILED, + locale, + servers=", ".join(failed), + ) thread_registry = server.app_runtime.gateway.thread_registry thread_id, session_key = await resolve_thread_id( diff --git a/src/octop/api/routers/connectors.py b/src/octop/api/routers/connectors.py index fbf05bf..ecff59d 100644 --- a/src/octop/api/routers/connectors.py +++ b/src/octop/api/routers/connectors.py @@ -23,6 +23,11 @@ get_catalog_entry, list_catalog, ) +from octop.infra.connectors.custom_mcp import ( + CUSTOM_MCP_KIND, + is_custom_mcp_kind, + parse_synthetic_instance_id, +) from octop.infra.connectors.oauth import ( auth_info_for_kind, delete_oauth_ctx, @@ -33,7 +38,11 @@ save_oauth_ctx, start_oauth, ) -from octop.infra.connectors.probe import prepare_probe_credentials, probe_connector +from octop.infra.connectors.probe import ( + prepare_probe_credentials, + probe_connector, + probe_custom_mcp_server, +) from octop.infra.connectors.service import ConnectorService from octop.infra.errors import ErrorCode, OctopError from octop.infra.utils.ulid import new_ulid @@ -68,6 +77,21 @@ class TestCredentialsBody(BaseModel): credentials: dict[str, Any] = Field(default_factory=dict) +class CustomMcpPutBody(BaseModel): + servers: dict[str, Any] = Field(default_factory=dict) + + +class CustomMcpServerPatchBody(BaseModel): + enabled: bool + + +class CustomMcpTestBody(BaseModel): + """Probe one server by name (saved) or by inline spec.""" + + name: str | None = None + server: dict[str, Any] | None = None + + def _connector_service(server: Any) -> ConnectorService: return ConnectorService( repo=server.services.repos.connector_repo, @@ -221,9 +245,91 @@ async def list_instances( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> list[dict[str, Any]]: - """List the current user's connected third-party accounts.""" - rows = _connector_service(server).list_user_instances(user.id) - return [_instance_to_dict(inst) for inst in rows] + """List the current user's connected accounts (custom MCP expanded per server).""" + return _connector_service(server).list_instances_for_api(user.id) + + +@router.get("/connectors/custom-mcp", summary="Get custom MCP servers") +async def get_custom_mcp( + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Return the user's custom MCP server map (langchain-mcp-adapters shape).""" + servers = _connector_service(server).get_custom_servers(user.id) + return {"servers": servers} + + +@router.put("/connectors/custom-mcp", summary="Save custom MCP servers") +async def put_custom_mcp( + body: CustomMcpPutBody, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Replace the user's custom MCP servers document and reload agents.""" + svc = _connector_service(server) + try: + servers = svc.put_custom_servers(user.id, body.servers) + except ValueError as exc: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc + server.services.audit_repo.write( + actor=user.username, + action="connector.custom_mcp.save", + target=CUSTOM_MCP_KIND, + payload=str(len(servers)), + ) + _schedule_connector_reload(server, user.id) + return {"servers": servers} + + +@router.patch( + "/connectors/custom-mcp/servers/{server_name}", + summary="Enable or disable one custom MCP server", +) +async def patch_custom_mcp_server( + server_name: str, + body: CustomMcpServerPatchBody, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Toggle ``enabled`` for a single custom MCP server without rewriting others.""" + svc = _connector_service(server) + try: + servers = svc.patch_custom_server_enabled(user.id, server_name, enabled=body.enabled) + except KeyError as exc: + raise OctopError( + ErrorCode.CONNECTOR_NOT_FOUND, f"custom MCP server {server_name!r} not found" + ) from exc + except ValueError as exc: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc + _schedule_connector_reload(server, user.id) + return {"servers": servers} + + +@router.post("/connectors/custom-mcp/test", summary="Probe a custom MCP server") +async def test_custom_mcp( + body: CustomMcpTestBody, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Probe connectivity for one custom MCP server (saved name or inline spec).""" + svc = _connector_service(server) + spec: dict[str, Any] | None = None + if body.server is not None: + spec = dict(body.server) + elif body.name: + saved = svc.get_custom_servers(user.id) + raw = saved.get(body.name) + if not isinstance(raw, dict): + raise OctopError( + ErrorCode.CONNECTOR_NOT_FOUND, f"custom MCP server {body.name!r} not found" + ) + spec = dict(raw) + else: + raise OctopError( + ErrorCode.CONNECTOR_INVALID_CREDENTIALS, + "provide name or server spec to probe", + ) + return await probe_custom_mcp_server(spec) @router.get("/connector-instances/{instance_id}", summary="Get connector instance") @@ -266,6 +372,11 @@ async def create_instance( server: Any = Depends(get_server), ) -> dict[str, Any]: """Connect a third-party account. Replaces any existing instance of the same kind.""" + if is_custom_mcp_kind(body.kind): + raise OctopError( + ErrorCode.CONNECTOR_KIND_UNSUPPORTED, + "use PUT /connectors/custom-mcp for custom MCP servers", + ) entry = get_catalog_entry(body.kind) if entry is None: raise OctopError(ErrorCode.CONNECTOR_KIND_UNSUPPORTED, f"unknown kind {body.kind!r}") @@ -321,12 +432,41 @@ async def patch_instance( server: Any = Depends(get_server), ) -> dict[str, Any]: """Enable or disable a connector instance without deleting credentials.""" + synthetic_name = parse_synthetic_instance_id(instance_id) + if synthetic_name is not None: + if body.status is None: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, "status is required") + status = body.status.strip() + if status not in ("active", "disabled"): + raise OctopError( + ErrorCode.CONNECTOR_INVALID_CREDENTIALS, "status must be active or disabled" + ) + svc = _connector_service(server) + try: + svc.patch_custom_server_enabled(user.id, synthetic_name, enabled=(status == "active")) + except KeyError as exc: + raise OctopError( + ErrorCode.CONNECTOR_NOT_FOUND, f"instance {instance_id!r} not found" + ) from exc + except ValueError as exc: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc + _schedule_connector_reload(server, user.id) + for item in svc.list_instances_for_api(user.id): + if item["instance_id"] == instance_id: + return item + raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, f"instance {instance_id!r} not found") + repo = server.services.repos.connector_repo inst = repo.get(instance_id) if inst is None: raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, f"instance {instance_id!r} not found") if inst.user_id != user.id: raise OctopError(ErrorCode.FORBIDDEN, "not your connector instance") + if is_custom_mcp_kind(inst.kind): + raise OctopError( + ErrorCode.CONNECTOR_KIND_UNSUPPORTED, + "use PATCH /connectors/custom-mcp/servers/{name} for custom MCP servers", + ) if body.status is not None: status = body.status.strip() if status not in ("active", "disabled"): @@ -347,6 +487,26 @@ async def delete_instance( server: Any = Depends(get_server), ) -> None: """Disconnect and delete stored credentials for a connector instance.""" + synthetic_name = parse_synthetic_instance_id(instance_id) + if synthetic_name is not None: + svc = _connector_service(server) + servers = dict(svc.get_custom_servers(user.id)) + if synthetic_name not in servers: + raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, f"instance {instance_id!r} not found") + del servers[synthetic_name] + try: + svc.put_custom_servers(user.id, servers) + except ValueError as exc: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc + server.services.audit_repo.write( + actor=user.username, + action="connector.custom_mcp.delete_server", + target=synthetic_name, + payload=CUSTOM_MCP_KIND, + ) + _schedule_connector_reload(server, user.id) + return + repo = server.services.repos.connector_repo inst = repo.get(instance_id) if inst is None: @@ -370,6 +530,15 @@ async def test_instance( server: Any = Depends(get_server), ) -> dict[str, Any]: """Probe the connector with stored credentials and return success or error details.""" + synthetic_name = parse_synthetic_instance_id(instance_id) + if synthetic_name is not None: + svc = _connector_service(server) + saved = svc.get_custom_servers(user.id) + raw = saved.get(synthetic_name) + if not isinstance(raw, dict): + raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, f"instance {instance_id!r} not found") + return await probe_custom_mcp_server(dict(raw)) + repo = server.services.repos.connector_repo inst = repo.get(instance_id) if inst is None: diff --git a/src/octop/api/routers/experts.py b/src/octop/api/routers/experts.py index be363cd..421d066 100644 --- a/src/octop/api/routers/experts.py +++ b/src/octop/api/routers/experts.py @@ -1,27 +1,54 @@ -"""Experts router — expose the bundled scene templates. +"""Experts router — bundled scene templates + SkillHub expert market. -GET /api/experts → summaries (id, label, description, …) -GET /api/experts/{id} → template metadata + lazy ``file_contents`` -POST /api/agents/from-expert/{id} → create agent and seed workspace files +GET /api/experts → bundled expert summaries +GET /api/experts/{id} → template metadata + lazy ``file_contents`` +POST /api/agents/from-expert/{id} → create agent from bundled expert + +GET /api/experts/hub → SkillHub market cards (``?q=&scene=``) +GET /api/experts/hub/{slug} → SkillHub market detail + quick prompts +POST /api/experts/hub/{slug}/install → create agent from market expert """ from __future__ import annotations +import asyncio from typing import Any from fastapi import APIRouter, Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field from octop.api.deps import current_user, get_server from octop.infra.agents.experts.catalog import ( build_create_spec_from_expert, preview_file_paths, ) +from octop.infra.agents.experts.market_creation import ( + SkillHubMarketAgentCreateOptions, +) +from octop.infra.agents.experts.market_creation import ( + create_agent_from_skillhub_skillset as create_skillhub_market_agent, +) +from octop.infra.agents.experts.skillhub_market import ( + SkillHubMarketError, + SkillHubMarketErrorKind, + browse_skillsets, + fetch_skillset, +) from octop.infra.errors import ErrorCode, OctopError from octop.infra.utils.locale import resolve_user_locale router = APIRouter() +_SAFE_MARKET_REASONS: dict[SkillHubMarketErrorKind, str] = { + SkillHubMarketErrorKind.NOT_FOUND: "expert not found", + SkillHubMarketErrorKind.INVALID_SLUG: "invalid expert id", + SkillHubMarketErrorKind.UPSTREAM_TIMEOUT: "upstream timeout", + SkillHubMarketErrorKind.UPSTREAM_BAD_PAYLOAD: "invalid upstream response", + SkillHubMarketErrorKind.PACKAGE_INVALID: "invalid expert package", + SkillHubMarketErrorKind.PACKAGE_TOO_LARGE: "expert package too large", + SkillHubMarketErrorKind.UPSTREAM_FAILED: "upstream request failed", +} + class FromExpertBody(BaseModel): name: str | None = None @@ -31,6 +58,63 @@ class FromExpertBody(BaseModel): backend: dict[str, Any] | None = None +class LocalizedTextResponse(BaseModel): + zh: str = "" + en: str = "" + + +class QuickPromptResponse(BaseModel): + title: LocalizedTextResponse + description: LocalizedTextResponse + prompt: LocalizedTextResponse + color: str = "#e8f4ff" + icon_name: str | None = None + + +class ExpertHubItemResponse(BaseModel): + id: str + slug: str + label: LocalizedTextResponse + description: LocalizedTextResponse + scene: str = "" + sub_scene: str = "" + icon_url: str | None = None + icon_name: str | None = None + color: str | None = None + skill_slugs: list[str] = Field(default_factory=list) + skill_count: int = 0 + source: str = "skillhub" + content: LocalizedTextResponse | None = None + quick_prompts: list[QuickPromptResponse] | None = None + + +class ExpertHubListResponse(BaseModel): + items: list[ExpertHubItemResponse] + scenes: list[str] = Field(default_factory=list) + + +class MarketCreateSourceResponse(BaseModel): + source: str + kind: str + slug: str + welcome_enrichment: str + + +class MarketCreateResponse(BaseModel): + id: int | str + agent_id: str + user_id: int + name: str + description: str | None = None + default_model: str | None = None + state: str + expert_id: str + icon_name: str | None = None + color: str | None = None + market: MarketCreateSourceResponse + bootstrap_pending: bool + + def _quick_prompt_dict(p: Any) -> dict[str, Any]: return { "title": {"zh": p.title_zh, "en": p.title_en}, @@ -76,6 +160,23 @@ def _expert_dict( return result +def _map_skillhub_error(exc: SkillHubMarketError) -> OctopError: + kind = getattr(exc, "kind", SkillHubMarketErrorKind.UPSTREAM_FAILED) + if kind in ( + SkillHubMarketErrorKind.NOT_FOUND, + SkillHubMarketErrorKind.INVALID_SLUG, + ): + return OctopError(ErrorCode.NOT_FOUND, "skillhub expert not found") + reason = _SAFE_MARKET_REASONS.get( + kind, _SAFE_MARKET_REASONS[SkillHubMarketErrorKind.UPSTREAM_FAILED] + ) + return OctopError( + ErrorCode.EXPERT_MARKET_FAILED, + f"expert market failed: {reason}", + details={"reason": reason, "kind": kind.value}, + ) + + @router.get("/experts") async def list_experts( _: Any = Depends(current_user), @@ -87,6 +188,95 @@ async def list_experts( return [_summary_dict(s) for s in catalog.list_summaries()] +@router.get( + "/experts/hub", + response_model=ExpertHubListResponse, + summary="List SkillHub expert market cards", +) +async def list_expert_hub( + q: str = "", + scene: str = "", + _: Any = Depends(current_user), +) -> dict[str, Any]: + """List SkillHub skillsets as market expert cards, optionally filtered by scene.""" + try: + items, scenes = await asyncio.to_thread(browse_skillsets, q, scene=scene) + except SkillHubMarketError as exc: + raise _map_skillhub_error(exc) from exc + return { + "items": [item.api_dict(include_content=False) for item in items], + "scenes": scenes, + } + + +@router.get( + "/experts/hub/{slug}", + response_model=ExpertHubItemResponse, + summary="Get SkillHub expert market detail", +) +async def get_expert_hub_item( + slug: str, + _: Any = Depends(current_user), +) -> dict[str, Any]: + """SkillHub market detail, including workflow prompt and default quick prompts.""" + try: + item = await asyncio.to_thread(fetch_skillset, slug) + except SkillHubMarketError as exc: + raise _map_skillhub_error(exc) from exc + return item.api_dict(include_content=True) + + +@router.post( + "/experts/hub/{slug}/install", + status_code=201, + response_model=MarketCreateResponse, + summary="Create an agent from a SkillHub expert market card", +) +async def install_expert_hub_item( + slug: str, + body: FromExpertBody, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Create an agent from a SkillHub skillset-backed expert template.""" + try: + result = await create_skillhub_market_agent( + server=server, + user=user, + slug=slug, + options=SkillHubMarketAgentCreateOptions( + name=body.name, + description=body.description, + providers=body.providers, + default_model=body.default_model, + backend=body.backend, + ), + ) + except SkillHubMarketError as exc: + raise _map_skillhub_error(exc) from exc + + row = result.row + return { + "id": row.id, + "agent_id": row.agent_id, + "user_id": row.user_id, + "name": row.name, + "description": row.description, + "default_model": row.default_model, + "state": row.last_state or "unknown", + "expert_id": result.expert_id, + "icon_name": result.icon_name, + "color": result.color, + "market": { + "source": "skillhub", + "kind": "skillset", + "slug": result.slug, + "welcome_enrichment": result.welcome_enrichment, + }, + "bootstrap_pending": not server.app_runtime.agent_registry.is_bootstrapped(row.agent_id), + } + + @router.get("/experts/{expert_id}") async def get_expert( expert_id: str, diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 28a4055..42e1e4a 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -299,6 +299,7 @@ "CONNECTOR_KIND_UNSUPPORTED": "Unsupported connector type.", "CONNECTOR_NOT_BOUND": "Connector is not bound to this agent.", "CONNECTOR_ALREADY_BOUND": "Connector is already bound.", + "CONNECTOR_MCP_LOAD_FAILED": "Failed to load tools for MCP server(s): {servers}", "CRON_TRIGGER_INVALID": "Invalid cron trigger.", "SLASH_UNKNOWN": "Unknown slash command.", "SLASH_BAD_ARGS": "Invalid arguments.", @@ -314,6 +315,7 @@ "SKILL_IMPORT_UNSUPPORTED_URL": "This skill URL source is not supported.", "SKILL_IMPORT_FAILED": "Skill import failed: {reason}", "SKILL_ALREADY_EXISTS": "Skill {name} already exists. Enable overwrite to replace it.", + "EXPERT_MARKET_FAILED": "Expert market is temporarily unavailable. Please try again later.", "DESKTOP_SESSION_LIMIT": "Too many concurrent remote desktop streams (max {limit}). Close other connections and try again.", "DESKTOP_CAPTURE_FAILED": "Screen capture failed repeatedly. On Linux servers restart the virtual desktop; on macOS grant Screen Recording and Accessibility, then reconnect." }, diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 47d9f73..7975510 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -299,6 +299,7 @@ "CONNECTOR_KIND_UNSUPPORTED": "不支持的连接器类型。", "CONNECTOR_NOT_BOUND": "连接器未绑定到此 Agent。", "CONNECTOR_ALREADY_BOUND": "连接器已绑定。", + "CONNECTOR_MCP_LOAD_FAILED": "无法加载 MCP 工具:{servers}", "CRON_TRIGGER_INVALID": "无效的定时触发器。", "SLASH_UNKNOWN": "未知的斜杠指令。", "SLASH_BAD_ARGS": "参数无效。", @@ -314,6 +315,7 @@ "SKILL_IMPORT_UNSUPPORTED_URL": "不支持该技能 URL 来源。", "SKILL_IMPORT_FAILED": "技能导入失败:{reason}", "SKILL_ALREADY_EXISTS": "技能 {name} 已存在,如需覆盖请启用 overwrite。", + "EXPERT_MARKET_FAILED": "专家市场暂时不可用,请稍后重试。", "DESKTOP_SESSION_LIMIT": "远程桌面并发连接已达上限(最多 {limit} 路),请关闭其他连接后重试。", "DESKTOP_CAPTURE_FAILED": "连续抓屏失败。Linux 服务器请重启虚拟桌面;macOS 请在系统设置中授予屏幕录制与辅助功能权限后重连。" }, diff --git a/src/octop/infra/agents/experts/catalog.py b/src/octop/infra/agents/experts/catalog.py index fdc51a4..913e8d5 100644 --- a/src/octop/infra/agents/experts/catalog.py +++ b/src/octop/infra/agents/experts/catalog.py @@ -284,16 +284,22 @@ class ExpertCatalog: point ``library_root`` at a fixture directory. """ - def __init__(self, library_root: Path) -> None: + def __init__(self, library_root: Path, extra_roots: list[Path] | None = None) -> None: self._root = library_root + self._extra_roots = list(extra_roots or []) self._experts: dict[str, Expert] = {} + self._expert_dirs: dict[str, Path] = {} @property def root(self) -> Path: return self._root + @property + def roots(self) -> tuple[Path, ...]: + return (self._root, *self._extra_roots) + def expert_dir(self, expert_id: str) -> Path: - return self._root / expert_id + return self._expert_dirs.get(expert_id) or (self._root / expert_id) def read_file_contents( self, @@ -311,40 +317,43 @@ def read_file_contents( def refresh(self) -> None: """Re-scan the library directory; quietly skip malformed entries.""" out: dict[str, Expert] = {} - if not self._root.exists(): - self._experts = {} - return - for entry in sorted(self._root.iterdir()): - if not entry.is_dir(): - continue - manifest_path = entry / MANIFEST_FILENAME - if not manifest_path.exists(): - continue - data = _read_manifest(manifest_path) - if not isinstance(data, dict): + dirs: dict[str, Path] = {} + for root in self.roots: + if not root.exists(): continue - ex_id = str(data.get("id") or entry.name) - prompt_files = [str(f) for f in (data.get("prompt_files") or [])] - seed_paths = discover_seed_paths(entry) - summary = ExpertSummary( - id=ex_id, - label_zh=_coerce_label(data.get("label"), "zh"), - label_en=_coerce_label(data.get("label"), "en"), - description_zh=_coerce_label(data.get("description"), "zh"), - description_en=_coerce_label(data.get("description"), "en"), - welcome_message_zh=_coerce_label(data.get("welcome_message"), "zh"), - welcome_message_en=_coerce_label(data.get("welcome_message"), "en"), - icon_name=data.get("icon_name"), - color=data.get("color"), - quick_prompts=_parse_quick_prompts(data), - ) - out[ex_id] = Expert( - summary=summary, - files=seed_paths, - prompt_files=prompt_files, - quick_prompts=summary.quick_prompts, - ) + for entry in sorted(root.iterdir()): + if not entry.is_dir(): + continue + manifest_path = entry / MANIFEST_FILENAME + if not manifest_path.exists(): + continue + data = _read_manifest(manifest_path) + if not isinstance(data, dict): + continue + ex_id = str(data.get("id") or entry.name) + prompt_files = [str(f) for f in (data.get("prompt_files") or [])] + seed_paths = discover_seed_paths(entry) + summary = ExpertSummary( + id=ex_id, + label_zh=_coerce_label(data.get("label"), "zh"), + label_en=_coerce_label(data.get("label"), "en"), + description_zh=_coerce_label(data.get("description"), "zh"), + description_en=_coerce_label(data.get("description"), "en"), + welcome_message_zh=_coerce_label(data.get("welcome_message"), "zh"), + welcome_message_en=_coerce_label(data.get("welcome_message"), "en"), + icon_name=data.get("icon_name"), + color=data.get("color"), + quick_prompts=_parse_quick_prompts(data), + ) + out[ex_id] = Expert( + summary=summary, + files=seed_paths, + prompt_files=prompt_files, + quick_prompts=summary.quick_prompts, + ) + dirs[ex_id] = entry self._experts = out + self._expert_dirs = dirs logger.info("expert catalog loaded: %d templates", len(out)) def list_summaries(self) -> list[ExpertSummary]: diff --git a/src/octop/infra/agents/experts/manifest_generator.py b/src/octop/infra/agents/experts/manifest_generator.py new file mode 100644 index 0000000..901347b --- /dev/null +++ b/src/octop/infra/agents/experts/manifest_generator.py @@ -0,0 +1,625 @@ +"""Generate SkillHub expert welcome metadata with an internal generator skill.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from pathlib import Path +from typing import Any + +from langchain_core.messages import HumanMessage, SystemMessage + +from octop.infra.utils.llm_text import ainvoke_text + +logger = logging.getLogger(__name__) + +_SKILL_PATH = Path(__file__).with_name("manifest_generator_skill.md") +_MANIFEST_FILENAME = "manifest.json" +_MAX_WORKFLOW_CHARS = 6000 +_MAX_SKILL_EXCERPT_CHARS = 500 +_MAX_LABEL_CHARS = 64 +_MAX_EXPERT_DESCRIPTION_CHARS = 220 +_MAX_PROMPT_CHARS = 180 +_MAX_TITLE_CHARS = 18 +_MAX_DESCRIPTION_CHARS = 36 +_MAX_WELCOME_CHARS_ZH = 40 +_MAX_WELCOME_CHARS_EN = 90 +_MAX_QUICK_PROMPTS = 6 +_MIN_QUICK_PROMPTS = 6 + +_ALLOWED_ICONS = { + "zap", + "list-todo", + "file-text", + "activity", + "trending-up", + "presentation", + "cpu", + "server", + "wrench", + "message-square", + "book-open", + "globe", + "mail", + "terminal", + "hard-drive", + "heart", + "user", + "sparkles", +} +_ICON_FALLBACKS = ["zap", "list-todo", "file-text", "presentation"] +_COLOR_FALLBACKS = ( + "#e8f4ff", + "#dcfce7", + "#fef3c7", + "#fce7f3", + "#f1f5f9", + "#eef2ff", + "#ecfeff", + "#fff1f2", +) + + +class ExpertManifestGenerationError(RuntimeError): + """Raised when model output cannot be normalized into manifest metadata.""" + + +async def generate_and_apply_skillhub_manifest_assets( + *, + llm: Any, + item: Any, + expert_dir: Path, + model_ref: str, + timeout: float = 45.0, +) -> bool: + """Generate welcome metadata for a cached SkillHub expert and write manifest.json. + + Returns ``True`` when model-generated assets were written. Callers should catch + :class:`Exception` and keep the deterministic fallback manifest when generation + fails; expert creation should never depend on this enrichment. + """ + manifest_path = expert_dir / _MANIFEST_FILENAME + manifest = await asyncio.to_thread(_read_manifest, manifest_path) + if not manifest: + raise ExpertManifestGenerationError("cached expert manifest is missing") + + skill_slugs = _manifest_skill_slugs(manifest) + skillset_prompt = await asyncio.to_thread( + _read_text, + expert_dir / "skills" / item.slug / "SKILL.md", + ) + if not skillset_prompt.strip(): + raise ExpertManifestGenerationError("cached skillset workflow prompt is missing") + skill_context = await asyncio.to_thread( + collect_skill_context, + expert_dir, + skill_slugs=skill_slugs, + main_skill_slug=item.slug, + ) + + assets = await generate_skillhub_manifest_assets( + llm=llm, + item=item, + skill_slugs=skill_slugs, + skillset_prompt=skillset_prompt, + skill_context=skill_context, + timeout=timeout, + ) + await asyncio.to_thread( + apply_manifest_assets, + manifest_path, + assets, + model_ref=model_ref, + ) + return True + + +async def generate_skillhub_manifest_assets( + *, + llm: Any, + item: Any, + skill_slugs: list[str], + skillset_prompt: str, + skill_context: list[dict[str, str]], + timeout: float = 45.0, +) -> dict[str, Any]: + """Call the internal generator skill and return normalized manifest fields.""" + system_prompt = await asyncio.to_thread(_SKILL_PATH.read_text, encoding="utf-8") + fallback_label_zh = _fallback_label_zh(item) + fallback_label_en = _fallback_label_en(item) + context = { + "expert": { + "slug": item.slug, + "name_zh": fallback_label_zh, + "name_en": fallback_label_en, + "source_name_zh": item.display_name or item.slug, + "source_name_en": item.display_name_en or "", + "summary_zh": item.summary, + "summary_en": item.summary_en or "", + "scene": item.scene, + "sub_scene": item.sub_scene, + "skill_slugs": skill_slugs, + }, + "workflow_prompt": _clip(skillset_prompt, _MAX_WORKFLOW_CHARS), + "skills": skill_context, + "target": { + "locale_fields": ["zh", "en"], + "welcome_max_chars_zh": _MAX_WELCOME_CHARS_ZH, + "welcome_max_chars_en": _MAX_WELCOME_CHARS_EN, + "quick_prompt_min": _MIN_QUICK_PROMPTS, + "quick_prompt_max": _MAX_QUICK_PROMPTS, + "output": "label + description + one-line welcome_message + quick_prompts for Octop expert manifest", + }, + } + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=json.dumps(context, ensure_ascii=False, indent=2)), + ] + generator_llm = _bind_json_response(llm) + text = await ainvoke_text(generator_llm, messages, timeout=timeout) + raw = parse_model_json(text) + return normalize_manifest_assets( + raw, + fallback_name_zh=fallback_label_zh, + fallback_name_en=fallback_label_en, + fallback_summary_zh=item.summary, + fallback_summary_en=item.summary_en or f"Expert workflow for {fallback_label_en}.", + ) + + +def _bind_json_response(llm: Any) -> Any: + """Use provider JSON mode when the LangChain model supports binding kwargs.""" + bind = getattr(llm, "bind", None) + if not callable(bind): + return llm + try: + return bind(response_format={"type": "json_object"}) + except TypeError: + return llm + + +def merge_manifest_assets( + manifest: dict[str, Any], + assets: dict[str, Any], + *, + model_ref: str, +) -> dict[str, Any]: + """Return a copy of *manifest* with generated welcome metadata merged in.""" + out = dict(manifest) + out["label"] = assets["label"] + out["description"] = assets["description"] + out["welcome_message"] = assets["welcome_message"] + out["quick_prompts"] = assets["quick_prompts"] + raw_skillhub = out.get("skillhub") + skillhub = dict(raw_skillhub) if isinstance(raw_skillhub, dict) else {} + generated = { + "by": "expert-manifest-generator", + "model": model_ref, + } + skillhub["manifest_generated"] = generated + skillhub["welcome_generated"] = generated + out["skillhub"] = skillhub + return out + + +def apply_manifest_assets( + manifest_path: Path, + assets: dict[str, Any], + *, + model_ref: str, +) -> None: + """Merge generated welcome metadata into a cached expert manifest.""" + manifest = _read_manifest(manifest_path) + if manifest is None: + raise ExpertManifestGenerationError("manifest is not valid JSON") + merged = merge_manifest_assets(manifest, assets, model_ref=model_ref) + manifest_path.write_text( + json.dumps(merged, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +async def build_skillhub_agent_manifest_bytes( + *, + llm: Any, + item: Any, + expert_dir: Path, + model_ref: str, + timeout: float = 45.0, +) -> bytes: + """Generate welcome metadata from the cached template; return manifest bytes. + + Reads the shared SkillHub cache for workflow context only — does not mutate it. + Callers should upload the returned bytes to the agent workspace. + """ + manifest_path = expert_dir / _MANIFEST_FILENAME + manifest = await asyncio.to_thread(_read_manifest, manifest_path) + if not manifest: + raise ExpertManifestGenerationError("cached expert manifest is missing") + + skill_slugs = _manifest_skill_slugs(manifest) + skillset_prompt = await asyncio.to_thread( + _read_text, + expert_dir / "skills" / item.slug / "SKILL.md", + ) + if not skillset_prompt.strip(): + raise ExpertManifestGenerationError("cached skillset workflow prompt is missing") + skill_context = await asyncio.to_thread( + collect_skill_context, + expert_dir, + skill_slugs=skill_slugs, + main_skill_slug=item.slug, + ) + + assets = await generate_skillhub_manifest_assets( + llm=llm, + item=item, + skill_slugs=skill_slugs, + skillset_prompt=skillset_prompt, + skill_context=skill_context, + timeout=timeout, + ) + merged = merge_manifest_assets(manifest, assets, model_ref=model_ref) + return (json.dumps(merged, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +def collect_skill_context( + expert_dir: Path, + *, + skill_slugs: list[str], + main_skill_slug: str, +) -> list[dict[str, str]]: + """Summarize cached skill files for the generator prompt.""" + ordered = _unique([main_skill_slug, *skill_slugs]) + out: list[dict[str, str]] = [] + for slug in ordered: + text = _read_text(expert_dir / "skills" / slug / "SKILL.md") + if not text.strip(): + continue + meta = _frontmatter(text) + body = _strip_frontmatter(text) + out.append( + { + "slug": slug, + "name": meta.get("name") or slug, + "description": meta.get("description") or "", + "excerpt": _clip(body.strip(), _MAX_SKILL_EXCERPT_CHARS), + }, + ) + return out + + +def parse_model_json(text: str) -> dict[str, Any]: + """Extract one JSON object from a model response.""" + cleaned = text.strip() + fence = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", cleaned, flags=re.DOTALL) + if fence: + cleaned = fence.group(1).strip() + try: + payload = json.loads(cleaned) + except json.JSONDecodeError: + start = cleaned.find("{") + end = cleaned.rfind("}") + if start < 0 or end <= start: + raise ExpertManifestGenerationError("model did not return JSON") from None + try: + payload = json.loads(cleaned[start : end + 1]) + except json.JSONDecodeError as exc: + raise ExpertManifestGenerationError("model returned invalid JSON") from exc + if not isinstance(payload, dict): + raise ExpertManifestGenerationError("model JSON root must be an object") + return payload + + +def normalize_manifest_assets( + payload: dict[str, Any], + *, + fallback_name_zh: str, + fallback_name_en: str, + fallback_summary_zh: str = "", + fallback_summary_en: str = "", +) -> dict[str, Any]: + """Validate and trim generated manifest fields.""" + if isinstance(payload.get("manifest"), dict): + payload = payload["manifest"] + label_zh, label_en = _localized_pair( + payload.get("label"), + fallback_zh=fallback_name_zh, + fallback_en=fallback_name_en, + max_chars=_MAX_LABEL_CHARS, + ) + label_zh = _ensure_zh_expert_label(label_zh) + label_en = _ensure_en_expert_label(label_en) + + expert_description_zh, expert_description_en = _localized_pair( + payload.get("description"), + fallback_zh=fallback_summary_zh or f"围绕「{label_zh}」提供专家级工作流支持。", + fallback_en=fallback_summary_en or f"Expert workflow for {label_en}.", + max_chars=_MAX_EXPERT_DESCRIPTION_CHARS, + ) + welcome = payload.get("welcome_message") + if isinstance(welcome, dict): + welcome_zh_raw = str(welcome.get("zh") or welcome.get("cn") or "") + welcome_en_raw = str(welcome.get("en") or "") + elif isinstance(welcome, str): + welcome_zh_raw, welcome_en_raw = welcome, "" + else: + welcome_zh_raw, welcome_en_raw = "", "" + welcome_zh_fallback = _capability_welcome_fallback_zh(expert_description_zh) + welcome_en_fallback = _capability_welcome_fallback_en(expert_description_en) + welcome_zh = _clip_welcome( + welcome_zh_raw.strip(), + _MAX_WELCOME_CHARS_ZH, + fallback=welcome_zh_fallback, + require_chinese=True, + ) + welcome_en = _clip_welcome( + welcome_en_raw.strip(), + _MAX_WELCOME_CHARS_EN, + fallback=welcome_en_fallback, + require_chinese=False, + ) + + raw_prompts = payload.get("quick_prompts") + if not isinstance(raw_prompts, list): + raise ExpertManifestGenerationError("quick_prompts must be a list") + + prompts: list[dict[str, Any]] = [] + for idx, raw in enumerate(raw_prompts): + if not isinstance(raw, dict): + continue + title_zh, title_en = _localized_pair( + raw.get("title"), + fallback_zh="开始处理", + fallback_en="Start work", + max_chars=_MAX_TITLE_CHARS, + ) + description_zh, description_en = _localized_pair( + raw.get("description"), + fallback_zh="描述目标、材料和期望结果", + fallback_en="Describe the goal, materials, and expected result", + max_chars=_MAX_DESCRIPTION_CHARS, + ) + prompt_zh, prompt_en = _localized_pair( + raw.get("prompt"), + fallback_zh=f"请作为「{label_zh}」,帮我处理以下任务:\n\n", + fallback_en=f"As the {label_en}, help me with this task:\n\n", + max_chars=_MAX_PROMPT_CHARS, + ) + if not (title_zh and title_en and prompt_zh and prompt_en): + continue + prompts.append( + { + "title": {"zh": title_zh, "en": title_en}, + "description": {"zh": description_zh, "en": description_en}, + "prompt": {"zh": prompt_zh, "en": prompt_en}, + "color": _normalize_color(raw.get("color"), idx), + "icon_name": _normalize_icon(raw.get("icon_name"), idx), + }, + ) + if len(prompts) >= _MAX_QUICK_PROMPTS: + break + + if len(prompts) < 2: + raise ExpertManifestGenerationError("model returned too few usable quick prompts") + while len(prompts) < _MIN_QUICK_PROMPTS: + idx = len(prompts) + prompts.append( + { + "title": { + "zh": _clip(f"继续推进 {idx + 1}", _MAX_TITLE_CHARS), + "en": _clip(f"Continue {idx + 1}", _MAX_TITLE_CHARS), + }, + "description": { + "zh": "补充目标与材料,继续专家工作流", + "en": "Add context and continue the workflow", + }, + "prompt": { + "zh": f"请作为「{label_zh}」,帮我继续推进下一步。\n我的情况/目标/材料是:\n", + "en": ( + f"As the {label_en}, help me continue with the next step.\n" + "My context, goals, or materials are:\n" + ), + }, + "color": _normalize_color(None, idx), + "icon_name": _normalize_icon(None, idx), + }, + ) + return { + "label": {"zh": label_zh, "en": label_en}, + "description": {"zh": expert_description_zh, "en": expert_description_en}, + "welcome_message": {"zh": welcome_zh, "en": welcome_en}, + "quick_prompts": prompts[:_MAX_QUICK_PROMPTS], + } + + +def _manifest_skill_slugs(manifest: dict[str, Any]) -> list[str]: + skillhub = manifest.get("skillhub") + raw = skillhub.get("skill_slugs") if isinstance(skillhub, dict) else None + if isinstance(raw, list): + return [str(s).strip() for s in raw if str(s).strip()] + return [] + + +def _read_manifest(path: Path) -> dict[str, Any] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + + +def _frontmatter(text: str) -> dict[str, str]: + if not text.startswith("---\n"): + return {} + end = text.find("\n---", 4) + if end == -1: + return {} + out: dict[str, str] = {} + for line in text[4:end].splitlines(): + key, sep, value = line.partition(":") + if sep: + out[key.strip()] = value.strip().strip("\"'") + return out + + +def _strip_frontmatter(text: str) -> str: + if not text.startswith("---\n"): + return text + end = text.find("\n---", 4) + return text[end + 4 :].lstrip("\n") if end != -1 else text + + +def _fallback_label_zh(item: Any) -> str: + name = str(getattr(item, "display_name", "") or getattr(item, "slug", "") or "").strip() + if not name: + return "专家" + return _ensure_zh_expert_label(name) + + +def _fallback_label_en(item: Any) -> str: + raw = str(getattr(item, "display_name_en", "") or "").strip() + if not raw: + raw = _title_from_slug(str(getattr(item, "slug", "") or "")) + if not raw: + raw = "Expert" + return _ensure_en_expert_label(raw) + + +def _title_from_slug(slug: str) -> str: + words = [word for word in re.split(r"[-_.\s]+", slug.strip()) if word] + return " ".join(word[:1].upper() + word[1:] for word in words) + + +def _ensure_zh_expert_label(name: str) -> str: + text = name.strip() + if not text: + return "专家" + return text if text.endswith("专家") else f"{text}专家" + + +def _ensure_en_expert_label(name: str) -> str: + text = name.strip() + if not text: + return "Expert" + return text if text.lower().endswith("expert") else f"{text} Expert" + + +def _localized_pair( + node: Any, + *, + fallback_zh: str, + fallback_en: str, + max_chars: int, +) -> tuple[str, str]: + if isinstance(node, dict): + zh = str(node.get("zh") or node.get("cn") or fallback_zh) + en = str(node.get("en") or fallback_en) + elif isinstance(node, str): + zh, en = node, fallback_en + else: + zh, en = fallback_zh, fallback_en + return _clip(zh.strip(), max_chars), _clip(en.strip(), max_chars) + + +def _normalize_color(value: Any, idx: int) -> str: + """Assign quick-card chip colors from the built-in pastel palette. + + Model-provided colors are ignored: LLMs often emit saturated brand colors that + do not match bundled expert chips even after lightening. + """ + return _COLOR_FALLBACKS[idx % len(_COLOR_FALLBACKS)] + + +def _normalize_icon(value: Any, idx: int) -> str: + icon = str(value or "").strip() + if icon in _ALLOWED_ICONS: + return icon + return _ICON_FALLBACKS[idx % len(_ICON_FALLBACKS)] + + +def _clip(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + return text[: max_chars - 1].rstrip() + "…" + + +def _clip_welcome( + text: str, + max_chars: int, + *, + fallback: str, + require_chinese: bool | None = None, +) -> str: + """Return one complete welcome line; never mid-sentence ellipsis.""" + cleaned = " ".join((text or "").split()) + if require_chinese is True and cleaned and not _looks_chinese(cleaned): + cleaned = "" + if require_chinese is False and cleaned and _looks_chinese(cleaned): + cleaned = "" + if cleaned: + for sep in ("。", "!", "?", ".", "!", "?"): + idx = cleaned.find(sep) + if idx >= 6: + cleaned = cleaned[:idx].strip() + break + if 6 <= len(cleaned) <= max_chars: + return cleaned + fb = " ".join((fallback or "").split()) + if not fb: + return "" + if len(fb) <= max_chars: + return fb + # Fallbacks are authored short; keep a complete prefix without ellipsis marks. + for sep in (",", ",", "、", " "): + idx = fb.rfind(sep, 0, max_chars + 1) + if idx >= 6: + return fb[:idx].strip() + return fb[:max_chars].rstrip() + + +_CJK_RE = re.compile(r"[\u3400-\u9fff]") + + +def _looks_chinese(text: str) -> bool: + return bool(_CJK_RE.search(text)) + + +def _capability_welcome_fallback_zh(description_zh: str) -> str: + line = _clip_welcome( + description_zh, + _MAX_WELCOME_CHARS_ZH, + fallback="提供专业、可落地的专家工作流支持", + require_chinese=True, + ) + return line or "提供专业、可落地的专家工作流支持" + + +def _capability_welcome_fallback_en(description_en: str) -> str: + line = _clip_welcome( + description_en, + _MAX_WELCOME_CHARS_EN, + fallback="Practical expert workflow support for your goals", + require_chinese=False, + ) + return line or "Practical expert workflow support for your goals" + + +def _unique(values: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for value in values: + item = str(value).strip() + if item and item not in seen: + seen.add(item) + out.append(item) + return out diff --git a/src/octop/infra/agents/experts/manifest_generator_skill.md b/src/octop/infra/agents/experts/manifest_generator_skill.md new file mode 100644 index 0000000..103cb43 --- /dev/null +++ b/src/octop/infra/agents/experts/manifest_generator_skill.md @@ -0,0 +1,128 @@ +--- +name: expert-manifest-generator +description: Generate bilingual Octop expert manifest metadata from a SkillHub skillset package. +--- + +# Expert Manifest Generator + +You turn a SkillHub skillset package into the small manifest metadata Octop needs +for an expert agent. You do not create a soul/persona file. You only generate +display metadata, a welcome message, and quick-start cards. + +Return JSON only. Do not include Markdown fences, commentary, XML tags, or +reasoning. +The JSON must be syntactically valid: use double quotes for all keys and string +values, escape newlines inside strings as `\n`, and do not use comments, +trailing commas, or unquoted keys. + +## Input + +The user message is JSON with: + +- `expert`: slug, Chinese/English names, Chinese/English summaries, scene, sub_scene. +- `workflow_prompt`: the normalized main skillset orchestration prompt saved as + the skillset `SKILL.md`. +- `skills`: included SkillHub skill packages with slug, name, description, and a short excerpt. +- `target`: output requirements. + +## Output Schema + +Return exactly this shape: + +```json +{ + "label": { + "zh": "string", + "en": "string" + }, + "description": { + "zh": "string", + "en": "string" + }, + "welcome_message": { + "zh": "string", + "en": "string" + }, + "quick_prompts": [ + { + "title": { "zh": "string", "en": "string" }, + "description": { "zh": "string", "en": "string" }, + "prompt": { "zh": "string", "en": "string" }, + "color": "#RRGGBB", + "icon_name": "string" + } + ] +} +``` + +## Requirements + +- Produce an expert role name in `label`. +- `label.zh` must be a natural Chinese expert name ending with `专家`. +- `label.en` must be a natural English expert name ending with `Expert`. +- If the source name is a task or domain name, convert it into an expert role + name instead of copying it directly. +- `description.zh` and `description.en` must summarize the expert's workflow + and value proposition in the corresponding language. +- `welcome_message` must be one short capability summary only (about 12–36 + Chinese characters / one brief English line under ~80 characters). It appears + next to `@ExpertName`, so do **not** restate the expert name, do **not** say + “I am…”, and do **not** tell users to pick quick-start cards. Summarize what + the expert helps with. + Prefer forms like: + - zh: `把灵感扩展成可长期连载的长篇大纲` + - en: `Expand ideas into serialization-ready outlines` +- Keep `welcome_message` as one complete short phrase. Do not truncate with + ellipsis (`…` / `...`) and do not leave hanging connectors like “再到…”. +- `welcome_message.zh` must be natural Simplified Chinese only. +- `welcome_message.en` must be natural English only; never copy Chinese text into + `.en`, and never put English into `.zh`. +- Produce exactly 6 quick-start cards. If the workflow has fewer than 6 major + operations, still produce 6 distinct entry points by covering adjacent tasks + (plan, analyze, deliver, revise, ask clarifying questions, etc.). +- Do not return fewer than 6 cards. +- The cards must be specific to the expert's domain and workflow, not generic. +- Prefer concrete operations named by the workflow prompt. +- Cover acquisition, analysis, and output/deliverable steps when present. +- Keep card titles short enough for UI cards (about 8–16 Chinese characters). +- Keep descriptions to one short line (about 12–28 Chinese characters). +- Make prompts short starter templates for the chat box: one clear ask plus a + blank input cue. Do not include numbered step lists, long SOP instructions, + or multi-paragraph guidance inside `prompt`. +- Prefer forms like: + - zh: `请作为「…专家」,帮我完成「…」。\n我的情况/目标/材料是:\n` + - en: `As the … Expert, help me with: ….\nMy context, goals, or materials are:\n` +- Treat each quick prompt as a starter template for a real user. When the task + requires project details, data, code, documents, goals, or constraints, end + `prompt.zh` with a final blank input cue line `我的情况/目标/材料是:` and end + `prompt.en` with `My context, goals, or materials are:`. Do not fill content + after those cue lines. +- Keep each `prompt.zh` / `prompt.en` under roughly 120 characters excluding the + trailing blank cue line. +- Include both Chinese and English for every localized field. +- Every `.zh` field must be natural Simplified Chinese. +- Every `.en` field must be natural English for an English-speaking user. +- Never copy Chinese text, pinyin, mixed Chinese-English fragments, or raw Chinese + workflow headings into `.en` fields. +- When the source workflow is Chinese, translate the workflow intent, actions, + and deliverables into concise professional English equivalents. +- `prompt.zh` and `prompt.en` must express the same task intent, but each prompt + should be written natively in its own language. +- `prompt.en` must be a short English chat starter for the same task; do not + expand it into a multi-step checklist. +- If an exact domain translation is uncertain, choose a clear professional + English paraphrase grounded in the workflow; do not leave Chinese fragments. +- Prefer professional, task-oriented wording. +- Do not mention SkillHub, packages, JSON, schema, or internal implementation. +- Do not invent unsupported abilities beyond the workflow prompt and skills. + +Allowed `icon_name` values: + +`zap`, `list-todo`, `file-text`, `activity`, `trending-up`, `presentation`, +`cpu`, `server`, `wrench`, `message-square`, `book-open`, `globe`, `mail`, +`terminal`, `hard-drive`, `heart`, `user`, `sparkles`. + +Use distinct **light pastel** hex colors for icon backgrounds when suggesting +them (examples: `#e8f4ff`, `#dcfce7`, `#fef3c7`, `#fce7f3`). Octop may remap +card colors onto this shared pastel palette so market experts match built-in +chips — avoid saturated or dark brand colors. diff --git a/src/octop/infra/agents/experts/market_creation.py b/src/octop/infra/agents/experts/market_creation.py new file mode 100644 index 0000000..dae08ec --- /dev/null +++ b/src/octop/infra/agents/experts/market_creation.py @@ -0,0 +1,312 @@ +"""Create agents from SkillHub-backed expert market templates.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass +from typing import Any, Literal + +from octop.infra.agents.experts.catalog import MANIFEST_FILENAME, build_create_spec_from_expert +from octop.infra.agents.experts.manifest_generator import ( + build_skillhub_agent_manifest_bytes, +) +from octop.infra.agents.experts.skillhub_market import ( + SkillHubMarketError, + SkillHubMarketErrorKind, + install_skillset_template, +) +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.locale import resolve_user_locale + +logger = logging.getLogger(__name__) + +_SKILLHUB_MANIFEST_GENERATION_TIMEOUT_SECONDS = 45.0 +_GENERATOR_LIGHTWEIGHT_MODEL_HINTS = ( + "flash", + "mini", + "m3", + "turbo", + "haiku", + "lite", + "small", +) + +WelcomeEnrichment = Literal["pending", "skipped", "succeeded", "failed"] + + +@dataclass(frozen=True) +class SkillHubMarketAgentCreateOptions: + name: str | None = None + description: str | None = None + providers: list[str] | None = None + default_model: str | None = None + backend: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class SkillHubMarketAgentCreateResult: + row: Any + expert_id: str + icon_name: str | None + color: str | None + slug: str + welcome_enrichment: WelcomeEnrichment + + +def _resolve_generator_model_ref(server: Any, requested_model: str | None) -> str | None: + """Pick a usable model for internal manifest generation.""" + assert server.app_runtime is not None + registry = server.app_runtime.agent_registry + providers = registry.providers + requested = (requested_model or "").strip() + if requested and providers.is_model_ref_usable(requested): + return requested + + active_name, active_model = server.services.settings_repo.get_active_model() + active_ref = f"{active_name}/{active_model}" if active_name and active_model else "" + first_ref = providers.resolve_first_model_ref() + candidates = [ + ref for ref in (active_ref, first_ref) if ref and providers.is_model_ref_usable(ref) + ] + if candidates: + return min(candidates, key=_generator_model_score) + + return None + + +def _generator_model_score(model_ref: str) -> tuple[int, int]: + """Prefer lower-latency models for best-effort metadata generation.""" + _provider, _sep, model = model_ref.lower().partition("/") + if any(hint in model for hint in _GENERATOR_LIGHTWEIGHT_MODEL_HINTS): + return (0, len(model_ref)) + return (1, len(model_ref)) + + +def _resolve_generator_llm( + *, + server: Any, + requested_model: str | None, + slug: str, +) -> tuple[Any, str] | None: + """Return ``(llm, model_ref)`` or ``None`` when generation should be skipped.""" + assert server.app_runtime is not None + registry = server.app_runtime.agent_registry + harness_manager = registry.harness_manager + factory = harness_manager.shared_factory if harness_manager is not None else None + if factory is None: + logger.info("skip SkillHub manifest generation for %s: no model factory", slug) + return None + + model_ref = _resolve_generator_model_ref(server, requested_model) + if not model_ref: + logger.info("skip SkillHub manifest generation for %s: no usable model", slug) + return None + try: + return factory.get(model_ref), model_ref + except Exception as exc: + logger.warning( + "skip SkillHub manifest generation for %s: model %s unavailable: %s", + slug, + model_ref, + exc, + ) + return None + + +def _set_welcome_enrichment_status( + *, + server: Any, + agent_id: str, + status: WelcomeEnrichment, +) -> None: + """Persist enrichment status on agent config without forcing a harness reload.""" + registry = server.app_runtime.agent_registry + assert registry is not None + cfg = dict(registry.get_config(agent_id)) + source = cfg.get("expert_source") + if isinstance(source, dict): + cfg["expert_source"] = {**source, "welcome_enrichment": status} + else: + cfg["welcome_enrichment"] = status + server.services.agent_repo.update_config( + agent_id, + config_json=json.dumps(cfg, ensure_ascii=False), + ) + + +async def _enrich_agent_welcome_async( + *, + server: Any, + item: Any, + agent_id: str, + requested_model: str | None, +) -> None: + """Background: LLM-enrich welcome cards and write only the agent workspace copy. + + The shared SkillHub cache under ``expert_market/`` stays deterministic so + concurrent installs of the same slug cannot clobber each other. + """ + try: + resolved = _resolve_generator_llm( + server=server, + requested_model=requested_model, + slug=item.slug, + ) + if resolved is None: + _set_welcome_enrichment_status( + server=server, + agent_id=agent_id, + status="skipped", + ) + return + llm, model_ref = resolved + expert_dir = server.paths.expert_market_dir / item.expert_id + payload = await build_skillhub_agent_manifest_bytes( + llm=llm, + item=item, + expert_dir=expert_dir, + model_ref=model_ref, + timeout=_SKILLHUB_MANIFEST_GENERATION_TIMEOUT_SECONDS, + ) + assert server.app_runtime is not None + registry = server.app_runtime.agent_registry + workspace = registry.workspace_for_agent(agent_id) + if workspace is None: + logger.info( + "SkillHub welcome enrichment skipped upload agent=%s: no workspace", + agent_id, + ) + _set_welcome_enrichment_status( + server=server, + agent_id=agent_id, + status="failed", + ) + return + await workspace.aupload_many([(MANIFEST_FILENAME, payload)]) + _set_welcome_enrichment_status( + server=server, + agent_id=agent_id, + status="succeeded", + ) + logger.info( + "SkillHub welcome enrichment applied agent=%s slug=%s", + agent_id, + item.slug, + ) + except Exception: + logger.warning( + "SkillHub welcome enrichment failed agent=%s slug=%s", + agent_id, + item.slug, + exc_info=True, + ) + try: + _set_welcome_enrichment_status( + server=server, + agent_id=agent_id, + status="failed", + ) + except Exception: + logger.warning( + "failed to persist welcome enrichment failure agent=%s", + agent_id, + exc_info=True, + ) + + +async def create_agent_from_skillhub_skillset( + *, + server: Any, + user: Any, + slug: str, + options: SkillHubMarketAgentCreateOptions, +) -> SkillHubMarketAgentCreateResult: + """Install a SkillHub skillset template and create an Octop agent from it. + + Welcome / quick-prompt cards use the deterministic SkillHub workflow first so + create stays fast. Optional LLM enrichment runs in the background and updates + only this agent's workspace ``manifest.json`` when ready. + """ + catalog = server.expert_catalog + if catalog is None: + raise OctopError( + ErrorCode.INTERNAL_ERROR, + "expert catalog is not available", + status=503, + ) + assert server.app_runtime is not None + + item = await asyncio.to_thread( + install_skillset_template, + slug=slug, + cache_root=server.paths.expert_market_dir, + ) + + catalog.refresh() + expert = catalog.get(item.expert_id) + if expert is None: + raise SkillHubMarketError( + f"SkillHub expert template {item.expert_id!r} was not cached", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) + + can_enrich = ( + _resolve_generator_llm( + server=server, + requested_model=options.default_model, + slug=item.slug, + ) + is not None + ) + welcome_enrichment: WelcomeEnrichment = "pending" if can_enrich else "skipped" + + config_extra: dict[str, Any] = { + "expert_source": { + "type": "skillhub", + "kind": "skillset", + "slug": item.slug, + "welcome_enrichment": welcome_enrichment, + } + } + if options.providers: + config_extra["providers"] = list(options.providers) + if options.backend: + config_extra["backend"] = options.backend + + locale = resolve_user_locale( + user_repo=server.services.user_repo, + user_id=user.id, + ) + spec = build_create_spec_from_expert( + expert_id=item.expert_id, + expert=expert, + user_id=user.id, + name=options.name, + description=options.description, + locale=locale, + default_model=options.default_model, + config_extra=config_extra, + ) + row = await server.app_runtime.agent_registry.create(spec, defer_bootstrap=True) + + if can_enrich: + asyncio.create_task( + _enrich_agent_welcome_async( + server=server, + item=item, + agent_id=row.agent_id, + requested_model=options.default_model, + ), + name=f"skillhub-welcome-{row.agent_id}", + ) + + return SkillHubMarketAgentCreateResult( + row=row, + expert_id=item.expert_id, + icon_name=expert.summary.icon_name, + color=expert.summary.color, + slug=item.slug, + welcome_enrichment=welcome_enrichment, + ) diff --git a/src/octop/infra/agents/experts/skillhub_market.py b/src/octop/infra/agents/experts/skillhub_market.py new file mode 100644 index 0000000..fd1c809 --- /dev/null +++ b/src/octop/infra/agents/experts/skillhub_market.py @@ -0,0 +1,1213 @@ +"""SkillHub skillset marketplace integration for expert templates. + +SkillHub currently exposes expert-like assets as *skillsets*. A skillset is a +workflow prompt plus a list of skill slugs. We normalize that package into the +same on-disk shape as bundled experts: + +``manifest.json`` + ``SOUL.md`` + ``skills//SKILL.md``. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import threading +import time +import zipfile +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + +MARKET_EXPERT_PREFIX = "skillhub-skillset-" +DEFAULT_SKILLHUB_HOST = "https://api.skillhub.cn" +_HTTP_TIMEOUT = 30 +_SKILLSET_PAGE_SIZE = 100 +_MAX_SKILLSET_PAGES = 20 +_MAX_WORKFLOW_QUICK_PROMPTS = 6 +_SKILLSET_LIST_CACHE_TTL_SECONDS = 300.0 +_MAX_HTTP_BYTES = 32 * 1024 * 1024 +_MAX_ZIP_ENTRIES = 2_000 +_MAX_ZIP_UNCOMPRESSED_BYTES = 64 * 1024 * 1024 +_MAX_ZIP_COMPRESSION_RATIO = 100.0 +_HTTP_READ_CHUNK = 64 * 1024 +# Product nav order for expert market scene tabs (matches dashboard copy). +_SCENE_ORDER = ( + "ecommerce", + "finance", + "content-creation", + "lifestyle", + "marketing", + "mysticism", + "academic", + "legal", + "tech", + "education", + "healthcare", + "hr", + "media", + "design", +) +_SLUG_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +_STEP_HEADING_RE = re.compile(r"^##\s*步骤\s*(\d+)\s*[::]\s*(.+?)\s*$", re.MULTILINE) +_QUICK_PROMPT_COLORS = ( + "#e8f4ff", + "#fef3c7", + "#dcfce7", + "#f1f5f9", + "#fff1f2", + "#eef2ff", + "#ecfeff", + "#f0fdf4", + "#faf5ff", +) + + +class SkillHubMarketErrorKind(StrEnum): + NOT_FOUND = "not_found" + INVALID_SLUG = "invalid_slug" + UPSTREAM_TIMEOUT = "upstream_timeout" + UPSTREAM_BAD_PAYLOAD = "upstream_bad_payload" + PACKAGE_INVALID = "package_invalid" + PACKAGE_TOO_LARGE = "package_too_large" + UPSTREAM_FAILED = "upstream_failed" + + +class SkillHubMarketError(RuntimeError): + """Raised when SkillHub marketplace fetch/install fails.""" + + def __init__( + self, + message: str, + *, + kind: SkillHubMarketErrorKind = SkillHubMarketErrorKind.UPSTREAM_FAILED, + ) -> None: + super().__init__(message) + self.kind = kind + + +@dataclass(frozen=True) +class SkillHubSkillset: + slug: str + display_name: str + display_name_en: str = "" + summary: str = "" + summary_en: str = "" + scene: str = "" + sub_scene: str = "" + content: str = "" + content_en: str = "" + icon_url: str = "" + skill_slugs: tuple[str, ...] = () + skill_count: int = 0 + raw: dict[str, Any] = field(default_factory=dict) + + @property + def expert_id(self) -> str: + return market_expert_id(self.slug) + + def api_dict(self, *, include_content: bool = False) -> dict[str, Any]: + name_zh = _expert_label_zh(self) + name_en = _expert_label_en(self) + out: dict[str, Any] = { + "id": self.expert_id, + "slug": self.slug, + "label": { + "zh": name_zh, + "en": name_en, + }, + "description": { + "zh": self.summary, + "en": _expert_summary_en(self, name_en), + }, + "scene": self.scene, + "sub_scene": self.sub_scene, + "icon_url": self.icon_url or None, + "icon_name": _scene_icon_name(self.scene), + "color": _scene_color(self.scene), + "skill_slugs": list(self.skill_slugs), + "skill_count": self.skill_count or len(self.skill_slugs), + "source": "skillhub", + } + if include_content: + out["content"] = {"zh": self.content, "en": self.content_en} + out["quick_prompts"] = quick_prompts_for_skillset(self) + return out + + +_skillset_list_cache: list[SkillHubSkillset] | None = None +_skillset_list_cache_at: float = 0.0 +_skillset_list_lock = threading.Lock() +_skillset_list_cv = threading.Condition(_skillset_list_lock) +_skillset_list_loading = False + +_install_locks_guard = threading.Lock() +_install_locks: dict[str, threading.Lock] = {} + + +def market_expert_id(slug: str) -> str: + return f"{MARKET_EXPERT_PREFIX}{slug}" + + +def validate_skillset_slug(slug: str) -> str: + trimmed = slug.strip() + if not trimmed or not _SLUG_RE.fullmatch(trimmed): + raise SkillHubMarketError( + "invalid skillset slug", + kind=SkillHubMarketErrorKind.INVALID_SLUG, + ) + return trimmed + + +def _install_lock_for(slug: str) -> threading.Lock: + with _install_locks_guard: + lock = _install_locks.get(slug) + if lock is None: + lock = threading.Lock() + _install_locks[slug] = lock + return lock + + +def fetch_skillsets(query: str = "", *, scene: str = "") -> list[SkillHubSkillset]: + items, _scenes = browse_skillsets(query, scene=scene) + return items + + +def list_skillset_scenes() -> list[str]: + """Return distinct SkillHub scenes in product nav order.""" + _items, scenes = browse_skillsets() + return scenes + + +def _ordered_scenes(present: set[str]) -> list[str]: + ordered = [scene for scene in _SCENE_ORDER if scene in present] + extras = sorted(scene for scene in present if scene not in _SCENE_ORDER) + return ordered + extras + + +def browse_skillsets( + query: str = "", + *, + scene: str = "", +) -> tuple[list[SkillHubSkillset], list[str]]: + """Fetch skillsets once and return ``(filtered_items, all_scenes)``.""" + all_items = _fetch_all_skillsets() + present = {item.scene.strip() for item in all_items if item.scene.strip()} + scenes = _ordered_scenes(present) + + items = all_items + scene_key = scene.strip().lower() + if scene_key: + items = [item for item in items if item.scene.lower() == scene_key] + q = query.strip().lower() + if q: + items = [ + item + for item in items + if q in item.slug.lower() + or q in item.display_name.lower() + or q in item.display_name_en.lower() + or q in item.summary.lower() + or q in item.summary_en.lower() + or q in item.scene.lower() + or q in item.sub_scene.lower() + ] + return items, scenes + + +def _fetch_all_skillsets(*, force: bool = False) -> list[SkillHubSkillset]: + """Fetch the full SkillHub skillset list. + + The SkillHub endpoint defaults to 20 rows even though it returns ``total``. + ``limit=`` is ignored by the current API; ``page`` + ``pageSize`` is the + supported shape. Results are cached in-process for a short TTL with + single-flight refresh and stale fallback on upstream failure. + """ + global _skillset_list_cache, _skillset_list_cache_at, _skillset_list_loading + + with _skillset_list_cv: + now = time.monotonic() + if ( + not force + and _skillset_list_cache is not None + and (now - _skillset_list_cache_at) < _SKILLSET_LIST_CACHE_TTL_SECONDS + ): + return list(_skillset_list_cache) + + while _skillset_list_loading: + _skillset_list_cv.wait(timeout=_HTTP_TIMEOUT + 5) + now = time.monotonic() + if ( + not force + and _skillset_list_cache is not None + and (now - _skillset_list_cache_at) < _SKILLSET_LIST_CACHE_TTL_SECONDS + ): + return list(_skillset_list_cache) + + stale = list(_skillset_list_cache) if _skillset_list_cache is not None else None + _skillset_list_loading = True + + try: + items = _load_all_skillsets_uncached() + with _skillset_list_cv: + _skillset_list_cache = items + _skillset_list_cache_at = time.monotonic() + return list(items) + except SkillHubMarketError: + if stale is not None and not force: + logger.warning("SkillHub skillset list refresh failed; serving stale cache") + return stale + raise + finally: + with _skillset_list_cv: + _skillset_list_loading = False + _skillset_list_cv.notify_all() + + +def _load_all_skillsets_uncached() -> list[SkillHubSkillset]: + items: list[SkillHubSkillset] = [] + seen: set[str] = set() + total: int | None = None + page = 1 + + while page <= _MAX_SKILLSET_PAGES: + data = _http_json_get( + _api_url( + "/api/v1/skillsets", + params={"page": page, "pageSize": _SKILLSET_PAGE_SIZE}, + ) + ) + raw_items = data.get("skillSets") if isinstance(data, dict) else None + if not isinstance(raw_items, list): + raise SkillHubMarketError( + "SkillHub skillsets response is invalid", + kind=SkillHubMarketErrorKind.UPSTREAM_BAD_PAYLOAD, + ) + if total is None: + total = _coerce_positive_int(data.get("total")) if isinstance(data, dict) else None + + page_items = [_skillset_from_raw(x) for x in raw_items if isinstance(x, dict)] + for item in page_items: + if not item.slug or item.slug in seen: + continue + seen.add(item.slug) + items.append(item) + + if not raw_items: + break + if total is not None and len(items) >= total: + break + if len(raw_items) < _SKILLSET_PAGE_SIZE: + break + page += 1 + + return items + + +def fetch_skillset(slug: str) -> SkillHubSkillset: + safe_slug = validate_skillset_slug(slug) + data = _http_json_get(_api_url(f"/api/v1/skillsets/{quote(safe_slug, safe='')}")) + if not isinstance(data, dict): + raise SkillHubMarketError( + f"SkillHub skillset {safe_slug!r} not found", + kind=SkillHubMarketErrorKind.NOT_FOUND, + ) + item = _skillset_from_raw(data) + if not item.slug: + raise SkillHubMarketError( + f"SkillHub skillset {safe_slug!r} not found", + kind=SkillHubMarketErrorKind.NOT_FOUND, + ) + return item + + +def install_skillset_template(*, slug: str, cache_root: Path) -> SkillHubSkillset: + """Download a SkillHub skillset and cache it as an ExpertCatalog directory.""" + safe_slug = validate_skillset_slug(slug) + with _install_lock_for(safe_slug): + return _install_skillset_template_locked(slug=safe_slug, cache_root=cache_root) + + +def _install_skillset_template_locked(*, slug: str, cache_root: Path) -> SkillHubSkillset: + item = fetch_skillset(slug) + package = _download_skillset_package(item.slug) + try: + manifest, prompt = _parse_skillset_package( + package, + skillset_slug=item.slug, + fallback_content=item.content, + ) + except SkillHubMarketError: + raise + except zipfile.BadZipFile as exc: + raise SkillHubMarketError( + "SkillHub skillset package is not a valid zip", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) from exc + skill_slugs = _manifest_skill_slugs(manifest) or list(item.skill_slugs) + if not skill_slugs: + raise SkillHubMarketError( + f"SkillHub skillset {item.slug!r} has no skills", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) + + cache_root.mkdir(parents=True, exist_ok=True) + staging = cache_root / f".tmp-{item.expert_id}-{os.getpid()}-{time.time_ns()}" + try: + _write_expert_template( + expert_dir=staging, + item=item, + skill_slugs=skill_slugs, + skillset_prompt=prompt, + ) + for skill_slug in skill_slugs: + _download_skill_into_template( + skill_slug=skill_slug, + expert_dir=staging, + ) + _replace_dir(staging, cache_root / item.expert_id) + finally: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + return item + + +def _replace_dir(src: Path, dest: Path) -> None: + """Atomically replace ``dest`` with ``src`` on the same filesystem.""" + dest.parent.mkdir(parents=True, exist_ok=True) + backup: Path | None = None + if dest.exists(): + backup = dest.with_name(f".{dest.name}.old-{os.getpid()}-{time.time_ns()}") + dest.rename(backup) + try: + src.rename(dest) + except Exception: + if backup is not None and backup.exists() and not dest.exists(): + backup.rename(dest) + raise + if backup is not None: + shutil.rmtree(backup, ignore_errors=True) + + +def _skillset_from_raw(raw: dict[str, Any]) -> SkillHubSkillset: + skill_slugs = raw.get("skillSlugs") + if not isinstance(skill_slugs, list): + skill_slugs = [] + cleaned_slugs = tuple(str(s).strip() for s in skill_slugs if str(s).strip()) + return SkillHubSkillset( + slug=str(raw.get("slug") or "").strip(), + display_name=str(raw.get("displayName") or raw.get("name") or "").strip(), + display_name_en=str(raw.get("displayNameEn") or "").strip(), + summary=str(raw.get("summary") or raw.get("description") or "").strip(), + summary_en=str(raw.get("summaryEn") or "").strip(), + scene=str(raw.get("scene") or "").strip(), + sub_scene=str(raw.get("subScene") or raw.get("sub_scene") or "").strip(), + content=str(raw.get("content") or "").strip(), + content_en=str(raw.get("contentEn") or "").strip(), + icon_url=str(raw.get("iconUrl") or "").strip(), + skill_slugs=cleaned_slugs, + skill_count=_coerce_nonneg_int(raw.get("skillCount"), default=len(cleaned_slugs)), + raw=raw, + ) + + +def _coerce_positive_int(value: Any) -> int | None: + try: + out = int(value) + except (TypeError, ValueError): + return None + return out if out > 0 else None + + +def _coerce_nonneg_int(value: Any, *, default: int) -> int: + try: + out = int(value) + except (TypeError, ValueError): + return default + return out if out >= 0 else default + + +def _api_host() -> str: + return (os.environ.get("SKILLHUB_HOST", "").strip() or DEFAULT_SKILLHUB_HOST).rstrip("/") + + +def _api_url(path: str, params: dict[str, Any] | None = None) -> str: + url = f"{_api_host()}/{path.lstrip('/')}" + if params: + url = f"{url}?{urlencode(params)}" + return url + + +def _http_get(url: str, *, accept: str) -> bytes: + req = Request( + url, + headers={ + "Accept": accept, + "User-Agent": "octop-expert-skillhub/1.0", + }, + ) + try: + with urlopen(req, timeout=_HTTP_TIMEOUT) as resp: + return _read_response_limited(resp, url=url) + except HTTPError as exc: + if exc.code == 404: + raise SkillHubMarketError( + "SkillHub resource not found", + kind=SkillHubMarketErrorKind.NOT_FOUND, + ) from exc + raise SkillHubMarketError( + f"SkillHub request failed: HTTP {exc.code}", + kind=SkillHubMarketErrorKind.UPSTREAM_FAILED, + ) from exc + except TimeoutError as exc: + raise SkillHubMarketError( + "SkillHub request timed out", + kind=SkillHubMarketErrorKind.UPSTREAM_TIMEOUT, + ) from exc + except URLError as exc: + reason = str(getattr(exc, "reason", "") or exc).lower() + kind = ( + SkillHubMarketErrorKind.UPSTREAM_TIMEOUT + if "timed out" in reason or "timeout" in reason + else SkillHubMarketErrorKind.UPSTREAM_FAILED + ) + raise SkillHubMarketError( + "SkillHub request failed", + kind=kind, + ) from exc + except OSError as exc: + raise SkillHubMarketError( + "SkillHub request failed", + kind=SkillHubMarketErrorKind.UPSTREAM_FAILED, + ) from exc + + +def _read_response_limited(resp: Any, *, url: str) -> bytes: + content_length = resp.headers.get("Content-Length") + if content_length: + try: + declared = int(content_length) + except ValueError: + declared = -1 + if declared > _MAX_HTTP_BYTES: + raise SkillHubMarketError( + f"SkillHub response too large for {url}", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + chunks: list[bytes] = [] + total = 0 + while True: + chunk = resp.read(_HTTP_READ_CHUNK) + if not chunk: + break + total += len(chunk) + if total > _MAX_HTTP_BYTES: + raise SkillHubMarketError( + f"SkillHub response too large for {url}", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + chunks.append(chunk) + return b"".join(chunks) + + +def _http_json_get(url: str) -> Any: + payload = _http_get(url, accept="application/json") + try: + return json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SkillHubMarketError( + "SkillHub returned invalid JSON", + kind=SkillHubMarketErrorKind.UPSTREAM_BAD_PAYLOAD, + ) from exc + + +def _download_skillset_package(slug: str) -> bytes: + url = _api_url(f"/api/v1/skillsets/{quote(slug, safe='')}/download") + return _http_get(url, accept="application/zip,*/*") + + +def _download_skill_package(slug: str) -> bytes: + url = _api_url("/api/v1/download", params={"slug": slug}) + return _http_get(url, accept="application/zip,*/*") + + +def _parse_skillset_package( + zip_bytes: bytes, + *, + skillset_slug: str, + fallback_content: str, +) -> tuple[dict[str, Any], str]: + try: + import io + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + _validate_zip(zf) + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + prompt = "" + zip_names = zf.namelist() + skillset_files = [ + n for n in zip_names if n.startswith("skillsets/") and n.endswith(".md") + ] + if skillset_files: + preferred = f"skillsets/{skillset_slug}.md" + selected = preferred if preferred in zip_names else skillset_files[0] + prompt = zf.read(selected).decode("utf-8") + elif "identify.md" in zip_names: + prompt = zf.read("identify.md").decode("utf-8") + elif fallback_content: + prompt = fallback_content + except SkillHubMarketError: + raise + except KeyError as exc: + raise SkillHubMarketError( + "SkillHub skillset package missing manifest.json", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) from exc + except zipfile.BadZipFile as exc: + raise SkillHubMarketError( + "SkillHub skillset package is not a valid zip", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) from exc + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise SkillHubMarketError( + "Failed to parse SkillHub skillset package", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) from exc + if not isinstance(manifest, dict): + raise SkillHubMarketError( + "SkillHub skillset manifest is invalid", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) + if not prompt.strip(): + raise SkillHubMarketError( + "SkillHub skillset package missing workflow prompt", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) + return manifest, _dedupe_frontmatter(prompt) + + +def _manifest_skill_slugs(manifest: dict[str, Any]) -> list[str]: + raw = manifest.get("skillSlugs") + if isinstance(raw, list): + return [str(s).strip() for s in raw if str(s).strip()] + skillsets = manifest.get("skillSets") + if not isinstance(skillsets, list): + return [] + out: list[str] = [] + for item in skillsets: + if not isinstance(item, dict): + continue + slugs = item.get("skillSlugs") + if isinstance(slugs, list): + out.extend(str(s).strip() for s in slugs if str(s).strip()) + seen: set[str] = set() + deduped: list[str] = [] + for slug in out: + if slug in seen: + continue + seen.add(slug) + deduped.append(slug) + return deduped + + +def _write_expert_template( + *, + expert_dir: Path, + item: SkillHubSkillset, + skill_slugs: list[str], + skillset_prompt: str, +) -> None: + expert_dir.mkdir(parents=True, exist_ok=True) + (expert_dir / "manifest.json").write_text( + json.dumps( + _expert_manifest(item, skill_slugs, skillset_prompt=skillset_prompt), + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + (expert_dir / "SOUL.md").write_text(_expert_soul(item, skill_slugs), encoding="utf-8") + skillset_dir = expert_dir / "skills" / item.slug + skillset_dir.mkdir(parents=True, exist_ok=True) + (skillset_dir / "SKILL.md").write_text(skillset_prompt, encoding="utf-8") + + +def _download_skill_into_template(*, skill_slug: str, expert_dir: Path) -> None: + validate_skillset_slug(skill_slug) + zip_bytes = _download_skill_package(skill_slug) + target_dir = expert_dir / "skills" / skill_slug + target_dir.mkdir(parents=True, exist_ok=True) + try: + _extract_zip(zip_bytes, target_dir) + except zipfile.BadZipFile as exc: + raise SkillHubMarketError( + f"SkillHub skill package {skill_slug!r} is not a valid zip", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) from exc + + +def _extract_zip(zip_bytes: bytes, target_dir: Path) -> None: + import io + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + _validate_zip(zf) + for member in zf.infolist(): + if member.is_dir(): + continue + dest = target_dir / member.filename + dest.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as src: + dest.write_bytes(_read_zip_member_limited(src, member)) + + +def _read_zip_member_limited(src: Any, member: zipfile.ZipInfo) -> bytes: + remaining = member.file_size + if remaining < 0 or remaining > _MAX_ZIP_UNCOMPRESSED_BYTES: + raise SkillHubMarketError( + f"zip entry too large: {member.filename}", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + chunks: list[bytes] = [] + total = 0 + while True: + chunk = src.read(_HTTP_READ_CHUNK) + if not chunk: + break + total += len(chunk) + if total > remaining or total > _MAX_ZIP_UNCOMPRESSED_BYTES: + raise SkillHubMarketError( + f"zip entry too large: {member.filename}", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + chunks.append(chunk) + return b"".join(chunks) + + +def _validate_zip(zf: zipfile.ZipFile) -> None: + infos = zf.infolist() + if len(infos) > _MAX_ZIP_ENTRIES: + raise SkillHubMarketError( + "zip has too many entries", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + total_uncompressed = 0 + for member in infos: + path = Path(member.filename) + if path.is_absolute() or ".." in path.parts: + raise SkillHubMarketError( + f"unsafe zip path entry: {member.filename}", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) + if member.file_size < 0: + raise SkillHubMarketError( + f"invalid zip entry size: {member.filename}", + kind=SkillHubMarketErrorKind.PACKAGE_INVALID, + ) + total_uncompressed += member.file_size + if total_uncompressed > _MAX_ZIP_UNCOMPRESSED_BYTES: + raise SkillHubMarketError( + "zip uncompressed size exceeds limit", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + compressed = member.compress_size or 0 + if ( + compressed > 0 + and member.file_size / compressed > _MAX_ZIP_COMPRESSION_RATIO + and member.file_size > 1024 * 1024 + ): + raise SkillHubMarketError( + f"zip compression ratio too high: {member.filename}", + kind=SkillHubMarketErrorKind.PACKAGE_TOO_LARGE, + ) + + +def _dedupe_frontmatter(text: str) -> str: + """Remove a repeated leading YAML frontmatter block if SkillHub duplicated it.""" + if not text.startswith("---\n"): + return text + end = text.find("\n---", 4) + if end == -1: + return text + block = text[: end + 4] + rest = text[end + 4 :].lstrip("\n") + if rest.startswith(block): + return f"{block}\n\n{rest[len(block) :].lstrip()}" + return text + + +def _expert_label_zh(item: SkillHubSkillset) -> str: + name = (item.display_name or item.slug).strip() + if not name: + return "专家" + return name if name.endswith("专家") else f"{name}专家" + + +def _expert_label_en(item: SkillHubSkillset) -> str: + name = (item.display_name_en or _title_from_slug(item.slug) or item.slug).strip() + if not name: + return "Expert" + return name if name.lower().endswith("expert") else f"{name} Expert" + + +def _expert_summary_en(item: SkillHubSkillset, name_en: str) -> str: + return item.summary_en.strip() if item.summary_en.strip() else f"Expert workflow for {name_en}." + + +_CJK_RE = re.compile(r"[\u3400-\u9fff]") + + +def _looks_chinese(text: str) -> bool: + return bool(_CJK_RE.search(text)) + + +def _capability_welcome_line( + summary: str, + *, + fallback: str, + max_chars: int, + require_chinese: bool | None = None, +) -> str: + """One complete capability line for the chat welcome subtitle. + + Never truncates with an ellipsis. If the summary cannot fit as one full + sentence, fall back to a short complete line instead. + """ + cleaned = " ".join((summary or "").split()) + if require_chinese is True and cleaned and not _looks_chinese(cleaned): + cleaned = "" + if require_chinese is False and cleaned and _looks_chinese(cleaned): + cleaned = "" + if cleaned: + for sep in ("。", "!", "?", ".", "!", "?"): + idx = cleaned.find(sep) + if idx >= 6: + cleaned = cleaned[:idx].strip() + break + if 6 <= len(cleaned) <= max_chars: + return cleaned + fb = " ".join((fallback or "").split()) + return fb if fb else fallback + + +def _title_from_slug(slug: str) -> str: + words = [word for word in re.split(r"[-_.\s]+", slug.strip()) if word] + return " ".join(word[:1].upper() + word[1:] for word in words) + + +def _expert_manifest( + item: SkillHubSkillset, + skill_slugs: list[str], + *, + skillset_prompt: str = "", +) -> dict[str, Any]: + name_zh = _expert_label_zh(item) + name_en = _expert_label_en(item) + summary_zh = item.summary + summary_en = _expert_summary_en(item, name_en) + return { + "id": item.expert_id, + "label": {"zh": name_zh, "en": name_en}, + "description": {"zh": summary_zh, "en": summary_en}, + "welcome_message": { + "zh": _capability_welcome_line( + item.summary, + fallback="提供专业、可落地的专家工作流支持", + max_chars=40, + require_chinese=True, + ), + "en": _capability_welcome_line( + item.summary_en, + fallback="Practical expert workflow support for your goals", + max_chars=90, + require_chinese=False, + ), + }, + "icon_name": _scene_icon_name(item.scene), + "color": _scene_color(item.scene), + "prompt_files": ["SOUL.md"], + "quick_prompts": quick_prompts_for_skillset(item, skillset_prompt), + "source": { + "type": "skillhub", + "kind": "skillset", + "slug": item.slug, + "scene": item.scene, + "sub_scene": item.sub_scene, + }, + "skillhub": { + "slug": item.slug, + "scene": item.scene, + "sub_scene": item.sub_scene, + "skill_slugs": skill_slugs, + }, + } + + +def _expert_soul(item: SkillHubSkillset, skill_slugs: list[str]) -> str: + name = _expert_label_zh(item) + skill_list = "\n".join(f"- `{slug}`" for slug in skill_slugs) + summary = item.summary or "围绕该 SkillHub skillset 提供专家级工作流支持。" + return f"""# {name} + +你是「{name}」,来源于 SkillHub skillset `{item.slug}`。 + +## 专家定位 + +{summary} + +## 工作方式 + +- 优先遵循 `skills/{item.slug}/SKILL.md` 中的工作流编排。 +- 根据用户目标主动拆解步骤、识别输入缺口,并给出可执行产物。 +- 需要具体能力时,调用已安装的配套技能;不要把技能清单当作用户可见负担。 +- 输出时保持结构清晰,先给结论和下一步,再补充必要依据。 + +## 配套技能 + +{skill_list} +""" + + +def _default_quick_prompts(item: SkillHubSkillset) -> list[dict[str, Any]]: + name_zh = _expert_label_zh(item) + name_en = _expert_label_en(item) + cards = [ + ( + "开始处理任务", + "Start a task", + "描述目标与上下文,马上开始", + "Describe the goal and start now", + f"请作为「{name_zh}」,帮我完成以下任务:\n我的情况/目标/材料是:\n", + f"As the {name_en}, help me complete this task:\nMy context, goals, or materials are:\n", + "zap", + ), + ( + "先制定计划", + "Make a plan", + "先拆步骤、材料与交付物", + "Break down steps and deliverables", + f"请根据「{name_zh}」工作流,先制定执行计划:\n我的情况/目标/材料是:\n", + f"Using the {name_en} workflow, first create a plan:\nMy context, goals, or materials are:\n", + "list-todo", + ), + ( + "分析现状", + "Analyze status", + "梳理问题、风险与优先级", + "Review issues, risks, and priorities", + f"请作为「{name_zh}」,帮我分析当前情况:\n我的情况/目标/材料是:\n", + f"As the {name_en}, analyze the current situation:\nMy context, goals, or materials are:\n", + "activity", + ), + ( + "产出结果", + "Produce result", + "直接生成可交付成果", + "Generate a ready-to-use deliverable", + f"请作为「{name_zh}」,直接给出可交付结果:\n我的情况/目标/材料是:\n", + f"As the {name_en}, produce a ready deliverable:\nMy context, goals, or materials are:\n", + "presentation", + ), + ( + "优化修改", + "Refine output", + "基于反馈迭代改进", + "Iterate based on feedback", + f"请作为「{name_zh}」,帮我优化下面内容:\n我的情况/目标/材料是:\n", + f"As the {name_en}, refine the following content:\nMy context, goals, or materials are:\n", + "sparkles", + ), + ( + "答疑澄清", + "Ask & clarify", + "先问清关键细节再继续", + "Clarify key details before continuing", + f"请作为「{name_zh}」,先向我确认关键信息:\n我的情况/目标/材料是:\n", + f"As the {name_en}, first clarify the key details:\nMy context, goals, or materials are:\n", + "message-square", + ), + ] + return [ + { + "title": {"zh": title_zh, "en": title_en}, + "description": {"zh": desc_zh, "en": desc_en}, + "prompt": {"zh": prompt_zh, "en": prompt_en}, + "color": _QUICK_PROMPT_COLORS[idx % len(_QUICK_PROMPT_COLORS)], + "icon_name": icon, + } + for idx, ( + title_zh, + title_en, + desc_zh, + desc_en, + prompt_zh, + prompt_en, + icon, + ) in enumerate(cards) + ] + + +def quick_prompts_for_skillset( + item: SkillHubSkillset, + workflow_prompt: str | None = None, +) -> list[dict[str, Any]]: + prompts = _workflow_quick_prompts(item, workflow_prompt or item.content) + return _ensure_min_quick_prompts(item, prompts) + + +def _ensure_min_quick_prompts( + item: SkillHubSkillset, + prompts: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Pad with default entry cards so each expert exposes at least six starters.""" + out = list(prompts[:_MAX_WORKFLOW_QUICK_PROMPTS]) + if len(out) >= _MAX_WORKFLOW_QUICK_PROMPTS: + return out + seen = { + ( + str((p.get("title") or {}).get("zh") or "").strip(), + str((p.get("title") or {}).get("en") or "").strip(), + ) + for p in out + } + for filler in _default_quick_prompts(item): + if len(out) >= _MAX_WORKFLOW_QUICK_PROMPTS: + break + key = ( + str((filler.get("title") or {}).get("zh") or "").strip(), + str((filler.get("title") or {}).get("en") or "").strip(), + ) + if key in seen: + continue + seen.add(key) + out.append(filler) + return out + + +def _workflow_quick_prompts( + item: SkillHubSkillset, + workflow_prompt: str, +) -> list[dict[str, Any]]: + steps = _workflow_steps(workflow_prompt) + if not steps: + return [] + name_zh = _expert_label_zh(item) + name_en = _expert_label_en(item) + prompts: list[dict[str, Any]] = [] + for idx, step in enumerate(steps[:_MAX_WORKFLOW_QUICK_PROMPTS]): + title = _clip_text(step["title"], 16) + description = _clip_text(_step_description(step["section"]), 28) + if not description: + description = f"完成「{title}」" + prompts.append( + { + "title": { + "zh": title, + "en": _english_step_title(title, idx), + }, + "description": { + "zh": description, + "en": _clip_text( + _english_step_description(step["section"], idx), + 40, + ), + }, + "prompt": { + "zh": _workflow_prompt_zh(name_zh, step), + "en": _workflow_prompt_en(name_en, idx, step), + }, + "color": _QUICK_PROMPT_COLORS[idx % len(_QUICK_PROMPT_COLORS)], + "icon_name": _quick_prompt_icon(step["title"], step["section"], idx), + } + ) + return prompts + + +def _workflow_steps(workflow_prompt: str) -> list[dict[str, str]]: + matches = list(_STEP_HEADING_RE.finditer(workflow_prompt or "")) + steps: list[dict[str, str]] = [] + for idx, match in enumerate(matches): + section_start = match.end() + section_end = matches[idx + 1].start() if idx + 1 < len(matches) else len(workflow_prompt) + raw_title = match.group(2).strip() + title = _clean_step_title(raw_title) + if not title: + continue + steps.append( + { + "number": match.group(1), + "title": title, + "section": workflow_prompt[section_start:section_end].strip(), + } + ) + return steps + + +def _clean_step_title(raw: str) -> str: + text = re.sub(r"[((][^))]*层[))]", "", raw).strip() + return text.strip(" ::-") + + +def _workflow_prompt_zh(name_zh: str, step: dict[str, str]) -> str: + title = _clip_text(step["title"], 24) + return f"请作为「{name_zh}」,帮我完成「{title}」。\n我的情况/目标/材料是:\n" + + +def _workflow_prompt_en( + name_en: str, + idx: int, + step: dict[str, str], +) -> str: + title_en = _english_step_title(step["title"], idx) + return f"As the {name_en}, help me with: {title_en}.\nMy context, goals, or materials are:\n" + + +def _english_step_title(title: str, idx: int) -> str: + """Use the source title when it is already English; otherwise a neutral step label.""" + clean = _clip_text(title, 24) + if clean and not re.search(r"[\u3400-\u9fff]", clean): + return clean + return f"Workflow step {idx + 1}" + + +def _english_step_description(section: str, idx: int) -> str: + output = _step_output_target(section) + if output and not re.search(r"[\u3400-\u9fff]", output): + return output + return "Share context for a ready result" + + +def _step_description(section: str) -> str: + output = _step_output_target(section) + if output: + return _normalize_output_line(output) + for line in section.splitlines(): + text = _clean_markdown_line(line) + if not text: + continue + if text.startswith("- "): + return text[2:].strip().rstrip("。") + return "" + + +def _step_output_target(section: str) -> str: + for line in section.splitlines(): + text = _clean_markdown_line(line) + if not text: + continue + if text.startswith("输出物"): + return _normalize_output_target(text.removeprefix("输出物")) + if text.startswith("输出"): + return _normalize_output_target(text.removeprefix("输出")) + return "" + + +def _clean_markdown_line(line: str) -> str: + text = line.strip() + text = re.sub(r"^\s*[-*]\s+", "- ", text) + text = text.replace("**", "").replace("`", "") + return text.strip() + + +def _normalize_output_target(text: str) -> str: + text = text.strip(" ::。") + if text.startswith(":") or text.startswith(":"): + text = text[1:].strip() + return text.rstrip("。") + + +def _normalize_output_line(text: str) -> str: + target = _normalize_output_target(text) + if not target: + return "" + if target.startswith(("生成", "输出", "给出", "形成", "交付")): + return target.rstrip("。") + return f"生成{target}".rstrip("。") + + +def _clip_text(text: str, max_chars: int) -> str: + clean = re.sub(r"\s+", " ", text).strip() + if len(clean) <= max_chars: + return clean + return clean[: max_chars - 1].rstrip() + "…" + + +def _quick_prompt_icon(title: str, section: str, idx: int) -> str: + text = f"{title}\n{section}" + keyword_icons = [ + (("巡检", "健康", "诊断", "异常", "日志", "监控", "分析"), "activity"), + (("评分", "指标", "数据", "统计", "预算", "投研", "经营", "趋势"), "trending-up"), + (("修复", "调试", "排查", "配置", "风险"), "wrench"), + (("云", "实例", "OS", "服务器", "集群", "节点"), "server"), + (("代码", "测试", "脚本", "命令", "自动化"), "terminal"), + (("视频", "分镜", "镜头", "画面", "剪辑", "脚本"), "video"), + (("合同", "文书", "报告", "纪要", "简历", "法条"), "file-text"), + (("检索", "搜索", "法规", "跨境", "市场"), "globe"), + (("计划", "方案", "策略", "SOP", "流程", "清单"), "list-todo"), + (("输出", "生成", "导出", "PPT", "PDF", "交付"), "presentation"), + ] + for keywords, icon in keyword_icons: + if any(k in text for k in keywords): + return icon + return [ + "zap", + "list-todo", + "activity", + "file-text", + "presentation", + "sparkles", + "message-square", + "book-open", + "globe", + ][idx % 9] + + +def _scene_icon_name(scene: str) -> str: + mapping = { + "academic": "book-open", + "content-creation": "pen-tool", + "design": "palette", + "ecommerce": "globe", + "education": "book-open", + "finance": "candlestick-chart", + "healthcare": "heart", + "lifestyle": "heart", + "marketing": "trending-up", + "mysticism": "sparkles", + "tech": "cpu", + "media": "video", + "legal": "file-text", + "hr": "user", + "office": "presentation", + "data": "trending-up", + } + return mapping.get(scene, "zap") + + +def _scene_color(scene: str) -> str: + mapping = { + "academic": "#4f46e5", + "content-creation": "#c026d3", + "design": "#db2777", + "ecommerce": "#16a34a", + "education": "#2563eb", + "finance": "#059669", + "healthcare": "#dc2626", + "lifestyle": "#f97316", + "marketing": "#ca8a04", + "mysticism": "#7c3aed", + "tech": "#2563eb", + "media": "#db2777", + "legal": "#0f766e", + "hr": "#7c3aed", + "office": "#ea580c", + "data": "#0891b2", + } + return mapping.get(scene, "#6366f1") diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 07e1263..8774c9e 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -158,6 +158,10 @@ def __init__( settings_repo=repos.settings_repo, config=self._config, ) + # User-scoped custom MCP tools: (user_id, server_name, fingerprint) -> tools + self._mcp_tool_cache: dict[tuple[int, str, str], list[Any]] = {} + self._mcp_tool_cache_locks: dict[tuple[int, str], asyncio.Lock] = {} + self._mcp_tool_cache_guard = asyncio.Lock() def set_cron_manager(self, cron_manager: CronManager) -> None: """Attach the process-wide CronManager (must be set before boot()).""" @@ -533,6 +537,7 @@ async def reload_connectors( self._connector_user_override.pop(agent_id, None) async def reload_connectors_for_user(self, user_id: int) -> None: + self.invalidate_mcp_tool_cache(user_id) reloaded: set[str] = set() for row in self._repos.agent_repo.list_by_user(user_id, include_disabled=False): await self.reload_connectors(row.agent_id, connector_user_id=user_id) @@ -543,46 +548,227 @@ async def reload_connectors_for_user(self, user_id: int) -> None: continue await self.reload_connectors(row.agent_id, connector_user_id=user_id) + def invalidate_mcp_tool_cache(self, user_id: int | None = None) -> None: + """Drop cached custom MCP tools (one user, or all users when ``user_id`` is None).""" + if user_id is None: + self._mcp_tool_cache.clear() + self._mcp_tool_cache_locks.clear() + return + for cache_key in [k for k in self._mcp_tool_cache if k[0] == user_id]: + del self._mcp_tool_cache[cache_key] + for lock_key in [k for k in self._mcp_tool_cache_locks if k[0] == user_id]: + del self._mcp_tool_cache_locks[lock_key] + + async def _server_lock(self, user_id: int, server_name: str) -> asyncio.Lock: + key = (user_id, server_name) + async with self._mcp_tool_cache_guard: + lock = self._mcp_tool_cache_locks.get(key) + if lock is None: + lock = asyncio.Lock() + self._mcp_tool_cache_locks[key] = lock + return lock + + async def _get_or_load_mcp_tools( + self, + user_id: int, + server_name: str, + spec: dict[str, Any], + ) -> list[Any]: + """Load custom MCP tools once per user/server/fingerprint; share across agents.""" + from harness_agent.mcp import aload_mcp_tools + + from octop.infra.connectors.mcp_tool_cache import ( + fingerprint_mcp_spec, + wrap_tools_for_shared_use, + ) + + fp = fingerprint_mcp_spec(spec) + cache_key = (user_id, server_name, fp) + cached = self._mcp_tool_cache.get(cache_key) + if cached is not None: + return cached + + async with self._mcp_tool_cache_guard: + cached = self._mcp_tool_cache.get(cache_key) + if cached is not None: + return cached + load_lock = self._mcp_tool_cache_locks.get((user_id, server_name)) + if load_lock is None: + load_lock = asyncio.Lock() + self._mcp_tool_cache_locks[(user_id, server_name)] = load_lock + + async with load_lock: + cached = self._mcp_tool_cache.get(cache_key) + if cached is not None: + return cached + raw = await aload_mcp_tools({server_name: spec}) + server_lock = await self._server_lock(user_id, server_name) + wrapped = wrap_tools_for_shared_use(raw, server_lock) + self._mcp_tool_cache[cache_key] = wrapped + stale = [ + key + for key in self._mcp_tool_cache + if key[0] == user_id and key[1] == server_name and key[2] != fp + ] + for key in stale: + del self._mcp_tool_cache[key] + logger.info( + "mcp tool cache store user=%s server=%s fingerprint=%s tools=%d", + user_id, + server_name, + fp, + len(wrapped), + ) + return wrapped + async def prepare_chat_mcp( self, agent_id: str, names: list[str] | None, *, connector_user_id: int | None = None, - ) -> None: - """Ensure requested MCP servers are configured and tools are loaded before chat.""" + ) -> list[str]: + """Ensure requested MCP servers are configured and tools are loaded before chat. + + Custom MCP tools are loaded on demand and shared via a user-level cache. + Built-in connectors still use reload_connectors when missing. + + Returns server names that still have no loaded tools after reload/retry. + """ if not names: - return + return [] agent = self.get_agent(agent_id) - cfg_names = set(agent.config.mcp_server_configs.keys()) + row = self.get_row(agent_id) + uid = self._connector_uid_for(row, connector_user_id=connector_user_id) if row else None + if uid is None and connector_user_id is not None: + uid = connector_user_id + tool_set: frozenset[str] = getattr(agent, "_mcp_tool_name_set", frozenset()) - missing_cfg = [n for n in names if n not in cfg_names] missing_tools = [n for n in names if not any(t.startswith(f"{n}_") for t in tool_set)] logger.info( - "prepare_chat_mcp agent=%s connector_user_id=%s requested=%s " - "cfg_names=%s tool_count=%d tools_sample=%s", + "prepare_chat_mcp agent=%s connector_user_id=%s requested=%s tool_count=%d missing=%s", agent_id, connector_user_id, names, - sorted(cfg_names), len(tool_set), - sorted(tool_set)[:8], + missing_tools, ) - if not missing_cfg and not missing_tools: + if not missing_tools: matched = sorted(t for t in tool_set if any(t.startswith(f"{n}_") for n in names)) logger.info( "prepare_chat_mcp agent=%s: MCP already ready, matching_tools=%s", agent_id, matched, ) - return - logger.info( - "Reloading agent %s MCP tools (missing_cfg=%s missing_tools=%s)", - agent_id, - missing_cfg, - missing_tools, - ) - await self.reload_connectors(agent_id, connector_user_id=connector_user_id) + return [] + + custom_configs: dict[str, Any] = {} + if uid is not None: + custom_configs = self._connector_svc.custom_harness_configs(uid) + + custom_missing = [n for n in missing_tools if n in custom_configs] + builtin_missing = [n for n in missing_tools if n not in custom_configs] + + if custom_missing and uid is not None: + for name in custom_missing: + spec = custom_configs[name] + if not isinstance(spec, dict) or not spec.get("transport"): + continue + try: + tools = await self._get_or_load_mcp_tools(uid, name, spec) + except Exception: + logger.exception( + "prepare_chat_mcp agent=%s: failed loading custom MCP %s", + agent_id, + name, + ) + continue + if tools: + try: + agent.append_mcp_tools(tools) + except Exception: + logger.exception( + "prepare_chat_mcp agent=%s: append_mcp_tools failed for %s", + agent_id, + name, + ) + continue + agent.config.mcp_server_configs[name] = dict(spec) + + if builtin_missing: + logger.info( + "Reloading agent %s MCP tools (builtin_missing=%s)", + agent_id, + builtin_missing, + ) + await self.reload_connectors(agent_id, connector_user_id=connector_user_id) + agent = self.get_agent(agent_id) + tool_set = getattr(agent, "_mcp_tool_name_set", frozenset()) + still_builtin = [ + n + for n in builtin_missing + if n in agent.config.mcp_server_configs + and not any(t.startswith(f"{n}_") for t in tool_set) + ] + still_builtin.extend( + n for n in builtin_missing if n not in agent.config.mcp_server_configs + ) + still_builtin = sorted(set(still_builtin)) + if still_builtin: + from harness_agent.mcp import aload_mcp_tools + + subset = { + n: agent.config.mcp_server_configs[n] + for n in still_builtin + if isinstance(agent.config.mcp_server_configs.get(n), dict) + and agent.config.mcp_server_configs[n].get("transport") + } + if subset: + logger.info( + "prepare_chat_mcp agent=%s: targeted MCP reload for %s", + agent_id, + sorted(subset), + ) + extra = await aload_mcp_tools(subset) + if extra: + agent.append_mcp_tools(extra) + + # Full reload drops previously appended custom tools — re-inject from cache. + if custom_configs and uid is not None: + agent = self.get_agent(agent_id) + tool_set = getattr(agent, "_mcp_tool_name_set", frozenset()) + for name in names: + if name not in custom_configs: + continue + if any(t.startswith(f"{name}_") for t in tool_set): + continue + spec = custom_configs[name] + if not isinstance(spec, dict) or not spec.get("transport"): + continue + try: + tools = await self._get_or_load_mcp_tools(uid, name, spec) + except Exception: + logger.exception( + "prepare_chat_mcp agent=%s: re-inject custom MCP %s failed", + agent_id, + name, + ) + continue + if tools: + agent.append_mcp_tools(tools) + agent.config.mcp_server_configs[name] = dict(spec) + tool_set = getattr(agent, "_mcp_tool_name_set", frozenset()) + + agent = self.get_agent(agent_id) + tool_set = getattr(agent, "_mcp_tool_name_set", frozenset()) + still_missing = sorted(n for n in names if not any(t.startswith(f"{n}_") for t in tool_set)) + if still_missing: + logger.warning( + "prepare_chat_mcp agent=%s: tools still missing for %s", + agent_id, + still_missing, + ) + return still_missing # ------------------------------------------------------------------ # Settings persistence — push global policy into harness runtime diff --git a/src/octop/infra/agents/providers/store.py b/src/octop/infra/agents/providers/store.py index ba20e06..af052ff 100644 --- a/src/octop/infra/agents/providers/store.py +++ b/src/octop/infra/agents/providers/store.py @@ -152,6 +152,17 @@ def resolve_model_for_multimodal_turn( upgraded = self.resolve_multimodal_model_ref() return upgraded or ref + def resolve_first_model_ref(self) -> str | None: + """First enabled model across usable providers, for lightweight internal tasks.""" + for row in self.iter_usable_rows(): + for model in row.get_models(): + if not model.get("enabled", True): + continue + model_id = str(model.get("id") or "").strip() + if model_id: + return f"{row.name}/{model_id}" + return None + def is_model_ref_usable(self, ref: str) -> bool: """Return True when *ref* points at an enabled model on a usable provider row.""" ref = ref.strip() diff --git a/src/octop/infra/connectors/builder.py b/src/octop/infra/connectors/builder.py index 6bec4bd..e0b553b 100644 --- a/src/octop/infra/connectors/builder.py +++ b/src/octop/infra/connectors/builder.py @@ -433,6 +433,19 @@ def build_mcp_server_configs_for_user( ) except Exception: logger.exception("failed to build MCP spec for instance %s", inst.instance_id) + + custom_configs = svc.custom_harness_configs(user_id) + for name, spec in custom_configs.items(): + # Defer loading: placeholder without ``transport`` so harness skips + # aload at agent start. Tools are injected via prepare_chat_mcp + cache. + configs[name] = {} + if log: + logger.info( + " defer custom MCP %s transport=%s (load on chat)", + name, + spec.get("transport"), + ) + if log: logger.info( "build_mcp_server_configs agent=%s result: %s", diff --git a/src/octop/infra/connectors/custom_mcp.py b/src/octop/infra/connectors/custom_mcp.py new file mode 100644 index 0000000..ee64d6e --- /dev/null +++ b/src/octop/infra/connectors/custom_mcp.py @@ -0,0 +1,231 @@ +"""User-defined MCP servers (streamable_http / stdio) stored as one connector doc.""" + +from __future__ import annotations + +import re +from typing import Any, Literal +from urllib.parse import urlparse + +from octop.infra.utils.ssrf_guard import UnsafeOutboundUrl, validate_https_url + +CUSTOM_MCP_KIND = "custom-mcp" +CUSTOM_MCP_DISPLAY_NAME = "自定义 MCP" + +_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_META_KEYS = frozenset({"enabled"}) +_MCP_STREAMABLE_HTTP_ACCEPT = "application/json, text/event-stream" + +Transport = Literal["streamable_http", "stdio"] + + +def is_custom_mcp_kind(kind: str) -> bool: + return kind == CUSTOM_MCP_KIND + + +def synthetic_instance_id(server_name: str) -> str: + return f"custom:{server_name}" + + +def parse_synthetic_instance_id(instance_id: str) -> str | None: + if not instance_id.startswith("custom:"): + return None + name = instance_id.removeprefix("custom:") + return name if name else None + + +def server_enabled(spec: dict[str, Any]) -> bool: + return spec.get("enabled", True) is not False + + +def extract_servers(payload: dict[str, Any] | None) -> dict[str, Any]: + """Return the servers map from a decrypted credential blob.""" + if not payload: + return {} + raw = payload.get("servers") + if isinstance(raw, dict): + return dict(raw) + # Legacy / direct map without wrapper + if ( + raw is None + and payload + and "servers" not in payload + and all(isinstance(v, dict) for v in payload.values()) + ): + return dict(payload) + return {} + + +def wrap_servers(servers: dict[str, Any]) -> dict[str, Any]: + return {"servers": servers} + + +def _normalize_headers(raw: Any) -> dict[str, str]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError("headers must be an object of string keys/values") + out: dict[str, str] = {} + for key, value in raw.items(): + k = str(key).strip() + if not k: + raise ValueError("header names must be non-empty") + out[k] = str(value) + return out + + +def _normalize_env(raw: Any) -> dict[str, str]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError("env must be an object of string keys/values") + out: dict[str, str] = {} + for key, value in raw.items(): + k = str(key).strip() + if not k: + raise ValueError("env names must be non-empty") + out[k] = str(value) + return out + + +def _normalize_args(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, str): + lines = [line.strip() for line in raw.splitlines() if line.strip()] + return lines + if not isinstance(raw, list): + raise ValueError("args must be a list of strings") + return [str(item) for item in raw] + + +def _validate_http_url(url: str) -> str: + text = url.strip() + if not text: + raise ValueError("url is required") + parsed = urlparse(text) + if parsed.scheme not in ("http", "https"): + raise ValueError("url must be http or https") + host = (parsed.hostname or "").lower().rstrip(".") + if not host: + raise ValueError("url missing hostname") + + # Loopback HTTP/HTTPS is allowed for local MCP servers (stdio alternative). + if host in {"localhost", "127.0.0.1", "::1"}: + return text + + # Public remote MCP: HTTPS only + existing SSRF guards (no private IPs). + if parsed.scheme != "https": + raise ValueError("non-local url must use https") + try: + validate_https_url(text, field="url") + except UnsafeOutboundUrl as exc: + raise ValueError(str(exc)) from exc + return text + + +def normalize_server_spec(name: str, raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError(f"server {name!r} must be an object") + transport = str(raw.get("transport") or "").strip() + if transport not in ("streamable_http", "stdio", "http"): + raise ValueError(f"server {name!r}: transport must be streamable_http or stdio") + if transport == "http": + transport = "streamable_http" + + enabled = raw.get("enabled", True) is not False + spec: dict[str, Any] = {"transport": transport, "enabled": enabled} + + if transport == "streamable_http": + url = _validate_http_url(str(raw.get("url") or "")) + spec["url"] = url + headers = _normalize_headers(raw.get("headers")) + if headers: + spec["headers"] = headers + else: + command = str(raw.get("command") or "").strip() + if not command: + raise ValueError(f"server {name!r}: command is required") + spec["command"] = command + args = _normalize_args(raw.get("args")) + if args: + spec["args"] = args + env = _normalize_env(raw.get("env")) + if env: + spec["env"] = env + + return spec + + +def validate_servers_map( + servers: Any, + *, + reserved_names: set[str] | None = None, +) -> dict[str, Any]: + if servers is None: + return {} + if not isinstance(servers, dict): + raise ValueError("servers must be an object") + reserved = reserved_names or set() + out: dict[str, Any] = {} + for name, raw in servers.items(): + key = str(name).strip() + if not key or not _SERVER_NAME_RE.match(key): + raise ValueError(f"invalid server name {name!r}: use letters, digits, _ or -") + if key in reserved: + raise ValueError(f"server name {key!r} conflicts with a built-in connector") + if key in out: + raise ValueError(f"duplicate server name {key!r}") + out[key] = normalize_server_spec(key, raw) + return out + + +def harness_spec_for_server(spec: dict[str, Any]) -> dict[str, Any]: + """Strip Octop meta keys; keep langchain-mcp-adapters connection fields.""" + out = {k: v for k, v in spec.items() if k not in _META_KEYS} + # Ensure stdio always has args list for adapters. + if out.get("transport") == "stdio" and "args" not in out: + out["args"] = [] + # Streamable HTTP MCP requires both content types (same as built-in remote). + if out.get("transport") == "streamable_http": + headers = {str(k): str(v) for k, v in dict(out.get("headers") or {}).items()} + headers.setdefault("Accept", _MCP_STREAMABLE_HTTP_ACCEPT) + out["headers"] = headers + return out + + +def enabled_harness_configs(servers: dict[str, Any]) -> dict[str, Any]: + configs: dict[str, Any] = {} + for name, spec in servers.items(): + if not isinstance(spec, dict): + continue + if not server_enabled(spec): + continue + configs[name] = harness_spec_for_server(spec) + return configs + + +def expand_custom_instances( + *, + parent: Any, + servers: dict[str, Any], +) -> list[dict[str, Any]]: + """Build list-API dicts for each custom server (independent status).""" + items: list[dict[str, Any]] = [] + for name, spec in servers.items(): + if not isinstance(spec, dict): + continue + enabled = server_enabled(spec) + items.append( + { + "instance_id": synthetic_instance_id(name), + "kind": CUSTOM_MCP_KIND, + "display_name": name, + "status": "active" if enabled else "disabled", + "mcp_server_name": name, + "has_credentials": True, + "created_at": parent.created_at, + "updated_at": parent.updated_at, + } + ) + items.sort(key=lambda row: str(row["display_name"])) + return items diff --git a/src/octop/infra/connectors/mcp_tool_cache.py b/src/octop/infra/connectors/mcp_tool_cache.py new file mode 100644 index 0000000..6ae3cbb --- /dev/null +++ b/src/octop/infra/connectors/mcp_tool_cache.py @@ -0,0 +1,94 @@ +"""User-scoped MCP tool cache helpers (fingerprint + serialized tool wrappers).""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from typing import Any + +from langchain_core.tools import StructuredTool + +# Connection fields that affect the live MCP session (exclude Octop meta like enabled). +_FINGERPRINT_KEYS = ( + "transport", + "url", + "headers", + "command", + "args", + "env", +) + + +def fingerprint_mcp_spec(spec: dict[str, Any]) -> str: + """Stable short hash of a harness MCP connection spec.""" + payload: dict[str, Any] = {} + for key in _FINGERPRINT_KEYS: + if key not in spec: + continue + value = spec[key] + if key in ("headers", "env") and isinstance(value, dict): + payload[key] = {str(k): str(v) for k, v in sorted(value.items())} + elif key == "args" and isinstance(value, list): + payload[key] = [str(x) for x in value] + else: + payload[key] = value + raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def wrap_tools_for_shared_use(tools: list[Any], lock: asyncio.Lock) -> list[Any]: + """Return LangChain ``StructuredTool`` wrappers that serialize invoke/ainvoke. + + Must be real ``BaseTool`` instances so ``HarnessAgent.append_mcp_tools`` / + LangGraph ``ToolNode`` accept them (plain proxies raise ValueError). + """ + from pydantic import BaseModel, create_model + + wrapped: list[Any] = [] + for tool in tools: + name = str(getattr(tool, "name", "") or "") or "mcp_tool" + description = str(getattr(tool, "description", "") or "") + args_schema = getattr(tool, "args_schema", None) + metadata = getattr(tool, "metadata", None) + if not ( + isinstance(args_schema, type) + and issubclass(args_schema, BaseModel) + or isinstance(args_schema, dict) + ): + args_schema = create_model(f"{name.replace('-', '_')}_Args") + + async def _acall( + _tool: Any = tool, + _lock: asyncio.Lock = lock, + **kwargs: Any, + ) -> Any: + async with _lock: + return await _tool.ainvoke(kwargs) + + def _call( + _tool: Any = tool, + _lock: asyncio.Lock = lock, + **kwargs: Any, + ) -> Any: + async def _run() -> Any: + async with _lock: + return await _tool.ainvoke(kwargs) + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_run()) + return _tool.invoke(kwargs) + + st_kwargs: dict[str, Any] = { + "name": name, + "description": description, + "args_schema": args_schema, + "coroutine": _acall, + "func": _call, + } + if isinstance(metadata, dict) and metadata: + st_kwargs["metadata"] = dict(metadata) + wrapped.append(StructuredTool(**st_kwargs)) + return wrapped diff --git a/src/octop/infra/connectors/probe.py b/src/octop/infra/connectors/probe.py index f928504..0e60060 100644 --- a/src/octop/infra/connectors/probe.py +++ b/src/octop/infra/connectors/probe.py @@ -427,3 +427,60 @@ async def probe_connector( "tool_count": len(probed_tools), "tools": probed_tools, } + + +async def probe_custom_mcp_server(spec: dict[str, Any]) -> dict[str, Any]: + """Probe one user-defined MCP server (streamable_http or stdio).""" + from octop.infra.connectors.custom_mcp import harness_spec_for_server, normalize_server_spec + + try: + normalized = normalize_server_spec("probe", spec) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + + connection = harness_spec_for_server(normalized) + transport = str(connection.get("transport") or "") + + if transport == "streamable_http": + url = str(connection["url"]) + headers = {str(k): str(v) for k, v in dict(connection.get("headers") or {}).items()} + # Ensure streamable Accept if caller omitted it. + headers.setdefault("Accept", "application/json, text/event-stream") + return await probe_streamable_http_mcp(url, headers, kind="custom-mcp") + + if transport == "stdio": + return await _probe_stdio_mcp(connection) + + return {"ok": False, "error": f"unsupported transport: {transport}"} + + +async def _probe_stdio_mcp(connection: dict[str, Any]) -> dict[str, Any]: + """List tools from a stdio MCP server with a short timeout.""" + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + command = str(connection.get("command") or "").strip() + if not command: + return {"ok": False, "error": "command is required"} + args = [str(a) for a in (connection.get("args") or [])] + env = connection.get("env") + env_map = {str(k): str(v) for k, v in dict(env or {}).items()} or None + params = StdioServerParameters(command=command, args=args, env=env_map) + + try: + async with asyncio.timeout(25): + async with ( + stdio_client(params) as (read, write), + ClientSession(read, write) as session, + ): + await session.initialize() + listed = await session.list_tools() + tools = normalize_tools( + [{"name": t.name, "description": t.description or ""} for t in listed.tools] + ) + return {"ok": True, "tool_count": len(tools), "tools": tools} + except TimeoutError: + return {"ok": False, "error": "stdio MCP probe timed out"} + except Exception as exc: + logger.exception("stdio MCP probe failed") + return {"ok": False, "error": str(exc)} diff --git a/src/octop/infra/connectors/service.py b/src/octop/infra/connectors/service.py index a647d73..0e18e6f 100644 --- a/src/octop/infra/connectors/service.py +++ b/src/octop/infra/connectors/service.py @@ -6,12 +6,24 @@ from typing import Any from octop.config import OctopConfig -from octop.infra.connectors.builder import build_http_mcp_spec +from octop.infra.connectors.builder import build_http_mcp_spec, mcp_server_name from octop.infra.connectors.catalog import get_catalog_entry from octop.infra.connectors.crypto import decrypt_credentials, encrypt_credentials +from octop.infra.connectors.custom_mcp import ( + CUSTOM_MCP_DISPLAY_NAME, + CUSTOM_MCP_KIND, + enabled_harness_configs, + expand_custom_instances, + extract_servers, + is_custom_mcp_kind, + server_enabled, + validate_servers_map, + wrap_servers, +) from octop.infra.connectors.oauth import refresh_oauth_credentials from octop.infra.db.repos.connectors import ConnectorRepo, ConnectorRow from octop.infra.db.repos.secrets import SecretRepo +from octop.infra.utils.ulid import new_ulid def list_user_connector_instances( @@ -102,11 +114,120 @@ async def ensure_fresh_credentials( self.encrypt_and_store(instance_id=instance_id, payload=creds) return creds + def reserved_builtin_mcp_names(self, user_id: int) -> set[str]: + names: set[str] = set() + for inst in self._repo.list_by_user(user_id): + if is_custom_mcp_kind(inst.kind): + continue + names.add(inst.mcp_server_name) + return names + + def get_custom_servers(self, user_id: int) -> dict[str, Any]: + row = self._repo.get_by_user_kind(user_id, CUSTOM_MCP_KIND) + if row is None or not row.has_credentials: + return {} + return extract_servers(self.decrypt(row.instance_id)) + + def put_custom_servers(self, user_id: int, servers: dict[str, Any]) -> dict[str, Any]: + normalized = validate_servers_map( + servers, + reserved_names=self.reserved_builtin_mcp_names(user_id), + ) + row = self._repo.get_by_user_kind(user_id, CUSTOM_MCP_KIND) + if not normalized: + if row is not None: + self._repo.delete(row.instance_id) + return {} + if row is None: + instance_id = new_ulid() + self._repo.create( + instance_id=instance_id, + user_id=user_id, + kind=CUSTOM_MCP_KIND, + display_name=CUSTOM_MCP_DISPLAY_NAME, + mcp_server_name=mcp_server_name(CUSTOM_MCP_KIND, instance_id), + ) + else: + instance_id = row.instance_id + self.encrypt_and_store( + instance_id=instance_id, + payload=wrap_servers(normalized), + ) + return normalized + + def patch_custom_server_enabled( + self, + user_id: int, + server_name: str, + *, + enabled: bool, + ) -> dict[str, Any]: + servers = dict(self.get_custom_servers(user_id)) + if server_name not in servers: + raise KeyError(server_name) + spec = dict(servers[server_name]) + spec["enabled"] = enabled + servers[server_name] = spec + return self.put_custom_servers(user_id, servers) + + def list_instances_for_api(self, user_id: int) -> list[dict[str, Any]]: + """Built-in rows + expanded custom servers (hide parent custom-mcp row).""" + out: list[dict[str, Any]] = [] + custom_row = self._repo.get_by_user_kind(user_id, CUSTOM_MCP_KIND) + for inst in self._repo.list_by_user(user_id): + if is_custom_mcp_kind(inst.kind): + continue + out.append( + { + "instance_id": inst.instance_id, + "kind": inst.kind, + "display_name": inst.display_name, + "status": inst.status, + "mcp_server_name": inst.mcp_server_name, + "has_credentials": inst.has_credentials, + "created_at": inst.created_at, + "updated_at": inst.updated_at, + } + ) + if custom_row is not None and custom_row.has_credentials: + out.extend( + expand_custom_instances( + parent=custom_row, + servers=extract_servers(self.decrypt(custom_row.instance_id)), + ) + ) + return out + + def list_active_mcp_server_names(self, user_id: int) -> list[str]: + names: list[str] = [] + for inst in self._repo.list_by_user(user_id): + if is_custom_mcp_kind(inst.kind): + continue + if inst.status != "active" or not inst.has_credentials: + continue + names.append(inst.mcp_server_name) + for name, spec in self.get_custom_servers(user_id).items(): + if isinstance(spec, dict) and server_enabled(spec): + names.append(name) + return sorted(names) + + def validate_mcp_servers_for_user(self, user_id: int, names: list[str]) -> list[str]: + allowed = set(self.list_active_mcp_server_names(user_id)) + unknown = sorted(set(names) - allowed) + if unknown: + raise ValueError(f"mcp_servers not available for user: {unknown}") + return list(names) + + def custom_harness_configs(self, user_id: int) -> dict[str, Any]: + return enabled_harness_configs(self.get_custom_servers(user_id)) + async def mcp_configs_for_user(self, user_id: int) -> dict[str, Any]: configs: dict[str, Any] = {} for inst in self._repo.list_by_user(user_id): if inst.status != "active": continue + if is_custom_mcp_kind(inst.kind): + continue entry = get_catalog_entry(inst.kind) if entry is None: continue @@ -119,6 +240,7 @@ async def mcp_configs_for_user(self, user_id: int) -> dict[str, Any]: creds=creds, config=self._config, ) + configs.update(self.custom_harness_configs(user_id)) return configs def verify_internal_token(self, instance_id: str, token: str) -> dict[str, Any] | None: diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 7234f5d..21107a7 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -36,6 +36,7 @@ class ErrorCode(StrEnum): CONNECTOR_KIND_UNSUPPORTED = "CONNECTOR_KIND_UNSUPPORTED" CONNECTOR_NOT_BOUND = "CONNECTOR_NOT_BOUND" CONNECTOR_ALREADY_BOUND = "CONNECTOR_ALREADY_BOUND" + CONNECTOR_MCP_LOAD_FAILED = "CONNECTOR_MCP_LOAD_FAILED" CRON_TRIGGER_INVALID = "CRON_TRIGGER_INVALID" SLASH_UNKNOWN = "SLASH_UNKNOWN" SLASH_BAD_ARGS = "SLASH_BAD_ARGS" @@ -51,6 +52,7 @@ class ErrorCode(StrEnum): SKILL_IMPORT_UNSUPPORTED_URL = "SKILL_IMPORT_UNSUPPORTED_URL" SKILL_IMPORT_FAILED = "SKILL_IMPORT_FAILED" SKILL_ALREADY_EXISTS = "SKILL_ALREADY_EXISTS" + EXPERT_MARKET_FAILED = "EXPERT_MARKET_FAILED" DESKTOP_SESSION_LIMIT = "DESKTOP_SESSION_LIMIT" DESKTOP_CAPTURE_FAILED = "DESKTOP_CAPTURE_FAILED" @@ -81,6 +83,7 @@ class ErrorCode(StrEnum): ErrorCode.CONNECTOR_KIND_UNSUPPORTED: 400, ErrorCode.CONNECTOR_NOT_BOUND: 400, ErrorCode.CONNECTOR_ALREADY_BOUND: 409, + ErrorCode.CONNECTOR_MCP_LOAD_FAILED: 502, ErrorCode.CRON_TRIGGER_INVALID: 400, ErrorCode.SLASH_UNKNOWN: 400, ErrorCode.SLASH_BAD_ARGS: 400, @@ -96,6 +99,7 @@ class ErrorCode(StrEnum): ErrorCode.SKILL_IMPORT_UNSUPPORTED_URL: 400, ErrorCode.SKILL_IMPORT_FAILED: 502, ErrorCode.SKILL_ALREADY_EXISTS: 409, + ErrorCode.EXPERT_MARKET_FAILED: 502, ErrorCode.DESKTOP_SESSION_LIMIT: 429, ErrorCode.DESKTOP_CAPTURE_FAILED: 503, } diff --git a/src/octop/infra/proactive/service.py b/src/octop/infra/proactive/service.py index 5c2ae25..b1af8a3 100644 --- a/src/octop/infra/proactive/service.py +++ b/src/octop/infra/proactive/service.py @@ -20,6 +20,7 @@ from octop.i18n.loader import tr as i18n_tr from octop.infra.proactive.picker import EpisodePicker +from octop.infra.utils.llm_text import ainvoke_text from octop.infra.utils.locale import normalize_locale if TYPE_CHECKING: @@ -62,22 +63,6 @@ def _format_episodes_for_prompt(episodes: list[Any]) -> str: return "\n\n".join(lines) -def _llm_response_text(response: Any) -> str: - """Extract plain text from a LangChain ``ainvoke`` result.""" - content = getattr(response, "content", response) - if isinstance(content, str): - return content - if isinstance(content, list): - parts: list[str] = [] - for block in content: - if isinstance(block, dict) and block.get("type") == "text": - parts.append(str(block.get("text") or "")) - elif isinstance(block, str): - parts.append(block) - return "".join(parts) - return str(content or "") - - # --------------------------------------------------------------------------- # ProactiveCareService # --------------------------------------------------------------------------- @@ -234,8 +219,7 @@ async def run(self, agent_id: str, session_key: str) -> None: SystemMessage(content=system_prompt), HumanMessage(content=f"User's recent life events:\n\n{episode_context}"), ] - response = await llm.ainvoke(messages) - care_text = _llm_response_text(response) + care_text = await ainvoke_text(llm, messages, timeout=30.0) # Truncate to the max length if len(care_text) > _MAX_CARE_TEXT_LEN: care_text = care_text[:_MAX_CARE_TEXT_LEN] diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index 9e768b5..135ac85 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -113,7 +113,10 @@ async def start(self) -> None: run_migrations(db) self.services = build_shared_services(db=db, paths=self.paths, config=config) self._ensure_jwt_secret() - self.expert_catalog = ExpertCatalog(default_library_root()) + self.expert_catalog = ExpertCatalog( + default_library_root(), + extra_roots=[self.paths.expert_market_dir], + ) self.expert_catalog.refresh() self.subagent_catalog = SubagentCatalog(default_package_root()) self.subagent_catalog.refresh() diff --git a/src/octop/infra/utils/llm_text.py b/src/octop/infra/utils/llm_text.py new file mode 100644 index 0000000..9f0630d --- /dev/null +++ b/src/octop/infra/utils/llm_text.py @@ -0,0 +1,68 @@ +"""Shared helpers for one-shot LangChain LLM calls. + +Used by chat polish, proactive care (memory), and SkillHub expert +manifest generation so text extraction and invoke timing stay consistent. +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +_THINKING_RE = re.compile( + r"[\s\S]*?\s*", + re.IGNORECASE, +) + + +def strip_thinking(text: str) -> str: + """Remove ``...`` blocks from model output.""" + return _THINKING_RE.sub("", text).strip() + + +def llm_text_content(result: Any) -> str: + """Extract plain text from a LangChain ``ainvoke`` result. + + Skips thinking/reasoning content blocks and strips ```` tags + so callers get the user-visible response only. + """ + content = getattr(result, "content", result) + if isinstance(content, str): + return strip_thinking(content) + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, dict): + block_type = str(block.get("type") or "").lower() + if block_type in ("thinking", "reasoning"): + continue + if block_type == "text": + parts.append(str(block.get("text") or "")) + else: + text = block.get("text") or block.get("content") + if text: + parts.append(str(text)) + elif isinstance(block, str): + parts.append(block) + return strip_thinking("".join(parts)) + return strip_thinking(str(content or "")) + + +async def ainvoke_text( + llm: Any, + messages: list[Any], + *, + timeout: float | None = 30.0, +) -> str: + """Invoke *llm* with *messages* and return stripped plain text. + + When *timeout* is set, wraps ``ainvoke`` in ``asyncio.wait_for`` + (same pattern as chat polish). Pass ``timeout=None`` to wait unbound. + """ + coro = llm.ainvoke(messages) + if timeout is None: + result = await coro + else: + result = await asyncio.wait_for(coro, timeout=timeout) + return llm_text_content(result) diff --git a/src/octop/infra/utils/paths.py b/src/octop/infra/utils/paths.py index 698e383..59b9fcc 100644 --- a/src/octop/infra/utils/paths.py +++ b/src/octop/infra/utils/paths.py @@ -54,6 +54,11 @@ def agents_dir(self) -> Path: """Global agents directory: ~/.octop/agents/""" return self.root / "agents" + @property + def expert_market_dir(self) -> Path: + """Cached SkillHub expert templates: ``~/.octop/expert_market/``.""" + return self.root / "expert_market" + def agent_workspace(self, agent_id: str) -> Path: """Global agent workspace: ~/.octop/agents//""" return self.agents_dir / agent_id diff --git a/tests/support/fakes.py b/tests/support/fakes.py index f37fc7c..13b121c 100644 --- a/tests/support/fakes.py +++ b/tests/support/fakes.py @@ -49,6 +49,7 @@ def __init__( self._backend = _FakeBackend() self._workspace = BackendWorkspace(self._backend, self._workspace_dir) self.config = SimpleNamespace(mcp_server_configs={}, skills_disabled=frozenset()) + self._mcp_tools: list[Any] = [] self._mcp_tool_name_set: frozenset[str] = frozenset() def use_workspace_dir(self, workspace_dir: Path, *, virtual_mode: bool = False) -> None: @@ -90,6 +91,25 @@ def workspace(self) -> BackendWorkspace: def is_bootstrapped(self) -> bool: return True + def append_mcp_tools(self, extra_tools: list[Any]) -> None: + """Mirror harness ``append_mcp_tools`` (no graph recompile in tests).""" + if not extra_tools: + return + existing = {str(getattr(t, "name", "") or "") for t in self._mcp_tools} + new_tools = [ + tool for tool in extra_tools if str(getattr(tool, "name", "") or "") not in existing + ] + if not new_tools: + return + self._mcp_tools = list(new_tools) + list(self._mcp_tools) + self._mcp_tool_name_set = frozenset( + str(getattr(t, "name", "") or "") for t in self._mcp_tools if getattr(t, "name", None) + ) + + def inject_mcp_tools(self, extra_tools: list[Any]) -> None: + """Alias for :meth:`append_mcp_tools` (gateway in-process MCP injection).""" + self.append_mcp_tools(extra_tools) + async def seed_default_subagent(self) -> None: """Mirror harness init_workspace default subagent seed.""" content = """--- diff --git a/tests/unit/agents/test_expert_catalog.py b/tests/unit/agents/test_expert_catalog.py index ae1794c..89df053 100644 --- a/tests/unit/agents/test_expert_catalog.py +++ b/tests/unit/agents/test_expert_catalog.py @@ -41,6 +41,29 @@ def test_expert_discovers_seed_paths(tmp_path: Path) -> None: assert expert.prompt_files == [] +def test_expert_catalog_reads_extra_roots(tmp_path: Path) -> None: + from octop.infra.agents.experts.catalog import ExpertCatalog + + bundled_root = tmp_path / "bundled" + market_root = tmp_path / "market" + bundled_dir = bundled_root / "bundled-expert" + market_dir = market_root / "market-expert" + bundled_dir.mkdir(parents=True) + market_dir.mkdir(parents=True) + _write_manifest(bundled_dir) + _write_manifest(market_dir) + (market_dir / "SOUL.md").write_text("# Market soul", encoding="utf-8") + + catalog = ExpertCatalog(bundled_root, extra_roots=[market_root]) + catalog.refresh() + + assert catalog.get("bundled-expert") is not None + market = catalog.get("market-expert") + assert market is not None + assert catalog.expert_dir("market-expert") == market_dir + assert "SOUL.md" in market.files + + def test_expert_lazy_read_file_contents(tmp_path: Path) -> None: from octop.infra.agents.experts.catalog import ExpertCatalog diff --git a/tests/unit/agents/test_expert_manifest_generator.py b/tests/unit/agents/test_expert_manifest_generator.py new file mode 100644 index 0000000..ffe8cfd --- /dev/null +++ b/tests/unit/agents/test_expert_manifest_generator.py @@ -0,0 +1,294 @@ +"""Tests for model-generated SkillHub expert manifest metadata.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +import pytest + + +class FakeLLM: + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload + self.messages: list[Any] = [] + + async def ainvoke(self, messages: list[Any]) -> SimpleNamespace: + self.messages = messages + return SimpleNamespace( + content=f"```json\n{json.dumps(self.payload, ensure_ascii=False)}\n```" + ) + + +def _generated_payload(*, prompt_count: int = 6) -> dict[str, Any]: + titles = ["启动任务", "规划路径", "分析材料", "生成方案", "优化迭代", "答疑澄清"] + prompts: list[dict[str, Any]] = [] + for idx, title in enumerate(titles[:prompt_count], start=1): + prompts.append( + { + "title": {"zh": title, "en": f"Card {idx}"}, + "description": { + "zh": f"第 {idx} 个专家入口", + "en": f"Expert entry {idx}", + }, + "prompt": { + "zh": f"请按专家工作流处理第 {idx} 个任务:\n\n", + "en": f"Use the expert workflow for task {idx}:\n\n", + }, + "color": "#123456", + "icon_name": "sparkles", + } + ) + return { + "label": { + "zh": "测试工作流专家", + "en": "Test Workflow Expert", + }, + "description": { + "zh": "按测试工作流组织任务和交付物。", + "en": "Organizes tasks and deliverables through the test workflow.", + }, + "welcome_message": { + "zh": "把测试任务按工作流推进到可交付结果", + "en": "Move test tasks through the workflow to a ready deliverable", + }, + "quick_prompts": prompts, + } + + +@pytest.mark.asyncio +async def test_generate_and_apply_skillhub_manifest_assets(tmp_path) -> None: + from octop.infra.agents.experts.catalog import ExpertCatalog + from octop.infra.agents.experts.manifest_generator import ( + generate_and_apply_skillhub_manifest_assets, + ) + + expert_dir = tmp_path / "skillhub-skillset-demo" + (expert_dir / "skills" / "demo").mkdir(parents=True) + (expert_dir / "skills" / "demo" / "SKILL.md").write_text( + "---\nname: Demo Workflow\ndescription: Demo workflow description\n---\n# Demo\nDo work.", + encoding="utf-8", + ) + (expert_dir / "skills" / "helper").mkdir(parents=True) + (expert_dir / "skills" / "helper" / "SKILL.md").write_text( + "---\nname: Helper\ndescription: Helper skill\n---\n# Helper\nAssist.", + encoding="utf-8", + ) + (expert_dir / "manifest.json").write_text( + json.dumps( + { + "id": "skillhub-skillset-demo", + "label": {"zh": "测试专家", "en": "Test Expert"}, + "description": {"zh": "测试描述", "en": "Test description"}, + "welcome_message": {"zh": "fallback", "en": "fallback"}, + "prompt_files": ["SOUL.md"], + "quick_prompts": [], + "skillhub": {"skill_slugs": ["helper"]}, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + item = SimpleNamespace( + slug="demo", + display_name="测试专家", + display_name_en="Test Expert", + summary="测试描述", + summary_en="Test description", + scene="tech", + sub_scene="automation", + ) + ok = await generate_and_apply_skillhub_manifest_assets( + llm=FakeLLM(_generated_payload()), + item=item, + expert_dir=expert_dir, + model_ref="p/model", + ) + + assert ok is True + data = json.loads((expert_dir / "manifest.json").read_text(encoding="utf-8")) + assert data["label"]["zh"] == "测试工作流专家" + assert data["label"]["en"] == "Test Workflow Expert" + assert ( + data["description"]["en"] == "Organizes tasks and deliverables through the test workflow." + ) + assert data["welcome_message"]["zh"] == "把测试任务按工作流推进到可交付结果" + assert len(data["quick_prompts"]) == 6 + assert data["quick_prompts"][0]["title"]["en"] == "Card 1" + assert data["skillhub"]["manifest_generated"]["model"] == "p/model" + assert data["skillhub"]["welcome_generated"]["model"] == "p/model" + + catalog = ExpertCatalog(tmp_path) + catalog.refresh() + expert = catalog.get("skillhub-skillset-demo") + assert expert is not None + assert expert.quick_prompts[0].title_zh == "启动任务" + + +def test_normalize_manifest_assets_rejects_empty_quick_prompts() -> None: + from octop.infra.agents.experts.manifest_generator import ( + ExpertManifestGenerationError, + normalize_manifest_assets, + ) + + with pytest.raises(ExpertManifestGenerationError): + normalize_manifest_assets( + {"welcome_message": {"zh": "hi", "en": "hi"}, "quick_prompts": []}, + fallback_name_zh="专家", + fallback_name_en="Expert", + ) + + +def test_normalize_manifest_assets_roleizes_fallback_label() -> None: + from octop.infra.agents.experts.manifest_generator import normalize_manifest_assets + + payload = _generated_payload() + payload.pop("label") + payload.pop("description") + + assets = normalize_manifest_assets( + payload, + fallback_name_zh="自动化测试", + fallback_name_en="Test Automation", + fallback_summary_zh="自动生成测试方案", + fallback_summary_en="Generate test plans", + ) + + assert assets["label"] == {"zh": "自动化测试专家", "en": "Test Automation Expert"} + assert assets["description"] == {"zh": "自动生成测试方案", "en": "Generate test plans"} + + +def test_normalize_manifest_assets_keeps_at_most_six_quick_prompts() -> None: + from octop.infra.agents.experts.manifest_generator import normalize_manifest_assets + + payload = _generated_payload() + payload["quick_prompts"] = [ + { + "title": {"zh": f"卡片 {idx}", "en": f"Card {idx}"}, + "description": {"zh": f"描述 {idx}", "en": f"Description {idx}"}, + "prompt": {"zh": f"处理任务 {idx}:\n\n", "en": f"Handle task {idx}:\n\n"}, + "color": "#123456", + "icon_name": "sparkles", + } + for idx in range(1, 11) + ] + + assets = normalize_manifest_assets( + payload, + fallback_name_zh="专家", + fallback_name_en="Expert", + ) + + assert len(assets["quick_prompts"]) == 6 + assert assets["quick_prompts"][-1]["title"]["zh"] == "卡片 6" + + +def test_normalize_manifest_assets_shortens_long_welcome() -> None: + from octop.infra.agents.experts.manifest_generator import normalize_manifest_assets + + payload = _generated_payload() + payload["welcome_message"] = { + "zh": "把灵感扩展成可连载大纲。也可以继续帮你修订人物小传。", + "en": ("Expand ideas into serialization-ready outlines. We can continue from any stage."), + } + + assets = normalize_manifest_assets( + payload, + fallback_name_zh="专家", + fallback_name_en="Expert", + ) + + assert assets["welcome_message"]["zh"] == "把灵感扩展成可连载大纲" + assert assets["welcome_message"]["en"] == ("Expand ideas into serialization-ready outlines") + assert "…" not in assets["welcome_message"]["zh"] + assert "…" not in assets["welcome_message"]["en"] + + +def test_normalize_manifest_assets_avoids_ellipsis_on_oversized_welcome() -> None: + from octop.infra.agents.experts.manifest_generator import normalize_manifest_assets + + payload = _generated_payload() + payload["welcome_message"] = { + "zh": ( + "覆盖塔罗占卜从「加密随机抽牌引擎」到「韦特经典体系 / 治愈反思派 / " + "13 牌阵丰富度」再到深度解读与疗愈建议的完整工作流" + ), + "en": ( + "Cover tarot from a crypto-random draw engine through Rider-Waite classics, " + "healing reflection schools, and rich 13-card spreads" + ), + } + payload["description"] = { + "zh": "塔罗占卜与牌阵解读", + "en": "Tarot draws and spread interpretation", + } + + assets = normalize_manifest_assets( + payload, + fallback_name_zh="塔罗占卜专家", + fallback_name_en="Tarot Expert", + ) + + assert "…" not in assets["welcome_message"]["zh"] + assert "再到" not in assets["welcome_message"]["zh"] + assert assets["welcome_message"]["zh"] == "塔罗占卜与牌阵解读" + assert assets["welcome_message"]["en"] == "Tarot draws and spread interpretation" + + +def test_normalize_manifest_assets_rejects_chinese_in_english_welcome() -> None: + from octop.infra.agents.experts.manifest_generator import normalize_manifest_assets + + payload = _generated_payload() + payload["welcome_message"] = { + "zh": "把灵感扩展成可连载大纲", + "en": "把灵感扩展成可连载大纲", + } + payload["description"] = { + "zh": "长篇大纲设计", + "en": "Turn story ideas into durable long-form outlines", + } + + assets = normalize_manifest_assets( + payload, + fallback_name_zh="长篇大纲设计专家", + fallback_name_en="Long-form Outline Expert", + ) + + assert assets["welcome_message"]["zh"] == "把灵感扩展成可连载大纲" + assert assets["welcome_message"]["en"] == ("Turn story ideas into durable long-form outlines") + + +def test_normalize_manifest_assets_uses_pastel_palette_for_quick_prompt_colors() -> None: + from octop.infra.agents.experts.manifest_generator import ( + _COLOR_FALLBACKS, + normalize_manifest_assets, + ) + + payload = _generated_payload(prompt_count=2) + payload["quick_prompts"][0]["color"] = "#1d4ed8" + payload["quick_prompts"][1]["color"] = "#e8f4ff" + + assets = normalize_manifest_assets( + payload, + fallback_name_zh="专家", + fallback_name_en="Expert", + ) + + assert assets["quick_prompts"][0]["color"] == _COLOR_FALLBACKS[0] + assert assets["quick_prompts"][1]["color"] == _COLOR_FALLBACKS[1] + + +def test_normalize_manifest_assets_pads_to_six_quick_prompts() -> None: + from octop.infra.agents.experts.manifest_generator import normalize_manifest_assets + + assets = normalize_manifest_assets( + _generated_payload(prompt_count=3), + fallback_name_zh="专家", + fallback_name_en="Expert", + ) + + assert len(assets["quick_prompts"]) == 6 + assert assets["quick_prompts"][0]["title"]["zh"] == "启动任务" + assert assets["quick_prompts"][3]["title"]["zh"] == "继续推进 4" diff --git a/tests/unit/agents/test_mcp_tool_cache.py b/tests/unit/agents/test_mcp_tool_cache.py new file mode 100644 index 0000000..ecdb2af --- /dev/null +++ b/tests/unit/agents/test_mcp_tool_cache.py @@ -0,0 +1,204 @@ +"""Unit tests for custom MCP deferred load + user-level tool cache.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langchain_core.tools import BaseTool, StructuredTool + +from octop.infra.connectors.mcp_tool_cache import ( + fingerprint_mcp_spec, + wrap_tools_for_shared_use, +) + + +def test_fingerprint_stable_and_ignores_enabled() -> None: + a = fingerprint_mcp_spec( + { + "transport": "stdio", + "command": "npx", + "args": ["-y", "pkg"], + "enabled": True, + } + ) + b = fingerprint_mcp_spec( + { + "transport": "stdio", + "command": "npx", + "args": ["-y", "pkg"], + "enabled": False, + } + ) + assert a == b + c = fingerprint_mcp_spec( + { + "transport": "stdio", + "command": "npx", + "args": ["-y", "other"], + } + ) + assert a != c + + +@pytest.mark.asyncio +async def test_wrap_tools_serialize_ainvoke() -> None: + order: list[str] = [] + + class _FakeTool: + name = "demo_tool" + description = "demo" + args_schema = None + + async def ainvoke(self, *_a: Any, **_k: Any) -> str: + order.append("start") + await asyncio.sleep(0.05) + order.append("end") + return "ok" + + def invoke(self, *_a: Any, **_k: Any) -> str: + return "ok" + + lock = asyncio.Lock() + wrapped = wrap_tools_for_shared_use([_FakeTool()], lock) + assert len(wrapped) == 1 + assert isinstance(wrapped[0], BaseTool) + assert isinstance(wrapped[0], StructuredTool) + + async def _call() -> Any: + return await wrapped[0].ainvoke({}) + + await asyncio.gather(_call(), _call()) + assert order == ["start", "end", "start", "end"] + + +@pytest.mark.asyncio +async def test_get_or_load_caches_and_reuses() -> None: + from octop.infra.agents.manager import AgentManager + + mgr = object.__new__(AgentManager) + mgr._mcp_tool_cache = {} + mgr._mcp_tool_cache_locks = {} + mgr._mcp_tool_cache_guard = asyncio.Lock() + + fake = MagicMock() + fake.name = "s_tool1" + fake.description = "t" + fake.args_schema = None + fake.metadata = None + fake.ainvoke = AsyncMock(return_value="ok") + aload = AsyncMock(return_value=[fake]) + + spec = {"transport": "stdio", "command": "npx", "args": []} + with patch("harness_agent.mcp.aload_mcp_tools", aload): + first = await mgr._get_or_load_mcp_tools(1, "s", spec) + second = await mgr._get_or_load_mcp_tools(1, "s", spec) + + assert aload.await_count == 1 + assert first is second + assert isinstance(first[0], StructuredTool) + assert first[0].name == "s_tool1" + + +@pytest.mark.asyncio +async def test_get_or_load_misses_on_fingerprint_change() -> None: + from octop.infra.agents.manager import AgentManager + + mgr = object.__new__(AgentManager) + mgr._mcp_tool_cache = {} + mgr._mcp_tool_cache_locks = {} + mgr._mcp_tool_cache_guard = asyncio.Lock() + + def _tool(name: str) -> MagicMock: + t = MagicMock() + t.name = name + t.description = "t" + t.args_schema = None + t.metadata = None + t.ainvoke = AsyncMock(return_value="ok") + return t + + aload = AsyncMock(side_effect=[[_tool("s_a")], [_tool("s_b")]]) + + with patch("harness_agent.mcp.aload_mcp_tools", aload): + a = await mgr._get_or_load_mcp_tools( + 1, "s", {"transport": "stdio", "command": "npx", "args": ["a"]} + ) + b = await mgr._get_or_load_mcp_tools( + 1, "s", {"transport": "stdio", "command": "npx", "args": ["b"]} + ) + + assert aload.await_count == 2 + assert a[0].name == "s_a" + assert b[0].name == "s_b" + assert len(mgr._mcp_tool_cache) == 1 + + +@pytest.mark.asyncio +async def test_prepare_chat_mcp_injects_custom_from_cache() -> None: + from octop.infra.agents.manager import AgentManager + + mgr = object.__new__(AgentManager) + mgr._mcp_tool_cache = {} + mgr._mcp_tool_cache_locks = {} + mgr._mcp_tool_cache_guard = asyncio.Lock() + + tool = MagicMock() + tool.name = "deepwiki_ask" + tool.description = "ask" + tool.args_schema = None + tool.metadata = None + tool.ainvoke = AsyncMock(return_value="ok") + aload = AsyncMock(return_value=[tool]) + + agent = MagicMock() + agent._mcp_tool_name_set = frozenset() + agent.config.mcp_server_configs = {"deepwiki": {}} + agent.append_mcp_tools = MagicMock( + side_effect=lambda tools: setattr( + agent, + "_mcp_tool_name_set", + frozenset(getattr(t, "name", "") for t in tools) | frozenset(agent._mcp_tool_name_set), + ) + ) + + row = MagicMock() + mgr.get_agent = MagicMock(return_value=agent) + mgr.get_row = MagicMock(return_value=row) + mgr._connector_uid_for = MagicMock(return_value=7) + mgr._connector_svc = MagicMock() + mgr._connector_svc.custom_harness_configs.return_value = { + "deepwiki": { + "transport": "streamable_http", + "url": "https://mcp.deepwiki.com/mcp", + "headers": {"Accept": "application/json, text/event-stream"}, + } + } + mgr.reload_connectors = AsyncMock() + + with patch("harness_agent.mcp.aload_mcp_tools", aload): + failed = await mgr.prepare_chat_mcp("A1", ["deepwiki"], connector_user_id=7) + failed2 = await mgr.prepare_chat_mcp("A1", ["deepwiki"], connector_user_id=7) + + assert failed == [] + assert failed2 == [] + assert aload.await_count == 1 + assert agent.append_mcp_tools.call_count == 1 + mgr.reload_connectors.assert_not_awaited() + assert agent.config.mcp_server_configs["deepwiki"]["transport"] == "streamable_http" + injected = agent.append_mcp_tools.call_args.args[0] + assert isinstance(injected[0], StructuredTool) + + +def test_wrap_tools_for_shared_use() -> None: + lock = asyncio.Lock() + inner = MagicMock() + inner.name = "x" + inner.description = "d" + inner.args_schema = None + inner.metadata = None + wrapped = wrap_tools_for_shared_use([inner], lock) + assert len(wrapped) == 1 + assert isinstance(wrapped[0], StructuredTool) diff --git a/tests/unit/agents/test_provider_store.py b/tests/unit/agents/test_provider_store.py index 7807e5c..7784902 100644 --- a/tests/unit/agents/test_provider_store.py +++ b/tests/unit/agents/test_provider_store.py @@ -218,6 +218,30 @@ def test_resolve_model_for_multimodal_turn( assert resolved == expected +def test_resolve_first_model_ref_returns_first_enabled_model(store: ProviderStore) -> None: + store._provider_repo.create( + name="p1", + kind="openai", + base_url="https://api.example.com/v1", + api_key="sk-test", + models_json=json.dumps( + [ + {"id": "disabled", "name": "disabled", "enabled": False}, + {"id": "ready", "name": "ready", "enabled": True}, + ] + ), + ) + store._provider_repo.create( + name="p2", + kind="openai", + base_url="https://api.example.com/v1", + api_key="sk-test", + models_json=json.dumps([{"id": "other", "name": "other", "enabled": True}]), + ) + + assert store.resolve_first_model_ref() == "p1/ready" + + def test_resolve_multimodal_model_ref_prefers_inferred_vision_model(store: ProviderStore) -> None: store._provider_repo.create( name="p", diff --git a/tests/unit/agents/test_skillhub_market.py b/tests/unit/agents/test_skillhub_market.py new file mode 100644 index 0000000..bd6aff1 --- /dev/null +++ b/tests/unit/agents/test_skillhub_market.py @@ -0,0 +1,443 @@ +"""Tests for SkillHub skillset normalization into expert templates.""" + +from __future__ import annotations + +import io +import json +import re +import zipfile + + +def _zip_bytes(files: dict[str, str]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return buf.getvalue() + + +def _has_cjk(text: str) -> bool: + return bool(re.search(r"[\u3400-\u9fff]", text)) + + +def test_skillhub_manifest_welcome_summarizes_capability() -> None: + from octop.infra.agents.experts.skillhub_market import ( + SkillHubSkillset, + _expert_manifest, + ) + + item = SkillHubSkillset( + slug="media-longform-outline", + display_name="长篇大纲设计", + display_name_en="Long-form Outline Design", + summary="把灵感扩展成可长期连载的长篇大纲。", + summary_en="Expand ideas into serialization-ready outlines.", + scene="media", + ) + + manifest = _expert_manifest(item, ["outline"]) + + assert manifest["welcome_message"]["zh"] == "把灵感扩展成可长期连载的长篇大纲" + assert "选下方卡片" not in manifest["welcome_message"]["zh"] + assert "…" not in manifest["welcome_message"]["zh"] + assert manifest["welcome_message"]["en"] == ("Expand ideas into serialization-ready outlines") + assert "Pick a card" not in manifest["welcome_message"]["en"] + + +def test_skillhub_manifest_welcome_keeps_chinese_when_summary_is_english() -> None: + from octop.infra.agents.experts.skillhub_market import ( + SkillHubSkillset, + _expert_manifest, + ) + + item = SkillHubSkillset( + slug="tarot-reading", + display_name="塔罗占卜", + display_name_en="Tarot Reading", + summary=( + "Cover tarot from crypto-random draw engines to Rider-Waite classics " + "and rich 13-card spreads." + ), + summary_en="Practical tarot draws with classic spreads.", + scene="mysticism", + ) + + manifest = _expert_manifest(item, ["tarot"]) + + assert "Cover tarot" not in manifest["welcome_message"]["zh"] + assert "…" not in manifest["welcome_message"]["zh"] + assert manifest["welcome_message"]["zh"] == "提供专业、可落地的专家工作流支持" + assert manifest["welcome_message"]["en"] == "Practical tarot draws with classic spreads" + + from octop.infra.agents.experts.skillhub_market import ( + SkillHubSkillset, + _expert_manifest, + ) + + item = SkillHubSkillset( + slug="tech-test-automation", + display_name="技术测试自动化", + display_name_en="Tech Test Automation", + summary="自动生成测试方案", + summary_en="Generate test plans", + scene="tech", + ) + + manifest = _expert_manifest(item, ["playwright", "pytest"]) + + assert manifest["id"] == "skillhub-skillset-tech-test-automation" + assert manifest["prompt_files"] == ["SOUL.md"] + assert manifest["label"]["zh"] == "技术测试自动化专家" + assert manifest["label"]["en"] == "Tech Test Automation Expert" + assert len(manifest["quick_prompts"]) == 6 + assert manifest["quick_prompts"][0]["title"]["zh"] + assert manifest["quick_prompts"][0]["title"]["en"] + assert "playwright" in manifest["skillhub"]["skill_slugs"] + + +def test_skillhub_manifest_generates_workflow_quick_prompts() -> None: + from octop.infra.agents.experts.skillhub_market import ( + SkillHubSkillset, + _expert_manifest, + ) + + item = SkillHubSkillset( + slug="media-storyboard-design", + display_name="分镜设计", + scene="media", + content="""# 分镜设计工作流 + +## 步骤 1:剧本到分镜表格转换(获取层) +- 将剧本文本解析为结构化分镜表格 + +输出标准分镜表格和拍摄计划。 + +## 步骤 2:电影感镜头运动设计(获取层) +- 为每个分镜设计镜头运动方案 + +输出镜头运动设计方案和机位规划。 +""", + ) + + manifest = _expert_manifest(item, ["script-to-storyboard"]) + + titles_zh = [p["title"]["zh"] for p in manifest["quick_prompts"]] + assert len(titles_zh) == 6 + assert titles_zh[:2] == [ + "剧本到分镜表格转换", + "电影感镜头运动设计", + ] + assert titles_zh[2:] == [ + "开始处理任务", + "先制定计划", + "分析现状", + "产出结果", + ] + assert manifest["quick_prompts"][0]["description"]["zh"] == "生成标准分镜表格和拍摄计划" + prompt = manifest["quick_prompts"][0]["prompt"]["zh"] + assert prompt == ( + "请作为「分镜设计专家」,帮我完成「剧本到分镜表格转换」。\n我的情况/目标/材料是:\n" + ) + assert "按以下方式引导我" not in prompt + assert "将剧本文本解析为结构化分镜表格" not in prompt + assert manifest["quick_prompts"][0]["icon_name"] == "video" + first = manifest["quick_prompts"][0] + assert first["title"]["en"] == "Workflow step 1" + assert first["description"]["en"] == "Share context for a ready result" + assert not _has_cjk(first["title"]["en"]) + assert not _has_cjk(first["description"]["en"]) + assert not _has_cjk(first["prompt"]["en"]) + assert first["prompt"]["en"] == ( + "As the Media Storyboard Design Expert, help me with: Workflow step 1.\n" + "My context, goals, or materials are:\n" + ) + + +def test_skillhub_manifest_keeps_up_to_six_workflow_quick_prompts() -> None: + from octop.infra.agents.experts.skillhub_market import ( + SkillHubSkillset, + _expert_manifest, + ) + + workflow = "# 工作流\n\n" + "\n\n".join( + f"## 步骤 {idx}:步骤标题 {idx}(处理层)\n- 执行动作 {idx}\n\n输出交付物 {idx}。" + for idx in range(1, 11) + ) + item = SkillHubSkillset( + slug="demo", + display_name="演示专家", + scene="tech", + content=workflow, + ) + + manifest = _expert_manifest(item, ["demo"]) + + assert len(manifest["quick_prompts"]) == 6 + assert manifest["quick_prompts"][0]["title"]["zh"] == "步骤标题 1" + assert manifest["quick_prompts"][-1]["title"]["zh"] == "步骤标题 6" + + +def test_browse_skillsets_filters_by_scene(monkeypatch) -> None: + from octop.infra.agents.experts import skillhub_market + + monkeypatch.setattr( + skillhub_market, + "_fetch_all_skillsets", + lambda: [ + skillhub_market.SkillHubSkillset( + slug="a", + display_name="A", + scene="tech", + ), + skillhub_market.SkillHubSkillset( + slug="b", + display_name="B", + scene="finance", + ), + skillhub_market.SkillHubSkillset( + slug="c", + display_name="C", + scene="tech", + ), + ], + ) + + items, scenes = skillhub_market.browse_skillsets(scene="tech") + + assert [item.slug for item in items] == ["a", "c"] + assert scenes == ["finance", "tech"] + + +def test_fetch_skillset_uses_detail_endpoint(monkeypatch) -> None: + from octop.infra.agents.experts import skillhub_market + + calls: list[str] = [] + + def fake_json_get(url: str) -> dict[str, object]: + calls.append(url) + return { + "slug": "tech-code-review", + "displayName": "代码审查", + "scene": "tech", + "summary": "review PRs", + } + + monkeypatch.setattr(skillhub_market, "_http_json_get", fake_json_get) + + item = skillhub_market.fetch_skillset("tech-code-review") + + assert item.slug == "tech-code-review" + assert item.scene == "tech" + assert "/api/v1/skillsets/tech-code-review" in calls[0] + + +def test_fetch_skillsets_uses_skillhub_pagination(monkeypatch) -> None: + from octop.infra.agents.experts import skillhub_market + + calls: list[str] = [] + + def fake_json_get(url: str) -> dict[str, object]: + calls.append(url) + if "page=1" in url: + return { + "skillSets": [ + {"slug": "one", "displayName": "One"}, + {"slug": "two", "displayName": "Two"}, + ], + "total": 3, + } + return { + "skillSets": [{"slug": "three", "displayName": "Three"}], + "total": 3, + } + + monkeypatch.setattr(skillhub_market, "_SKILLSET_PAGE_SIZE", 2) + monkeypatch.setattr(skillhub_market, "_skillset_list_cache", None) + monkeypatch.setattr(skillhub_market, "_skillset_list_cache_at", 0.0) + monkeypatch.setattr(skillhub_market, "_http_json_get", fake_json_get) + + items = skillhub_market.fetch_skillsets() + + assert [item.slug for item in items] == ["one", "two", "three"] + assert "page=1" in calls[0] + assert "pageSize=2" in calls[0] + assert "page=2" in calls[1] + + +def test_skillhub_manifest_skill_slugs_are_deduped() -> None: + from octop.infra.agents.experts.skillhub_market import _manifest_skill_slugs + + manifest = { + "skillSets": [ + {"skillSlugs": ["alpha", "beta"]}, + {"skillSlugs": ["beta", "gamma"]}, + ] + } + + assert _manifest_skill_slugs(manifest) == ["alpha", "beta", "gamma"] + + +def test_parse_skillset_package_prefers_matching_skillsets_prompt() -> None: + from octop.infra.agents.experts.skillhub_market import _parse_skillset_package + + manifest = {"skillSets": [{"slug": "target", "skillSlugs": ["alpha"]}]} + package = _zip_bytes( + { + "manifest.json": json.dumps(manifest), + "identify.md": "# Wrong prompt\n", + "skillsets/other.md": "# Other prompt\n", + "skillsets/target.md": "# Target workflow\n", + } + ) + + parsed_manifest, prompt = _parse_skillset_package( + package, + skillset_slug="target", + fallback_content="# Fallback\n", + ) + + assert parsed_manifest == manifest + assert prompt == "# Target workflow\n" + + +def test_parse_skillset_package_uses_identify_for_single_skillset() -> None: + from octop.infra.agents.experts.skillhub_market import _parse_skillset_package + + manifest = {"skillSlugs": ["alpha"]} + package = _zip_bytes( + { + "manifest.json": json.dumps(manifest), + "identify.md": "# Single skillset workflow\n", + } + ) + + _parsed_manifest, prompt = _parse_skillset_package( + package, + skillset_slug="target", + fallback_content="# Fallback\n", + ) + + assert prompt == "# Single skillset workflow\n" + + +def test_skillhub_dedupes_repeated_frontmatter() -> None: + from octop.infra.agents.experts.skillhub_market import _dedupe_frontmatter + + text = "---\ntitle: Demo\n---\n---\ntitle: Demo\n---\n# Body\n" + + assert _dedupe_frontmatter(text) == "---\ntitle: Demo\n---\n\n# Body\n" + + +def test_skillset_from_raw_tolerates_bad_skill_count() -> None: + from octop.infra.agents.experts.skillhub_market import _skillset_from_raw + + item = _skillset_from_raw( + { + "slug": "demo", + "displayName": "Demo", + "skillSlugs": ["a", "b"], + "skillCount": "not-a-number", + } + ) + + assert item.skill_count == 2 + + +def test_validate_zip_rejects_path_traversal() -> None: + import io + import zipfile + + import pytest + + from octop.infra.agents.experts.skillhub_market import ( + SkillHubMarketError, + SkillHubMarketErrorKind, + _validate_zip, + ) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("../evil.txt", "x") + with ( + zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf, + pytest.raises(SkillHubMarketError) as exc, + ): + _validate_zip(zf) + assert exc.value.kind == SkillHubMarketErrorKind.PACKAGE_INVALID + + +def test_validate_zip_rejects_too_many_entries(monkeypatch) -> None: + import io + import zipfile + + import pytest + + from octop.infra.agents.experts import skillhub_market + + monkeypatch.setattr(skillhub_market, "_MAX_ZIP_ENTRIES", 2) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("a.txt", "a") + zf.writestr("b.txt", "b") + zf.writestr("c.txt", "c") + with ( + zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf, + pytest.raises(skillhub_market.SkillHubMarketError) as exc, + ): + skillhub_market._validate_zip(zf) + assert exc.value.kind == skillhub_market.SkillHubMarketErrorKind.PACKAGE_TOO_LARGE + + +def test_fetch_all_skillsets_serves_stale_on_refresh_failure(monkeypatch) -> None: + from octop.infra.agents.experts import skillhub_market + + cached = [ + skillhub_market.SkillHubSkillset(slug="cached", display_name="Cached"), + ] + monkeypatch.setattr(skillhub_market, "_skillset_list_cache", cached) + monkeypatch.setattr(skillhub_market, "_skillset_list_cache_at", 0.0) + monkeypatch.setattr(skillhub_market, "_skillset_list_loading", False) + + def boom() -> list[skillhub_market.SkillHubSkillset]: + raise skillhub_market.SkillHubMarketError( + "upstream down", + kind=skillhub_market.SkillHubMarketErrorKind.UPSTREAM_FAILED, + ) + + monkeypatch.setattr(skillhub_market, "_load_all_skillsets_uncached", boom) + + items = skillhub_market._fetch_all_skillsets() + assert [item.slug for item in items] == ["cached"] + + +def test_map_skillhub_error_hides_upstream_details() -> None: + from octop.api.routers.experts import _map_skillhub_error + from octop.infra.agents.experts.skillhub_market import ( + SkillHubMarketError, + SkillHubMarketErrorKind, + ) + from octop.infra.errors import ErrorCode + + err = SkillHubMarketError( + "SkillHub request failed: https://secret.example/path", + kind=SkillHubMarketErrorKind.UPSTREAM_FAILED, + ) + mapped = _map_skillhub_error(err) + assert mapped.code == ErrorCode.EXPERT_MARKET_FAILED + assert "secret.example" not in mapped.message + assert mapped.details["kind"] == "upstream_failed" + assert "secret.example" not in str(mapped.details.get("reason", "")) + + +def test_map_skillhub_not_found() -> None: + from octop.api.routers.experts import _map_skillhub_error + from octop.infra.agents.experts.skillhub_market import ( + SkillHubMarketError, + SkillHubMarketErrorKind, + ) + from octop.infra.errors import ErrorCode + + mapped = _map_skillhub_error( + SkillHubMarketError("missing", kind=SkillHubMarketErrorKind.NOT_FOUND) + ) + assert mapped.code == ErrorCode.NOT_FOUND diff --git a/tests/unit/api/test_chat_polish.py b/tests/unit/api/test_chat_polish.py index 5dfb090..7d9838f 100644 --- a/tests/unit/api/test_chat_polish.py +++ b/tests/unit/api/test_chat_polish.py @@ -10,12 +10,12 @@ from octop.api.routers.chat.serialize import ( _entry_matches_thread, - _llm_text_content, _merge_adjacent_messages, _serialize_history_message, _strip_thinking, _ts_to_ms, ) +from octop.infra.utils.llm_text import llm_text_content as _llm_text_content def test_serialize_history_message_includes_thinking_and_tools() -> None: diff --git a/tests/unit/connectors/test_custom_mcp.py b/tests/unit/connectors/test_custom_mcp.py new file mode 100644 index 0000000..79117ce --- /dev/null +++ b/tests/unit/connectors/test_custom_mcp.py @@ -0,0 +1,267 @@ +"""Unit tests for custom MCP validation and assembly.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from octop.config import OctopConfig +from octop.infra.connectors.builder import build_mcp_server_configs_for_user, mcp_server_name +from octop.infra.connectors.custom_mcp import ( + CUSTOM_MCP_KIND, + enabled_harness_configs, + extract_servers, + harness_spec_for_server, + normalize_server_spec, + validate_servers_map, +) +from octop.infra.connectors.service import ConnectorService +from octop.infra.db.migrate import run_migrations +from octop.infra.db.pool import DBPool +from octop.infra.db.repos.connectors import ConnectorRepo +from octop.infra.db.repos.secrets import SecretRepo +from octop.infra.db.repos.settings import SettingsRepo +from octop.infra.utils.ulid import new_ulid + + +@pytest.fixture +def db(tmp_path: Path) -> DBPool: + pool = DBPool(tmp_path / "octop.db") + run_migrations(pool) + return pool + + +@pytest.fixture +def svc(db: DBPool) -> ConnectorService: + return ConnectorService( + repo=ConnectorRepo(db), + secret_repo=SecretRepo(db), + settings_repo=SettingsRepo(db), + config=OctopConfig(), + ) + + +def _ensure_user(db: DBPool) -> int: + with db.transaction() as conn: + conn.execute( + "INSERT INTO users(username, password_hash, role, created_at) " + "VALUES ('u1', 'x', 'user', 1)" + ) + row = conn.execute("SELECT id FROM users WHERE username = 'u1'").fetchone() + assert row is not None + return int(row["id"]) + + +def test_normalize_streamable_http_and_stdio(): + http = normalize_server_spec( + "deepwiki", + { + "transport": "streamable_http", + "url": "https://mcp.deepwiki.com/mcp", + "headers": {"Authorization": "Bearer t"}, + }, + ) + assert http["transport"] == "streamable_http" + assert http["url"] == "https://mcp.deepwiki.com/mcp" + assert http["headers"]["Authorization"] == "Bearer t" + assert http["enabled"] is True + + stdio = normalize_server_spec( + "local", + { + "transport": "stdio", + "command": "npx", + "args": ["-y", "pkg"], + "enabled": False, + }, + ) + assert stdio["transport"] == "stdio" + assert stdio["command"] == "npx" + assert stdio["args"] == ["-y", "pkg"] + assert stdio["enabled"] is False + + +def test_rejects_http_scheme_and_private_host(): + with pytest.raises(ValueError, match="https"): + normalize_server_spec( + "bad", + {"transport": "streamable_http", "url": "http://example.com/mcp"}, + ) + with pytest.raises(ValueError, match="private|not allowed|blocked"): + normalize_server_spec( + "bad", + {"transport": "streamable_http", "url": "https://10.0.0.1/mcp"}, + ) + + +def test_allows_loopback_http_and_https(): + for url in ( + "http://localhost:8080/mcp", + "https://127.0.0.1/mcp", + "http://[::1]/mcp", + ): + spec = normalize_server_spec( + "local", + {"transport": "streamable_http", "url": url}, + ) + assert spec["url"] == url + + +def test_validate_servers_map_reserved_and_duplicate(): + with pytest.raises(ValueError, match="conflicts"): + validate_servers_map( + {"taken": {"transport": "stdio", "command": "x"}}, + reserved_names={"taken"}, + ) + with pytest.raises(ValueError, match="invalid server name"): + validate_servers_map({"bad name": {"transport": "stdio", "command": "x"}}) + + +def test_enabled_harness_configs_filters_disabled(): + servers = validate_servers_map( + { + "on": { + "transport": "streamable_http", + "url": "https://mcp.example.com/mcp", + }, + "off": { + "transport": "stdio", + "command": "npx", + "enabled": False, + }, + } + ) + configs = enabled_harness_configs(servers) + assert set(configs) == {"on"} + assert "enabled" not in configs["on"] + assert configs["on"]["transport"] == "streamable_http" + + +def test_harness_spec_stdio_default_args(): + spec = harness_spec_for_server({"transport": "stdio", "command": "uvx", "enabled": True}) + assert spec["args"] == [] + assert "enabled" not in spec + + +def test_harness_spec_streamable_http_adds_accept(): + spec = harness_spec_for_server( + { + "transport": "streamable_http", + "url": "https://mcp.example.com/mcp", + "headers": {"Authorization": "Bearer x"}, + "enabled": True, + } + ) + assert spec["headers"]["Authorization"] == "Bearer x" + assert spec["headers"]["Accept"] == "application/json, text/event-stream" + assert "enabled" not in spec + + +def test_put_and_expand_custom_mcp(svc: ConnectorService, db: DBPool): + uid = _ensure_user(db) + servers = svc.put_custom_servers( + uid, + { + "deepwiki": { + "transport": "streamable_http", + "url": "https://mcp.deepwiki.com/mcp", + }, + "local": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "x"], + "enabled": False, + }, + }, + ) + assert set(servers) == {"deepwiki", "local"} + assert extract_servers( + svc.decrypt(svc._repo.get_by_user_kind(uid, CUSTOM_MCP_KIND).instance_id) + ) # noqa: SLF001 + + listed = svc.list_instances_for_api(uid) + assert len(listed) == 2 + by_name = {row["mcp_server_name"]: row for row in listed} + assert by_name["deepwiki"]["status"] == "active" + assert by_name["local"]["status"] == "disabled" + assert by_name["deepwiki"]["instance_id"] == "custom:deepwiki" + + active = svc.list_active_mcp_server_names(uid) + assert active == ["deepwiki"] + + svc.patch_custom_server_enabled(uid, "local", enabled=True) + assert set(svc.list_active_mcp_server_names(uid)) == {"deepwiki", "local"} + + +def test_put_empty_servers_deletes_parent_row(svc: ConnectorService, db: DBPool): + uid = _ensure_user(db) + svc.put_custom_servers( + uid, + { + "only": { + "transport": "stdio", + "command": "npx", + }, + }, + ) + assert svc._repo.get_by_user_kind(uid, CUSTOM_MCP_KIND) is not None # noqa: SLF001 + assert svc.put_custom_servers(uid, {}) == {} + assert svc._repo.get_by_user_kind(uid, CUSTOM_MCP_KIND) is None # noqa: SLF001 + assert svc.list_instances_for_api(uid) == [] + + +def test_build_mcp_configs_merges_enabled_custom(svc: ConnectorService, db: DBPool): + uid = _ensure_user(db) + # Built-in-style row that catalog would skip if unknown — use real catalog kind optional. + # Only custom servers here. + svc.put_custom_servers( + uid, + { + "a": { + "transport": "streamable_http", + "url": "https://mcp.example.com/a", + }, + "b": { + "transport": "stdio", + "command": "npx", + "enabled": False, + }, + }, + ) + configs = build_mcp_server_configs_for_user( + svc=svc, + connector_repo=svc._repo, # noqa: SLF001 + user_id=uid, + agent_id="agent-1", + agent_user_id=uid, + config=OctopConfig(), + log=False, + ) + assert set(configs) == {"a"} + # Custom MCP is deferred (placeholder) until prepare_chat_mcp. + assert configs["a"] == {} + assert "transport" not in configs["a"] + + +def test_custom_mcp_name_conflicts_with_builtin(svc: ConnectorService, db: DBPool): + uid = _ensure_user(db) + iid = new_ulid() + name = mcp_server_name("tencent-docs", iid) + svc._repo.create( # noqa: SLF001 + instance_id=iid, + user_id=uid, + kind="tencent-docs", + display_name="Docs", + mcp_server_name=name, + ) + with pytest.raises(ValueError, match="conflicts"): + svc.put_custom_servers( + uid, + { + name: { + "transport": "stdio", + "command": "npx", + } + }, + )