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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 28 additions & 11 deletions frontend/server/mpa_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
)

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

Expand Down Expand Up @@ -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-"
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/adk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6566,19 +6566,19 @@ 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";
loginUrl: string; qrCodeImage: string; expiresAt: string; pollAfterSeconds: number;
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})`); }
}
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/adk/feishuAccounts.ts
Original file line number Diff line number Diff line change
@@ -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<FeishuAccount[]> {
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<string>();
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;
}
1 change: 1 addition & 0 deletions frontend/src/cronjobs/MpaCronTasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ function RuntimeTasks({ runtime }: { runtime: MpaRuntime }) {
)}
{editor && (
<MpaTaskEditor
runtime={runtime}
task={editor.task}
copy={editor.copy}
busy={busy}
Expand Down
133 changes: 129 additions & 4 deletions frontend/src/cronjobs/MpaTaskEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DialogShell } from "../ui/SandboxControls";
import { Select } from "../components/primitives/Select/Select";
import { DeploymentSelect } from "../ui/DeploymentSelect";
import type { MpaCronTask, TaskFields } from "../adk/mpaCronTasks";
import {
channelRequest,
ChannelApiError,
type ChannelCapabilities,
} from "../adk/client";
import { listFeishuAccounts, type FeishuAccount } from "../adk/feishuAccounts";
import type { MpaRuntime, MpaCronTask, TaskFields } from "../adk/mpaCronTasks";
import { scheduleTypes } from "./mpaSchedule";
import { MpaIcon } from "./MpaTaskIcons";

export function MpaTaskEditor({
task,
runtime,
copy,
busy,
error,
onClose,
onSave,
}: {
task?: MpaCronTask;
runtime?: MpaRuntime;
copy: boolean;
busy: boolean;
error: string;
Expand Down Expand Up @@ -53,11 +62,75 @@ export function MpaTaskEditor({
String(task?.delivery?.channel || "Web"),
);
const [target, setTarget] = useState(String(task?.delivery?.targetId || ""));
const [botId, setBotId] = useState(String(task?.delivery?.appId || ""));
const [bots, setBots] = useState<FeishuAccount[]>([]);
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<ChannelCapabilities>(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<HTMLInputElement>(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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -296,6 +374,48 @@ export function MpaTaskEditor({
onChange={setChannel}
/>
</div>
{channel === "Feishu" && runtime && (
<>
{botsLoading && <p role="status">{label("botsLoading")}</p>}
{botsError && (
<div role="alert">
<p>{label(botsError)}</p>
<button
type="button"
className="cw-btn cw-btn-ghost"
disabled={busy || botsLoading}
onClick={() => setBotsReload((value) => value + 1)}
>
{label("retryBots")}
</button>
</div>
)}
{multiBot && (
<div>
<span>{label("botAccount")}</span>
<Select
aria-label={label("botAccount")}
value={botId}
disabled={busy || botsLoading || !!botsError}
options={[
{ value: "", label: label("selectBot") },
...bots.map((bot) => ({
value: bot.appId,
label: `${bot.appName || bot.appId} | ${bot.appId}${bot.enabled ? "" : ` | ${label("botDisabled")}`}`,
disabled: !bot.enabled,
})),
]}
onChange={(event) => setBotId(event.target.value)}
/>
{!botsLoading &&
!botsError &&
!bots.some(
(bot) => bot.appId === botId && bot.enabled,
) && <p role="status">{label("selectBot")}</p>}
</div>
)}
</>
)}
{channel === "Feishu" && (
<label>
{label("target")}
Expand Down Expand Up @@ -334,7 +454,12 @@ export function MpaTaskEditor({
</button>
<button
className="cw-btn cw-btn-primary is-primary"
disabled={busy}
disabled={
busy ||
(channel === "Feishu" &&
!!runtime &&
(botsLoading || !!botsError))
}
type="submit"
>
{label(busy ? "saving" : "save")}
Expand Down
Loading
Loading