diff --git a/frontend/README.md b/frontend/README.md index 2196362ee..c2af4d69b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1824,3 +1824,31 @@ MPA creation defaults to A2A discovery (`ENABLE_A2A=true`, `DISABLE_JWT_AUTH=fal MPA name validation highlights invalid input and its message in the theme danger color, offers a valid-name placeholder, and visibly disables Next until corrected. Shared Button disabled states are dimmed consistently; loading retains its own appearance. MPA 名称校验使用主题错误色显示非法输入及错误提示,并提供合法名称示例;名称修正前“下一步”明显置灰。共享 Button 的禁用态统一降低透明度,加载态保留原有外观。 + +MPA Agent details currently expose only Basic information. Other tabs and the Profile configuration action are hidden; this page does not query Profile status or submit Profile operations. General Agent pages and chat session protocols are unchanged. + +MPA 智能体详情当前仅显示基本信息。其他页面及 Profile 配置操作隐藏;本页不查询 Profile 状态或提交 Profile 操作。普通智能体页面与聊天会话协议保持不变。 + +### MPA Feishu multi-bot accounts / MPA 飞书多机器人 + +Runtimes advertising `multiBotChannels: ["feishu"]` expose a robot selector in +Automations → Messaging channels → MPA agent messaging channels. Add bots with +QR or manual binding, then manage enablement, unbinding, diagnostics and group +permissions for the selected `appId`. Adding a different bot preserves existing +accounts. Feishu scheduled tasks select an enabled delivery bot; switching to +Web delivery removes `delivery.appId`. Channel management requires Studio +administrator access. A failed or unauthorized account lookup blocks Feishu +task submission instead of selecting an arbitrary bot. Older runtimes keep the +single-bot interface. Compatible MPA images must be deployed separately. + +Runtime 声明 `multiBotChannels: ["feishu"]` 后,可在“自动化 → 消息渠道 → +MPA 智能体消息渠道”中选择机器人。扫码或手动添加后,启停、解绑、诊断和群权限 +均按选中的 `appId` 管理;新增不同机器人保留既有绑定。飞书定时任务须选择已启用 +的投递机器人,切换 Web 投递会清除 `delivery.appId`。渠道管理需要 Studio 管理员 +权限;账号读取失败或无权限时阻止飞书任务提交,不任意选择机器人。旧 Runtime +保持单机器人界面,兼容的 MPA 镜像需另行部署。 + +Contracts and verification / 契约与验证: +[channels](../specs/mpa-channels/README.md), +[tasks](../specs/studio-mpa-cron-tasks/README.md), +[design](../prd-spec/features/mpa-feishu-multi-bot/2026-10-09-studio-multi-bot.md). diff --git a/frontend/package.json b/frontend/package.json index 42c468208..bfd7e83bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "node scripts/build.mjs", "test": "node --test tests/*.test.mjs && npm run test:mpa-agent-info", - "test:mpa-agent-info": "vitest run tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx", + "test:mpa-agent-info": "vitest run tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx tests/mpaBasicDetails.test.tsx", "check:i18n": "node scripts/check-i18n.mjs", "test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts", "test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts", diff --git a/frontend/server/mpa_creation.py b/frontend/server/mpa_creation.py index c03f7ed80..6d1b50766 100644 --- a/frontend/server/mpa_creation.py +++ b/frontend/server/mpa_creation.py @@ -37,22 +37,27 @@ ConfigurationError, load_studio_profile, validate_creation_resources, + validate_creation_tos, validate_image_reference, validate_runtime_name, - validate_creation_tos, with_creation_images, with_creation_resources, with_creation_tos, ) -from veadk.integrations.mpa.managed.credentials import load_volcengine_credentials +from veadk.integrations.mpa.managed.credentials import ( + load_provider_credentials, + load_volcengine_credentials, +) from veadk.integrations.mpa.managed.tasks import CreationTasks, TaskError, owner_key -def _studio_task_path() -> Path: +def _studio_task_path(provider="volcengine") -> Path: return Path( os.getenv( "VEADK_MPA_TASK_DB", - "/tmp/veadk-studio/mpa-creation.sqlite3", + "/tmp/veadk-studio/mpa-creation-byteplus.sqlite3" + if provider == "byteplus" + else "/tmp/veadk-studio/mpa-creation.sqlite3", ) ) @@ -63,7 +68,7 @@ class CreationRequest(BaseModel): agentId: str = Field(default="", pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$") name: str = "" description: str = Field(default="", max_length=512) - region: str = Field(pattern=r"^cn-[a-z]+$", max_length=32) + region: str = Field(pattern=r"^(cn-[a-z]+|ap-southeast-1)$", max_length=32) runtimeImage: str = Field(default="", max_length=1024) workerImage: str = Field(default="", max_length=1024) pgHost: str = Field(default="", max_length=255) @@ -95,26 +100,36 @@ def validate_resources(self): def mount_mpa_creation_routes( - app: FastAPI, *, owner, service: CreationTasks | None = None, supported=True + app: FastAPI, + *, + owner, + service: CreationTasks | None = None, + supported=True, + provider="volcengine", ): tasks = service def get_tasks(): nonlocal tasks if tasks is None: - task_path = _studio_task_path() + task_path = _studio_task_path(provider) tasks = CreationTasks(task_path) return tasks def profile(region): if not supported: - raise ConfigurationError("MPA creation requires the Volcengine provider") - result = load_studio_profile(region=region) + raise ConfigurationError("MPA creation is unavailable for this provider") + result = load_studio_profile(region=region, provider=provider) try: - load_volcengine_credentials(result.managed.credential_file) + if provider == "volcengine": + load_volcengine_credentials(result.managed.credential_file) + else: + load_provider_credentials(provider, result.managed.credential_file) except ValueError: raise ConfigurationError( - "Configure server deployment credentials" + "Configure server BytePlus deployment credentials (BYTEPLUS_ACCESS_KEY/BYTEPLUS_SECRET_KEY or VEADK_MPA_BYTEPLUS_CREDENTIAL_FILE)" + if provider == "byteplus" + else "Configure server deployment credentials" ) from None return None, result @@ -143,6 +158,8 @@ async def create(request: Request): try: path, config = profile(body.region) payload = body.model_dump(mode="json") + if provider == "byteplus": + payload["provider"] = provider if not payload["agentId"]: payload["agentId"] = ( "mi-" diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index fd6e951f7..5cc260ed5 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -6566,7 +6566,7 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{ export type ChannelScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; -export interface ChannelCapabilities { channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; } +export interface ChannelCapabilities { channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; multiBotChannels?: string[]; accountScopedPermissions?: boolean; } export interface FeishuBinding { id: string; status: "PENDING" | "SCANNED" | "AUTHORIZED" | "REGISTERING" | "BOUND" | "FAILED" | "EXPIRED"; @@ -6574,11 +6574,11 @@ export interface FeishuBinding { appId?: string | null; lastErrorCode?: string | null; lastErrorMessage?: string | null; } export interface ChannelDiagnostics { - configured: boolean; appId?: string | null; appName?: string | null; + configured: boolean; enabled?: boolean; appId?: string | null; appName?: string | null; gatewayConfigured: boolean; routeConfigured: boolean; missingConfiguration: string[]; deliveryHealth: string; lastInboundAt?: number | null; lastDeliveryAt?: number | null; } -export interface ChannelPermission { channel: string; chatId: string; chatName?: string; groupSessionScope?: ChannelScope | null; } +export interface ChannelPermission { appId?: string; channel: string; chatId: string; chatName?: string; groupSessionScope?: ChannelScope | null; } export class ChannelApiError extends Error { constructor(public status: number) { super(`Channel request failed (${status})`); } } diff --git a/frontend/src/adk/feishuAccounts.ts b/frontend/src/adk/feishuAccounts.ts new file mode 100644 index 000000000..0f3edf259 --- /dev/null +++ b/frontend/src/adk/feishuAccounts.ts @@ -0,0 +1,44 @@ +import { channelRequest, ChannelApiError, type AdkEndpoint } from "./client"; + +export interface FeishuAccount { + channel: "feishu"; + appId: string; + appName?: string; + enabled: boolean; +} + +export async function listFeishuAccounts( + ep: AdkEndpoint, + signal: AbortSignal, +): Promise { + const data = await channelRequest<{ channels: unknown[] }>(ep, "", { + signal, + }); + if (!data || !Array.isArray(data.channels)) throw new ChannelApiError(502); + const accounts: FeishuAccount[] = []; + const ids = new Set(); + for (const value of data.channels) { + if (!value || typeof value !== "object" || !("channel" in value)) + throw new ChannelApiError(502); + if (value.channel !== "feishu") continue; + if ( + !("appId" in value) || + typeof value.appId !== "string" || + !value.appId.trim() || + ids.has(value.appId) + ) + throw new ChannelApiError(502); + if (!("enabled" in value) || typeof value.enabled !== "boolean") + throw new ChannelApiError(502); + ids.add(value.appId); + accounts.push({ + channel: "feishu", + appId: value.appId, + enabled: value.enabled, + ...("appName" in value && typeof value.appName === "string" + ? { appName: value.appName } + : {}), + }); + } + return accounts; +} diff --git a/frontend/src/cronjobs/MpaCronTasks.tsx b/frontend/src/cronjobs/MpaCronTasks.tsx index 2eefbc13e..faae62731 100644 --- a/frontend/src/cronjobs/MpaCronTasks.tsx +++ b/frontend/src/cronjobs/MpaCronTasks.tsx @@ -560,6 +560,7 @@ function RuntimeTasks({ runtime }: { runtime: MpaRuntime }) { )} {editor && ( ([]); + const [multiBot, setMultiBot] = useState(false); + const [botsLoading, setBotsLoading] = useState(false); + const [botsError, setBotsError] = useState(""); + const [botsReload, setBotsReload] = useState(0); + useEffect(() => { + if (!runtime || channel !== "Feishu") { + setBotsLoading(false); + return; + } + const controller = new AbortController(); + const ep = { runtimeId: runtime.runtimeId, region: runtime.region }; + setBotsLoading(true); + setBotsError(""); + void (async () => { + let caps: ChannelCapabilities; + try { + caps = await channelRequest(ep, "/capabilities", { + signal: controller.signal, + }); + } catch (reason) { + if (reason instanceof ChannelApiError && reason.status === 404) { + if (!controller.signal.aborted) setMultiBot(false); + return; + } + throw reason; + } + if (controller.signal.aborted) return; + const multiple = !!caps.multiBotChannels?.includes("feishu"); + setMultiBot(multiple); + if (!multiple) return; + const accounts = await listFeishuAccounts(ep, controller.signal); + if (controller.signal.aborted) return; + setBots(accounts); + setBotId( + (current) => + current || + (accounts.length === 1 && accounts[0].enabled + ? accounts[0].appId + : ""), + ); + })() + .catch((reason) => { + if (!controller.signal.aborted) + setBotsError( + reason instanceof ChannelApiError && + [401, 403].includes(reason.status) + ? "botsUnauthorized" + : "botsFailed", + ); + }) + .finally(() => { + if (!controller.signal.aborted) setBotsLoading(false); + }); + return () => controller.abort(); + }, [runtime?.runtimeId, runtime?.region, channel, botsReload]); const [validation, setValidation] = useState(""); const composing = useRef(false); const nameRef = useRef(null); function save() { if (busy || composing.current) return; + if (channel === "Feishu" && runtime) { + if (botsLoading || botsError) return; + if (multiBot && !bots.some((bot) => bot.appId === botId && bot.enabled)) { + setValidation(label("selectBot")); + return; + } + } try { new Intl.DateTimeFormat("en-US", { timeZone: zone }); } catch { @@ -115,10 +188,15 @@ export function MpaTaskEditor({ } const delivery = task?.delivery?.channel === channel && - String(task.delivery.targetId || "") === target + String(task.delivery.targetId || "") === target && + (channel !== "Feishu" || + !multiBot || + String(task.delivery.appId || "") === botId) && + !(channel === "Web" && task.delivery.appId) ? task.delivery : { channel, + ...(channel === "Feishu" && multiBot ? { appId: botId } : {}), targetId: channel === "Web" ? null : target.trim(), receiveIdType: "chat_id", bestEffort: true, @@ -296,6 +374,48 @@ export function MpaTaskEditor({ onChange={setChannel} /> + {channel === "Feishu" && runtime && ( + <> + {botsLoading &&

{label("botsLoading")}

} + {botsError && ( +
+

{label(botsError)}

+ +
+ )} + {multiBot && ( +
+ {label("botAccount")} + ({ + value: account.appId, + label: `${account.appName || account.appId} | ${account.enabled ? t("channels.accountEnabled") : t("channels.accountDisabled")}`, + description: account.appName ? account.appId : undefined, + })), + ]} + onChange={(event) => { + setAdding(false); + setSelected(event.target.value); + }} + /> + + + {active && !adding && ( + + )} +
+ {!loading && !error && !accounts.length && ( +

{t("channels.noAccounts")}

+ )} + {!loading && !error && !adding && !selected && accounts.length > 0 && ( +

{t("channels.selectAccount")}

+ )} + {!loading && !error && (adding || active) && ( +
+ {adding && ( + + )} + {renderPanel({ + accountId: adding ? undefined : selected, + accountMode: adding ? "add" : "manage", + onBound: refresh, + onDeleted: () => refresh(""), + onBusyChange: setBusy, + })} +
+ )} + + ); +} diff --git a/frontend/src/ui/MyAgents.tsx b/frontend/src/ui/MyAgents.tsx index 74a2fd025..b1b366756 100644 --- a/frontend/src/ui/MyAgents.tsx +++ b/frontend/src/ui/MyAgents.tsx @@ -1214,7 +1214,7 @@ export function MyAgents({ let createAgent: (() => void) | undefined; if (activeType === "general" && canCreateRuntimeAgents) { createAgent = () => onCreateAgent(region, "general"); - } else if (activeType === "mpa" && canCreateRuntimeAgents && cloudProvider === "volcengine") { + } else if (activeType === "mpa" && canCreateRuntimeAgents) { createAgent = () => setMpaCreateRegion(region); } else if (isSandboxMyAgentType(activeType) && canCreatePersonalAgents) { createAgent = () => onCreateSandboxAgent(activeType); @@ -1407,7 +1407,7 @@ export function MyAgents({ )} - {mpaCreateRegion && setMpaCreateRegion(null)} onCreated={() => { invalidateRuntimeAgentCache(); diff --git a/frontend/src/ui/RuntimeChannels.css b/frontend/src/ui/RuntimeChannels.css index b640c8ae5..ac98478ab 100644 --- a/frontend/src/ui/RuntimeChannels.css +++ b/frontend/src/ui/RuntimeChannels.css @@ -302,3 +302,23 @@ width: 16px; height: 16px; } + +.runtime-channels-account-actions { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 12px; +} +.runtime-channels-account-select { + display: flex; + flex: 1 1 240px; + min-width: 0; + flex-direction: column; + gap: 8px; +} +.runtime-channels-accounts > div > .runtime-channels { + margin-top: 16px; +} +.runtime-channels-accounts .runtime-channels-diagnostics { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); +} diff --git a/frontend/src/ui/RuntimeChannels.tsx b/frontend/src/ui/RuntimeChannels.tsx index 016b12b60..71af49ec9 100644 --- a/frontend/src/ui/RuntimeChannels.tsx +++ b/frontend/src/ui/RuntimeChannels.tsx @@ -18,23 +18,30 @@ import { WecomAuthorizationError, } from "../adk/wecomAuthorization"; import { SourceCloseIcon } from "./icons/SourceWorkspaceIcons"; +import { FeishuAccounts, type FeishuAccountPanelProps } from "./FeishuAccounts"; import "./RuntimeChannels.css"; const providers = ["feishu", "wecom", "dingtalk"] as const; type Provider = (typeof providers)[number]; type ConfigurationMethod = "quick" | "manual"; -export function RuntimeChannels(props: { runtimeId: string; region: string; showHeader?: boolean }) { +export function RuntimeChannels(props: { + runtimeId: string; + region: string; + showHeader?: boolean; +}) { const { t } = useTranslation("ui"); const [provider, setProvider] = useState("feishu"); const id = useId(); const tabs = useRef>([]); return (
- {props.showHeader !== false &&
-

{t("channels.workspaceTitle")}

-

{t("channels.workspaceDescription")}

-
} + {props.showHeader !== false && ( +
+

{t("channels.workspaceTitle")}

+

{t("channels.workspaceDescription")}

+
+ )}
) { const { t } = useTranslation("ui"); const ep = useMemo(() => ({ runtimeId, region }), [runtimeId, region]); const methodId = useId(); @@ -170,7 +182,14 @@ function ProviderChannel({ const polling = useRef(null); const authorizationWaiting = useRef(false); const storageKey = `mpa-${provider}-binding:${region}:${runtimeId}`; - const fail = (value: unknown) => { + const multiBot = + provider === "feishu" && + (!!accountMode || !!capability?.multiBotChannels?.includes("feishu")); + useEffect(() => { + onBusyChange?.(busy); + return () => onBusyChange?.(false); + }, [busy, onBusyChange]); + const fail = (value: unknown, registration = false) => { if (!alive.current) return; if (value instanceof WecomAuthorizationError) { const key = @@ -186,22 +205,34 @@ function ProviderChannel({ return; } const status = value instanceof ChannelApiError ? value.status : 0; - if (status === 409) setUncertain(true); + if (status === 409 && (registration || !multiBot)) setUncertain(true); setError( t( - `channels.${status === 409 ? "uncertain" : status === 404 ? "unsupported" : status === 401 || status === 403 ? "unauthorized" : status === 503 ? "configurationError" : "requestFailed"}`, + `channels.${status === 409 ? (registration || !multiBot ? "uncertain" : "accountConflict") : status === 404 ? "unsupported" : status === 401 || status === 403 ? "unauthorized" : status === 503 ? "configurationError" : "requestFailed"}`, ), ); }; async function refresh(signal: AbortSignal) { + if (accountMode === "add") { + setDiagnostics(null); + setGroups([]); + return; + } + const accountQuery = accountId + ? `?appId=${encodeURIComponent(accountId)}` + : ""; const [diag, permissions] = await Promise.all([ - channelRequest(ep, `/${provider}/diagnostics`, { - signal, - }), + channelRequest( + ep, + `/${provider}/diagnostics${accountQuery}`, + { + signal, + }, + ), provider === "feishu" ? channelRequest<{ permissions: ChannelPermission[] }>( ep, - "/chat-permissions?channel=feishu", + `/chat-permissions?channel=feishu${accountId ? `&appId=${encodeURIComponent(accountId)}` : ""}`, { signal }, ) : Promise.resolve({ permissions: [] }), @@ -247,6 +278,12 @@ function ProviderChannel({ ) throw new ChannelApiError(404); setCapability(caps); + if ( + provider === "feishu" && + caps.multiBotChannels?.includes("feishu") && + !accountMode + ) + return; await refresh(controller.signal); } catch (value) { if (!controller.signal.aborted) fail(value); @@ -275,7 +312,10 @@ function ProviderChannel({ setBinding(value); if (value.lastErrorCode === "REGISTRATION_UNCERTAIN") setUncertain(true); - if (value.status === "BOUND") await refresh(controller.signal); + if (value.status === "BOUND") { + if (accountMode) onBound?.(value.appId || undefined); + else await refresh(controller.signal); + } }) .catch((value) => { if (!controller.signal.aborted) fail(value); @@ -289,7 +329,10 @@ function ProviderChannel({ }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [binding, ep, busy, reload, method]); - async function perform(action: (signal: AbortSignal) => Promise) { + async function perform( + action: (signal: AbortSignal) => Promise, + registration = false, + ) { if (operation.current) return; const controller = new AbortController(); operation.current = controller; @@ -299,7 +342,7 @@ function ProviderChannel({ try { await action(controller.signal); } catch (value) { - if (!controller.signal.aborted) fail(value); + if (!controller.signal.aborted) fail(value, registration); } finally { if (operation.current === controller) { operation.current = null; @@ -322,9 +365,12 @@ function ProviderChannel({ setBinding(value); if (value.lastErrorCode === "REGISTRATION_UNCERTAIN") setUncertain(true); - if (value.status === "BOUND") await refresh(signal); + if (value.status === "BOUND") { + if (accountMode) onBound?.(value.appId || undefined); + else await refresh(signal); + } } - }); + }, true); } function beginWecom() { void perform(async (signal) => { @@ -391,6 +437,22 @@ function ProviderChannel({ return undefined; } }; + if (multiBot && !accountMode && capability) + return ( + ( + + )} + /> + ); const wecomAuthorizationActions = provider === "wecom" && capability && ( <> - + > + + + + + )} + )} {uncertain &&

{t("channels.uncertain")}

}
{method === "quick" && wecomAuthorizationActions} - {method === "quick" && provider !== "wecom" && ( - - )} + {method === "quick" && + provider !== "wecom" && + accountMode !== "manage" && ( + + )} {diagnostics?.configured && (
)} - {provider === "feishu" && ( + {provider === "feishu" && accountMode !== "add" && (
{groups.map((group) => ( -
  • +
  • - {group.chatName || group.chatId} ·{" "} + {group.chatName || group.chatId} |{" "} {t( `channels.scope_${group.groupSessionScope || "group"}`, )} @@ -912,7 +989,7 @@ function ProviderChannel({ void perform(async (signal) => { await channelRequest( ep, - `/chat-permissions?channel=${provider}&chat_id=${encodeURIComponent(group.chatId)}`, + `/chat-permissions?channel=${provider}&chat_id=${encodeURIComponent(group.chatId)}${accountId ? `&appId=${encodeURIComponent(accountId)}` : ""}`, { method: "DELETE", signal }, ); await refresh(signal); diff --git a/frontend/src/ui/mpa-create/MpaCreateDialog.tsx b/frontend/src/ui/mpa-create/MpaCreateDialog.tsx index f9b9d55a7..9a4e7f0f7 100644 --- a/frontend/src/ui/mpa-create/MpaCreateDialog.tsx +++ b/frontend/src/ui/mpa-create/MpaCreateDialog.tsx @@ -17,6 +17,7 @@ import { import "../../components/composites/ModalButton/ModalButton.css"; import "./MpaCreateDialog.css"; import { runtimeNameProblem } from "../../create/runtimeName"; +import type { CloudProvider } from "../../adk/cloudProvider"; import { validOpenViking, validPgTarget } from "../../adk/mpaCreationResources"; const PG_CONSOLE_URL = @@ -71,10 +72,12 @@ function initial(region: string): { export function MpaCreateDialog({ region, + cloudProvider = "volcengine", onClose, onCreated, }: { region: string; + cloudProvider?: CloudProvider; onClose: () => void; onCreated: () => void; }) { @@ -455,11 +458,21 @@ export function MpaCreateDialog({ )}

    - {t(key("pgConsole"))} + {t( + key( + cloudProvider === "byteplus" + ? "pgConsoleByteplus" + : "pgConsole", + ), + )} {!autoPg && ( <> @@ -506,11 +519,21 @@ export function MpaCreateDialog({ <>

    {t(key("openvikingDescription"))}

    - {t(key("openvikingConsole"))} + {t( + key( + cloudProvider === "byteplus" + ? "openvikingConsoleByteplus" + : "openvikingConsole", + ), + )}