-
openImage(item.path)}
- className="block w-full cursor-pointer"
- >
-
-
-
-
diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json
index 19502329..c9208541 100644
--- a/ui/web/src/i18n/locales/en/channels.json
+++ b/ui/web/src/i18n/locales/en/channels.json
@@ -317,7 +317,16 @@
"help": "Restrict which tools the agent can use in this group"
},
"system_prompt": { "label": "System Prompt" },
- "platform": { "label": "Platform", "help": "Select the platform this Pancake page serves." }
+ "platform": { "label": "Platform", "help": "Select the platform this Pancake page serves." },
+ "tiktok_type": { "label": "TikTok Type", "help": "Select the TikTok account type for this page" },
+ "features.private_reply": {
+ "label": "Private Reply (Comment → DM)",
+ "help": "Send a one-time DM to commenters after the public reply. Facebook/Instagram only. Meta allows DM within 7 days of the comment."
+ },
+ "private_reply_message": {
+ "label": "DM Message",
+ "help": "Supports the placeholders commenter_name and post_title. Empty = default English text."
+ }
},
"fieldOptions": {
"block_reply": {
diff --git a/ui/web/src/i18n/locales/en/chat.json b/ui/web/src/i18n/locales/en/chat.json
index c0a347e8..a8e3a9c3 100644
--- a/ui/web/src/i18n/locales/en/chat.json
+++ b/ui/web/src/i18n/locales/en/chat.json
@@ -29,5 +29,6 @@
"selectAgent": {
"title": "Select an agent",
"description": "Choose an agent to start chatting"
- }
+ },
+ "imageGenDownloadName": "generated-{{timestamp}}.png"
}
diff --git a/ui/web/src/i18n/locales/en/tools.json b/ui/web/src/i18n/locales/en/tools.json
index c59cf227..9173bc1a 100644
--- a/ui/web/src/i18n/locales/en/tools.json
+++ b/ui/web/src/i18n/locales/en/tools.json
@@ -232,7 +232,10 @@
"addProvider": "Add Provider",
"cancel": "Cancel",
"save": "Save",
- "saving": "Saving..."
+ "saving": "Saving...",
+ "imageModelLabel": "Image model",
+ "imageModelDefaultOption": "Default · gpt-image-2 (recommended)",
+ "imageModelLegacyOption": "Legacy · gpt-image-1.5"
},
"toast": {
"toggled": "Tool updated",
diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json
index 710b5b2b..77fbbaf4 100644
--- a/ui/web/src/i18n/locales/vi/channels.json
+++ b/ui/web/src/i18n/locales/vi/channels.json
@@ -244,7 +244,16 @@
"skills": { "label": "Bộ lọc skill", "help": "Giới hạn skill khả dụng cho nhóm này" },
"tools": { "label": "Danh sách công cụ được phép", "help": "Giới hạn công cụ agent có thể dùng trong nhóm này" },
"system_prompt": { "label": "Prompt hệ thống" },
- "platform": { "label": "Nền tảng", "help": "Chọn nền tảng mà trang Pancake này phục vụ." }
+ "platform": { "label": "Nền tảng", "help": "Chọn nền tảng mà trang Pancake này phục vụ." },
+ "tiktok_type": { "label": "Loại tài khoản TikTok", "help": "Chọn loại tài khoản TikTok cho trang này" },
+ "features.private_reply": {
+ "label": "Private Reply (Comment → DM)",
+ "help": "Gửi tin nhắn riêng cho người bình luận sau khi đã trả lời công khai. Chỉ Facebook/Instagram. Meta cho phép DM trong vòng 7 ngày kể từ khi có bình luận."
+ },
+ "private_reply_message": {
+ "label": "Nội dung DM",
+ "help": "Hỗ trợ biến commenter_name và post_title. Để trống dùng mặc định tiếng Anh."
+ }
},
"fieldOptions": {
"block_reply": {
diff --git a/ui/web/src/i18n/locales/vi/chat.json b/ui/web/src/i18n/locales/vi/chat.json
index c5c5ac90..b0c58e0d 100644
--- a/ui/web/src/i18n/locales/vi/chat.json
+++ b/ui/web/src/i18n/locales/vi/chat.json
@@ -29,5 +29,6 @@
"selectAgent": {
"title": "Chọn agent",
"description": "Chọn agent để bắt đầu trò chuyện"
- }
+ },
+ "imageGenDownloadName": "generated-{{timestamp}}.png"
}
diff --git a/ui/web/src/i18n/locales/vi/tools.json b/ui/web/src/i18n/locales/vi/tools.json
index 2c3fcd2f..3558d411 100644
--- a/ui/web/src/i18n/locales/vi/tools.json
+++ b/ui/web/src/i18n/locales/vi/tools.json
@@ -232,7 +232,10 @@
"addProvider": "Thêm provider",
"cancel": "Hủy",
"save": "Lưu",
- "saving": "Đang lưu..."
+ "saving": "Đang lưu...",
+ "imageModelLabel": "Mô hình ảnh",
+ "imageModelDefaultOption": "Mặc định · gpt-image-2 (khuyến nghị)",
+ "imageModelLegacyOption": "Cũ · gpt-image-1.5"
},
"toast": {
"toggled": "Đã cập nhật công cụ",
diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json
index 3315484c..ea5026de 100644
--- a/ui/web/src/i18n/locales/zh/channels.json
+++ b/ui/web/src/i18n/locales/zh/channels.json
@@ -244,7 +244,16 @@
"skills": { "label": "Skill过滤", "help": "限制此群组可用的Skill" },
"tools": { "label": "工具白名单", "help": "限制Agent在此群组中可使用的工具" },
"system_prompt": { "label": "系统提示词" },
- "platform": { "label": "平台", "help": "选择此 Pancake 页面所服务的平台。" }
+ "platform": { "label": "平台", "help": "选择此 Pancake 页面所服务的平台。" },
+ "tiktok_type": { "label": "TikTok 类型", "help": "选择此页面的 TikTok 账户类型" },
+ "features.private_reply": {
+ "label": "私信回复(评论 → 私信)",
+ "help": "在公开回复后向评论者发送一次性私信。仅支持 Facebook/Instagram,Meta 允许在评论后 7 天内发送。"
+ },
+ "private_reply_message": {
+ "label": "私信内容",
+ "help": "支持 commenter_name 和 post_title 占位符。留空使用英文默认文本。"
+ }
},
"fieldOptions": {
"block_reply": {
diff --git a/ui/web/src/i18n/locales/zh/chat.json b/ui/web/src/i18n/locales/zh/chat.json
index c16aa14c..ee00aecd 100644
--- a/ui/web/src/i18n/locales/zh/chat.json
+++ b/ui/web/src/i18n/locales/zh/chat.json
@@ -29,5 +29,6 @@
"selectAgent": {
"title": "选择Agent",
"description": "选择Agent开始对话"
- }
+ },
+ "imageGenDownloadName": "generated-{{timestamp}}.png"
}
diff --git a/ui/web/src/i18n/locales/zh/tools.json b/ui/web/src/i18n/locales/zh/tools.json
index 76520f89..fec84ae3 100644
--- a/ui/web/src/i18n/locales/zh/tools.json
+++ b/ui/web/src/i18n/locales/zh/tools.json
@@ -146,7 +146,10 @@
"selectModel": "选择模型",
"selectProvider": "选择Provider",
"settings": "设置",
- "timeout": "超时"
+ "timeout": "超时",
+ "imageModelLabel": "图像模型",
+ "imageModelDefaultOption": "默认 · gpt-image-2(推荐)",
+ "imageModelLegacyOption": "旧版 · gpt-image-1.5"
},
"noMatchDescription": "请尝试其他搜索词。",
"noMatchTitle": "未找到匹配工具",
diff --git a/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts b/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts
index 69d1dce5..a128833a 100644
--- a/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts
+++ b/ui/web/src/pages/agents/agent-detail/agent-display-utils.ts
@@ -27,6 +27,7 @@ export interface NormalizedChatGPTOAuthRouting {
overrideMode: ChatGPTOAuthRoutingOverrideMode;
strategy: EffectiveChatGPTOAuthRoutingStrategy;
extraProviderNames: string[];
+ hasExplicitExtraProviderNames: boolean;
}
export interface EffectiveChatGPTOAuthRouting {
@@ -73,8 +74,9 @@ export function normalizeChatGPTOAuthRouting(
return {
isExplicit: false,
overrideMode: "custom",
- strategy: "primary_first",
+ strategy: "priority_order",
extraProviderNames: [],
+ hasExplicitExtraProviderNames: false,
};
}
const routing = raw as Record
;
@@ -96,13 +98,14 @@ export function normalizeChatGPTOAuthRouting(
routing.override_mode === "custom" ||
hasStrategyField ||
hasExtraProviderField ||
- strategy !== "primary_first" ||
+ strategy !== "priority_order" ||
extraProviderNames.length > 0;
return {
isExplicit,
overrideMode,
strategy,
extraProviderNames,
+ hasExplicitExtraProviderNames: hasExtraProviderField,
};
}
@@ -111,7 +114,10 @@ export function hasActiveChatGPTOAuthRouting(
routing?: ChatGPTOAuthRoutingConfig | Record | null,
): boolean {
const normalized = normalizeChatGPTOAuthRouting(routing);
- return normalized.isExplicit && (normalized.strategy !== "primary_first" || normalized.extraProviderNames.length > 0);
+ return normalized.isExplicit && (
+ normalized.strategy === "round_robin" ||
+ normalized.extraProviderNames.length > 0
+ );
}
export function normalizeChatGPTOAuthRoutingInput(
@@ -121,8 +127,9 @@ export function normalizeChatGPTOAuthRoutingInput(
return {
isExplicit: false,
overrideMode: "custom",
- strategy: "primary_first",
+ strategy: "priority_order",
extraProviderNames: [],
+ hasExplicitExtraProviderNames: false,
};
}
return normalizeChatGPTOAuthRouting(routing);
@@ -139,8 +146,9 @@ export function resolveEffectiveChatGPTOAuthRouting(
({
isExplicit: false,
overrideMode: "custom",
- strategy: "primary_first",
+ strategy: "priority_order",
extraProviderNames: [],
+ hasExplicitExtraProviderNames: false,
} satisfies NormalizedChatGPTOAuthRouting);
let source: EffectiveChatGPTOAuthRouting["source"] = "single";
@@ -150,7 +158,7 @@ export function resolveEffectiveChatGPTOAuthRouting(
if (normalizedAgent.overrideMode === "inherit") {
source = providerDefaults ? "provider_default" : "single";
- strategy = providerDefaults?.strategy ?? "primary_first";
+ strategy = providerDefaults?.strategy ?? "priority_order";
extraProviderNames = providerDefaults?.extraProviderNames ?? [];
overrideMode = "inherit";
} else if (normalizedAgent.isExplicit) {
@@ -167,7 +175,7 @@ export function resolveEffectiveChatGPTOAuthRouting(
providerDefaults?.extraProviderNames.length &&
source === "agent_custom"
) {
- if (strategy === "primary_first" && extraProviderNames.length === 0) {
+ if (normalizedAgent.hasExplicitExtraProviderNames && extraProviderNames.length === 0) {
extraProviderNames = [];
} else {
extraProviderNames = providerDefaults.extraProviderNames;
@@ -190,8 +198,7 @@ export function strategyLabelKey(
strategy: EffectiveChatGPTOAuthRoutingStrategy,
): string {
if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin";
- if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder";
- return "chatgptOAuthRouting.strategy.primaryFirst";
+ return "chatgptOAuthRouting.strategy.priorityOrder";
}
/** Maps route readiness state to badge variant. */
@@ -252,18 +259,13 @@ export function buildAgentOtherConfigWithChatGPTOAuthRouting(
if (
providerDefaults ||
normalized.isExplicit ||
- normalized.strategy !== "primary_first" ||
normalized.extraProviderNames.length > 0
) {
const customRouting: Record = {
override_mode: "custom",
strategy: normalized.strategy,
};
- if (
- !providerDefaults ||
- (normalized.strategy === "primary_first" &&
- normalized.extraProviderNames.length === 0)
- ) {
+ if (normalized.hasExplicitExtraProviderNames || normalized.extraProviderNames.length > 0) {
customRouting.extra_provider_names = normalized.extraProviderNames;
}
result.chatgpt_oauth_routing = customRouting;
diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts b/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts
index be00fd0e..120006cd 100644
--- a/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts
+++ b/ui/web/src/pages/agents/agent-detail/codex-pool-routing-draft-utils.ts
@@ -8,16 +8,19 @@ export function buildDraftRouting(
savedRouting: NormalizedChatGPTOAuthRouting,
): ChatGPTOAuthRoutingConfig {
if (savedRouting.isExplicit) {
- return {
+ const draft: ChatGPTOAuthRoutingConfig = {
override_mode: savedRouting.overrideMode,
strategy: savedRouting.strategy,
- extra_provider_names: savedRouting.extraProviderNames,
};
+ if (savedRouting.hasExplicitExtraProviderNames || savedRouting.extraProviderNames.length > 0) {
+ draft.extra_provider_names = savedRouting.extraProviderNames;
+ }
+ return draft;
}
return {
override_mode: "inherit",
- strategy: "primary_first",
+ strategy: "priority_order",
extra_provider_names: [],
};
}
@@ -33,5 +36,6 @@ export function routingDraftSignature(
override_mode: "custom",
strategy: normalized.strategy,
extra_provider_names: normalized.extraProviderNames,
+ has_explicit_extra_provider_names: normalized.hasExplicitExtraProviderNames,
});
}
diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx
index 29bc7a74..b303754b 100644
--- a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx
+++ b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx
@@ -125,10 +125,17 @@ export function ChatGPTOAuthRoutingSection({
const blockedEntries = selectedEntries.filter((e) => e.routeReadiness === "blocked");
const routerActiveEntries = healthyEntries;
- const selectedStrategy: EffectiveChatGPTOAuthRoutingStrategy =
+ // When the agent inherits from the provider, paint the Traffic Policy
+ // buttons with the provider's effective strategy so the UI reflects what
+ // will actually run. Otherwise derive from the draft (custom override).
+ const draftStrategy: EffectiveChatGPTOAuthRoutingStrategy =
value.strategy === "round_robin" || value.strategy === "priority_order"
? value.strategy
- : "primary_first";
+ : "priority_order";
+ const selectedStrategy: EffectiveChatGPTOAuthRoutingStrategy =
+ mode === "inherit" && defaultRouting
+ ? defaultRouting.strategy
+ : draftStrategy;
const canEditMembership = canManageProviders && membershipEditable;
const canUsePoolStrategies =
canManageProviders &&
@@ -243,16 +250,7 @@ export function ChatGPTOAuthRoutingSection({
{t("chatgptOAuthRouting.strategyLabel")}
-
-
setStrategy("primary_first")}
- disabled={!canManageProviders || mode === "inherit"}
- className="h-9 text-xs sm:text-sm [@media(max-height:760px)]:h-8"
- >
- {t("chatgptOAuthRouting.strategy.primaryFirst")}
-
+
p.enabled);
+ const enabledProviders = useMemo(() => providers.filter((p) => p.enabled), [providers]);
const portalRef = useRef(null);
const [entries, setEntries] = useState(() =>
diff --git a/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts b/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts
index 59d7d1f2..12de3437 100644
--- a/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts
+++ b/ui/web/src/pages/builtin-tools/media-provider-chain-helpers.ts
@@ -1,5 +1,8 @@
import { uniqueId } from "@/lib/utils";
+import { getChatGPTOAuthPoolOwnership } from "@/pages/providers/provider-utils";
+import type { ProviderData } from "@/types/provider";
import { MEDIA_PARAMS_SCHEMA } from "./media-provider-params-schema";
+
export interface ProviderEntry {
id: string;
provider_id: string;
@@ -30,25 +33,45 @@ export function buildDefaultParams(toolName: string, providerType: string): Reco
return defaults;
}
-/** Parse stored settings into ProviderEntry list, filtering out unavailable providers. */
+/**
+ * Parse stored settings into ProviderEntry list, filtering out unavailable providers.
+ *
+ * Also migrates legacy entries that point directly at a Codex pool *member*
+ * (which the UI no longer lets users pick — the dropdown only shows owners).
+ * Such an entry is silently rewritten to its pool owner so the dropdown
+ * displays a matching option and runtime routing actually goes through the
+ * pool's strategy instead of a bare solo call to the member.
+ */
export function parseInitialEntries(
settings: Record,
- providers: Array<{ id: string; name: string }>,
+ providers: ProviderData[],
): ProviderEntry[] {
+ const ownership = getChatGPTOAuthPoolOwnership(providers);
+ const providersByName = new Map(providers.map((p) => [p.name, p]));
+
+ const resolveToOwner = (name: string, providerId: string): { name: string; id: string } => {
+ const ownerName = ownership.ownerByMember.get(name);
+ if (!ownerName) return { name, id: providerId };
+ const owner = providersByName.get(ownerName);
+ if (!owner) return { name, id: providerId };
+ return { name: ownerName, id: owner.id };
+ };
+
// New format: { providers: [...] }
if (Array.isArray(settings.providers)) {
return (settings.providers as Record[])
.map((p) => {
- const name = String(p.provider ?? "");
- const pid = String(p.provider_id ?? "");
- const resolved = (pid && providers.some((pr) => pr.id === pid))
- ? pid
- : providers.find((pr) => pr.name === name)?.id ?? "";
- if (!resolved) return null;
+ const rawName = String(p.provider ?? "");
+ const rawPid = String(p.provider_id ?? "");
+ const initialPid = (rawPid && providers.some((pr) => pr.id === rawPid))
+ ? rawPid
+ : providers.find((pr) => pr.name === rawName)?.id ?? "";
+ if (!initialPid) return null;
+ const migrated = resolveToOwner(rawName, initialPid);
return {
id: uniqueId(),
- provider_id: resolved,
- provider: name,
+ provider_id: migrated.id,
+ provider: migrated.name,
model: String(p.model ?? ""),
enabled: Boolean(p.enabled ?? true),
timeout: Number(p.timeout ?? 120),
@@ -64,11 +87,12 @@ export function parseInitialEntries(
const providerName = String(settings.provider ?? "");
const providerData = providers.find((p) => p.name === providerName);
if (providerData) {
+ const migrated = resolveToOwner(providerName, providerData.id);
return [
{
id: uniqueId(),
- provider_id: providerData.id,
- provider: providerName,
+ provider_id: migrated.id,
+ provider: migrated.name,
model: String(settings.model ?? ""),
enabled: true,
timeout: 120,
diff --git a/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts b/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts
index 492c807d..a6a42088 100644
--- a/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts
+++ b/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts
@@ -12,6 +12,18 @@ export type ParamField = {
export const MEDIA_PARAMS_SCHEMA: Record> = {
create_image: {
+ chatgpt_oauth: [
+ {
+ key: "image_model",
+ label: "Image model",
+ type: "select",
+ default: "gpt-image-2",
+ options: [
+ { value: "gpt-image-2", label: "Default · gpt-image-2 (recommended)" },
+ { value: "gpt-image-1.5", label: "Legacy · gpt-image-1.5" },
+ ],
+ },
+ ],
minimax_native: [],
bailian: [
{
diff --git a/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx b/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx
index b24c9398..10f98024 100644
--- a/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx
+++ b/ui/web/src/pages/builtin-tools/media-sortable-provider-card.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -17,6 +17,7 @@ import {
import { Combobox } from "@/components/ui/combobox";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
+import { getChatGPTOAuthPoolOwnership } from "@/pages/providers/provider-utils";
import { MEDIA_PARAMS_SCHEMA } from "./media-provider-params-schema";
import { ParamFieldControl } from "./media-param-field-control";
import { buildDefaultParams } from "./media-provider-chain-helpers";
@@ -55,6 +56,18 @@ export function SortableProviderCard({
opacity: isDragging ? 0.5 : 1,
};
+ // Pool-aware dropdown: hide pool members so the user can only pick pool
+ // owners (or standalone providers). Mirrors the Create Agent dropdown
+ // pattern (`components/shared/provider-model-select.tsx`).
+ const poolOwnership = useMemo(
+ () => getChatGPTOAuthPoolOwnership(enabledProviders),
+ [enabledProviders],
+ );
+ const dropdownProviders = useMemo(
+ () => enabledProviders.filter((p) => !poolOwnership.ownerByMember.has(p.name)),
+ [enabledProviders, poolOwnership],
+ );
+
const selectedProvider = enabledProviders.find((p) => p.id === entry.provider_id);
const { models, loading: modelsLoading } = useProviderModels(
entry.provider_id || undefined,
@@ -128,9 +141,16 @@ export function SortableProviderCard({
- {enabledProviders.map((p) => (
+ {dropdownProviders.map((p) => (
- {p.display_name || p.name}
+
+ {p.display_name || p.name}
+ {poolOwnership.membersByOwner.has(p.name) && (
+
+ {t("providers:list.poolBadge")}
+
+ )}
+
))}
diff --git a/ui/web/src/pages/channels/channel-schemas.test.ts b/ui/web/src/pages/channels/channel-schemas.test.ts
index bf1b90c6..6ef16a5a 100644
--- a/ui/web/src/pages/channels/channel-schemas.test.ts
+++ b/ui/web/src/pages/channels/channel-schemas.test.ts
@@ -41,6 +41,37 @@ describe("pancake configSchema", () => {
expect(values).not.toContain("zalo_oa");
});
+ it("exposes private_reply feature toggle gated on fb/ig only", () => {
+ const feat = pancakeConfig.find((f) => f.key === "features.private_reply");
+ expect(feat).toBeDefined();
+ expect(feat!.type).toBe("boolean");
+ expect(feat!.defaultValue).toBe(false);
+ expect(feat!.showWhen).toMatchObject({
+ key: "platform",
+ value: ["facebook", "instagram"],
+ });
+ });
+
+ it("exposes private_reply_message gated by the feature toggle", () => {
+ const msg = pancakeConfig.find((f) => f.key === "private_reply_message");
+ expect(msg).toBeDefined();
+ expect(msg!.type).toBe("textarea");
+ expect(msg!.showWhen).toEqual({ key: "features.private_reply", value: "true" });
+ });
+
+ it("does NOT expose removed private_reply config fields", () => {
+ const removed = [
+ "private_reply_mode",
+ "private_reply_only",
+ "private_reply_ttl_days",
+ "private_reply_options.allow_post_ids",
+ "private_reply_options.deny_post_ids",
+ ];
+ for (const key of removed) {
+ expect(pancakeConfig.find((f) => f.key === key), `field ${key} should be removed`).toBeUndefined();
+ }
+ });
+
it("has features.auto_react boolean toggle gated on platform=facebook", () => {
const f = pancakeConfig.find((x) => x.key === "features.auto_react");
expect(f).toBeDefined();
diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts
index fc3cd00d..65b685b6 100644
--- a/ui/web/src/pages/channels/channel-schemas.ts
+++ b/ui/web/src/pages/channels/channel-schemas.ts
@@ -99,6 +99,12 @@ const pancakePlatformOptions = [
{ value: "tokopedia", label: "Tokopedia" },
];
+const tiktokTypeOptions = [
+ { value: "livestream", label: "Livestream AIO" },
+ { value: "messaging", label: "Business Messaging" },
+ { value: "shop", label: "TikTok Shop" },
+];
+
// --- Config schemas ---
export const configSchema: Record = {
@@ -198,9 +204,17 @@ export const configSchema: Record = {
{ key: "page_id", label: "Page ID", type: "text", required: true, help: "Pancake internal page ID (numeric, from Pancake dashboard)" },
{ key: "webhook_page_id", label: "Webhook Page ID", type: "text", help: "Only needed when the native platform page ID in webhooks differs from the Pancake page ID above (rare). Leave empty if both are the same.", advanced: true },
{ key: "platform", label: "Platform", type: "select", required: true, defaultValue: "", options: pancakePlatformOptions, help: "Select the platform this Pancake page serves." },
+ { key: "tiktok_type", label: "TikTok Type", type: "select", options: tiktokTypeOptions, showWhen: { key: "platform", value: "tiktok" }, help: "Select the TikTok account type for this page" },
{ key: "features.inbox_reply", label: "Inbox Auto-Reply", type: "boolean", defaultValue: true },
{ key: "features.comment_reply", label: "Comment Reply", type: "boolean", defaultValue: false,
showWhen: { key: "platform", value: ["facebook", "instagram", "threads", "tiktok", "youtube"] } },
+ { key: "features.private_reply", label: "Private Reply (Comment → DM)", type: "boolean", defaultValue: false,
+ help: "Send a one-time DM to commenters after the public reply. Facebook/Instagram only. Meta allows DM within 7 days of the comment.",
+ showWhen: { key: "platform", value: ["facebook", "instagram"] } },
+ { key: "private_reply_message", label: "DM Message", type: "textarea",
+ help: "Supports {{commenter_name}} and {{post_title}}. Empty = default English text.",
+ placeholder: "Hi {{commenter_name}}! Thanks for commenting on \"{{post_title}}\". How can I help?",
+ showWhen: { key: "features.private_reply", value: "true" } },
{ key: "features.auto_react", label: "Auto-React (Like) Comments", type: "boolean",
defaultValue: false,
showWhen: { key: "platform", value: "facebook" },
diff --git a/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts b/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts
index b257c7c5..b321d100 100644
--- a/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts
+++ b/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts
@@ -23,7 +23,7 @@ interface ProviderCodexPoolActivityResponse {
}
const EMPTY: ProviderCodexPoolActivityResponse = {
- strategy: "primary_first",
+ strategy: "priority_order",
pool_providers: [],
stats_sample_size: 0,
provider_counts: [],
diff --git a/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx b/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx
index 186c9ddf..6f3382f5 100644
--- a/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx
+++ b/ui/web/src/pages/providers/pool-setup-wizard-dialog.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, useCallback } from "react";
+import { useState, useMemo, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -26,7 +26,6 @@ interface PoolSetupWizardDialogProps {
}
const STRATEGIES: { value: EffectiveChatGPTOAuthRoutingStrategy; labelKey: string }[] = [
- { value: "primary_first", labelKey: "list.strategy.primaryFirst" },
{ value: "round_robin", labelKey: "list.strategy.roundRobin" },
{ value: "priority_order", labelKey: "list.strategy.priorityOrder" },
];
@@ -43,9 +42,17 @@ export function PoolSetupWizardDialog({
const [selectedMemberIds, setSelectedMemberIds] = useState>(
() => new Set(unpooledProviders.slice(1).map((p) => p.id)),
);
- const [strategy, setStrategy] = useState("primary_first");
+ const [strategy, setStrategy] = useState("priority_order");
const [saving, setSaving] = useState(false);
+ useEffect(() => {
+ if (!open) return;
+ const nextOwnerId = unpooledProviders[0]?.id ?? "";
+ setOwnerId(nextOwnerId);
+ setSelectedMemberIds(new Set(unpooledProviders.slice(1).map((provider) => provider.id)));
+ setStrategy("priority_order");
+ }, [open, unpooledProviders]);
+
// When owner changes, auto-exclude owner from members
const handleOwnerChange = useCallback((id: string) => {
setOwnerId(id);
diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts b/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts
index 8171fd0e..9645cb78 100644
--- a/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts
+++ b/ui/web/src/pages/providers/provider-detail/provider-overview-helpers.ts
@@ -37,7 +37,7 @@ export function routingSignature(routing: ChatGPTOAuthRoutingConfig): string {
const strategy =
routing.strategy === "round_robin" || routing.strategy === "priority_order"
? routing.strategy
- : "primary_first";
+ : "priority_order";
return JSON.stringify({ strategy, extra_provider_names: extras });
}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx
index da71bdf0..ddf1ebac 100644
--- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx
+++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx
@@ -98,7 +98,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
const [apiKey, setApiKey] = useState(provider.api_key || "");
const [enabled, setEnabled] = useState(provider.enabled);
const [poolRouting, setPoolRouting] = useState({
- strategy: initialRouting?.strategy ?? "primary_first",
+ strategy: initialRouting?.strategy ?? "priority_order",
extra_provider_names: initialRouting?.extraProviderNames ?? [],
});
const initEmb = getEmbeddingSettings(provider.settings);
@@ -127,7 +127,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
const savedReasoningSig = useMemo(() => reasoningSignature(initialReasoningDefaults?.effort ?? "off", initialReasoningDefaults?.fallback ?? "downgrade"), [initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback]);
const draftReasoningSig = useMemo(() => reasoningSignature(reasoningExpert ? reasoningEffort : reasoningThinkingLevel, reasoningExpert ? reasoningFallback : "downgrade"), [reasoningEffort, reasoningExpert, reasoningFallback, reasoningThinkingLevel]);
const savedFormSig = useMemo(
- () => providerFormSignature({ displayName: provider.display_name || "", apiKey: provider.api_key || "", savedAPIKey: provider.api_key || "", showApiKey, enabled: provider.enabled, embEnabled: initEmb?.enabled ?? false, embModel: initEmb?.model ?? "", embApiBase: initEmb?.api_base ?? "", routing: { strategy: initialRouting?.strategy ?? "primary_first", extra_provider_names: initialRouting?.extraProviderNames ?? [] }, reasoningEffort: initialReasoningDefaults?.effort ?? "off", reasoningFallback: initialReasoningDefaults?.fallback ?? "downgrade", isOAuth }),
+ () => providerFormSignature({ displayName: provider.display_name || "", apiKey: provider.api_key || "", savedAPIKey: provider.api_key || "", showApiKey, enabled: provider.enabled, embEnabled: initEmb?.enabled ?? false, embModel: initEmb?.model ?? "", embApiBase: initEmb?.api_base ?? "", routing: { strategy: initialRouting?.strategy ?? "priority_order", extra_provider_names: initialRouting?.extraProviderNames ?? [] }, reasoningEffort: initialReasoningDefaults?.effort ?? "off", reasoningFallback: initialReasoningDefaults?.fallback ?? "downgrade", isOAuth }),
[initEmb?.api_base, initEmb?.enabled, initEmb?.model, initialReasoningDefaults?.effort, initialReasoningDefaults?.fallback, initialRouting?.extraProviderNames, initialRouting?.strategy, isOAuth, provider.api_key, provider.display_name, provider.enabled, showApiKey],
);
const savedFormSigRef = useRef(savedFormSig);
@@ -144,7 +144,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
const reasoning = getProviderReasoningDefaults(provider.settings) ?? providerReasoningDefaults;
const syncFromProvider = () => {
setEmbEnabled(es?.enabled ?? false); setEmbModel(es?.model ?? ""); setEmbApiBase(es?.api_base ?? "");
- setPoolRouting({ strategy: routing?.strategy ?? "primary_first", extra_provider_names: routing?.extraProviderNames ?? [] });
+ setPoolRouting({ strategy: routing?.strategy ?? "priority_order", extra_provider_names: routing?.extraProviderNames ?? [] });
const nextEffort = reasoning?.effort ?? "off"; const nextFallback = reasoning?.fallback ?? "downgrade";
setReasoningEffort(nextEffort); setReasoningFallback(nextFallback);
setReasoningThinkingLevel(deriveLegacyThinkingLevel(nextEffort));
@@ -211,7 +211,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
|| enabled !== provider.enabled
|| (showApiKey && comparableAPIKeyValue(apiKey, provider.api_key || "", showApiKey) !== "")
|| embEnabled !== (initEmb?.enabled ?? false) || embModel !== (initEmb?.model ?? "") || embApiBase !== (initEmb?.api_base ?? "")
- || (isOAuth && routingSignature(poolRouting) !== routingSignature({ strategy: initialRouting?.strategy ?? "primary_first", extra_provider_names: initialRouting?.extraProviderNames ?? [] }))
+ || (isOAuth && routingSignature(poolRouting) !== routingSignature({ strategy: initialRouting?.strategy ?? "priority_order", extra_provider_names: initialRouting?.extraProviderNames ?? [] }))
|| draftReasoningSig !== savedReasoningSig;
return (
diff --git a/ui/web/src/pages/providers/provider-list-row.tsx b/ui/web/src/pages/providers/provider-list-row.tsx
index f09a4fd9..c4187b7b 100644
--- a/ui/web/src/pages/providers/provider-list-row.tsx
+++ b/ui/web/src/pages/providers/provider-list-row.tsx
@@ -33,8 +33,7 @@ interface ProviderListRowProps {
function strategyLabelKey(strategy: EffectiveChatGPTOAuthRoutingStrategy): string {
if (strategy === "round_robin") return "list.strategy.roundRobin";
- if (strategy === "priority_order") return "list.strategy.priorityOrder";
- return "list.strategy.primaryFirst";
+ return "list.strategy.priorityOrder";
}
export function ProviderListRow({
diff --git a/ui/web/src/pages/providers/provider-utils.tsx b/ui/web/src/pages/providers/provider-utils.tsx
index 98d818ce..f3365884 100644
--- a/ui/web/src/pages/providers/provider-utils.tsx
+++ b/ui/web/src/pages/providers/provider-utils.tsx
@@ -31,19 +31,29 @@ export interface ChatGPTOAuthPoolOwnership {
export function getChatGPTOAuthPoolOwnership(
providers: ProviderData[],
+ options?: { enabledOnly?: boolean },
): ChatGPTOAuthPoolOwnership {
const membersByOwner = new Map();
const ownerByMember = new Map();
const strategyByOwner = new Map();
+ const enabledOnly = options?.enabledOnly ?? false;
+ const eligibleProviders = enabledOnly
+ ? providers.filter((provider) => provider.provider_type === "chatgpt_oauth" && provider.enabled)
+ : providers.filter((provider) => provider.provider_type === "chatgpt_oauth");
+ const eligibleProvidersByName = new Map(
+ eligibleProviders.map((provider) => [provider.name, provider]),
+ );
for (const provider of providers) {
if (provider.provider_type !== "chatgpt_oauth") continue;
+ if (enabledOnly && !provider.enabled) continue;
const routing = getChatGPTOAuthProviderRouting(provider.settings);
if (!routing) continue;
strategyByOwner.set(provider.name, routing.strategy);
- if (routing.extraProviderNames.length === 0) continue;
- membersByOwner.set(provider.name, routing.extraProviderNames);
- for (const memberName of routing.extraProviderNames) {
+ const eligibleMembers = routing.extraProviderNames.filter((memberName) => eligibleProvidersByName.has(memberName));
+ if (eligibleMembers.length === 0) continue;
+ membersByOwner.set(provider.name, eligibleMembers);
+ for (const memberName of eligibleMembers) {
if (!ownerByMember.has(memberName)) {
ownerByMember.set(memberName, provider.name);
}
diff --git a/ui/web/src/pages/providers/providers-page.tsx b/ui/web/src/pages/providers/providers-page.tsx
index 2c0ad489..a179bb93 100644
--- a/ui/web/src/pages/providers/providers-page.tsx
+++ b/ui/web/src/pages/providers/providers-page.tsx
@@ -69,6 +69,10 @@ function ProviderListView() {
() => getChatGPTOAuthPoolOwnership(providers),
[providers],
);
+ const selectablePoolOwnership = useMemo(
+ () => getChatGPTOAuthPoolOwnership(providers, { enabledOnly: true }),
+ [providers],
+ );
const oauthAvailabilityByName = useMemo(
() => new Map(statuses.map((status) => [status.provider.name, status.availability])),
[statuses],
@@ -78,10 +82,11 @@ function ProviderListView() {
() => providers.filter(
(p) =>
p.provider_type === "chatgpt_oauth" &&
- !poolOwnership.membersByOwner.has(p.name) &&
- !poolOwnership.ownerByMember.has(p.name),
+ p.enabled &&
+ !selectablePoolOwnership.membersByOwner.has(p.name) &&
+ !selectablePoolOwnership.ownerByMember.has(p.name),
),
- [providers, poolOwnership],
+ [providers, selectablePoolOwnership],
);
const filtered = useMemo(() => providers.filter(
@@ -207,7 +212,7 @@ function ProviderListView() {
return owner?.display_name || owner?.name || ownerName;
})(),
memberCount: poolOwnership.membersByOwner.get(p.name)?.length ?? 0,
- strategy: poolOwnership.strategyByOwner.get(p.name) ?? "primary_first",
+ strategy: poolOwnership.strategyByOwner.get(p.name) ?? "priority_order",
connectorPosition: memberConnectorByName.get(p.name) ?? "none",
quota: quotaByName.get(p.name),
quotaLoading: oauthAvailabilityByName.get(p.name) === "ready"
@@ -216,8 +221,9 @@ function ProviderListView() {
} : undefined}
showPoolHint={
p.provider_type === "chatgpt_oauth" &&
- !poolOwnership.ownerByMember.has(p.name) &&
- !poolOwnership.membersByOwner.has(p.name) &&
+ p.enabled &&
+ !selectablePoolOwnership.ownerByMember.has(p.name) &&
+ !selectablePoolOwnership.membersByOwner.has(p.name) &&
unpooledProviders.length >= 2
}
onClick={() => navigate(`/providers/${p.id}`)}
diff --git a/ui/web/src/pages/tts/sections/behavior-section.tsx b/ui/web/src/pages/tts/sections/behavior-section.tsx
index 3e7e8879..4c59aaf7 100644
--- a/ui/web/src/pages/tts/sections/behavior-section.tsx
+++ b/ui/web/src/pages/tts/sections/behavior-section.tsx
@@ -114,6 +114,7 @@ export function BehaviorSection({ draft, onUpdate }: Props) {
value={draft.timeout_ms}
onChange={(e) => onUpdate({ timeout_ms: Number(e.target.value) })}
min={1000}
+ max={300000}
/>
diff --git a/ui/web/src/types/agent.ts b/ui/web/src/types/agent.ts
index 4b65296a..5061ba60 100644
--- a/ui/web/src/types/agent.ts
+++ b/ui/web/src/types/agent.ts
@@ -95,13 +95,10 @@ export interface WorkspaceSharingConfig {
}
export type ChatGPTOAuthRoutingStrategy =
- | "manual"
- | "primary_first"
| "round_robin"
| "priority_order";
export type EffectiveChatGPTOAuthRoutingStrategy =
- | "primary_first"
| "round_robin"
| "priority_order";
diff --git a/ui/web/src/types/chat.ts b/ui/web/src/types/chat.ts
index 9873aecb..dfdedb89 100644
--- a/ui/web/src/types/chat.ts
+++ b/ui/web/src/types/chat.ts
@@ -33,6 +33,8 @@ export interface MediaItem {
fileName?: string;
size?: number;
kind: "image" | "video" | "audio" | "document" | "code";
+ /** Prompt that generated this asset (create_image, native Codex image_generation_call). */
+ prompt?: string;
}
/** Extended message with UI-specific fields */
diff --git a/ui/web/src/types/provider.ts b/ui/web/src/types/provider.ts
index 9d0d8904..bee56c38 100644
--- a/ui/web/src/types/provider.ts
+++ b/ui/web/src/types/provider.ts
@@ -81,8 +81,7 @@ export function normalizeChatGPTOAuthStrategy(
strategy: unknown,
): EffectiveChatGPTOAuthRoutingStrategy {
if (strategy === "round_robin") return "round_robin";
- if (strategy === "priority_order") return "priority_order";
- return "primary_first";
+ return "priority_order";
}
export function normalizeReasoningEffort(value: unknown): string {
@@ -140,7 +139,7 @@ export function getChatGPTOAuthProviderRouting(
const pool = rawPool as Record;
const strategy = normalizeChatGPTOAuthStrategy(pool.strategy);
const extraProviderNames = normalizeProviderNames(pool.extra_provider_names);
- if (strategy === "primary_first" && extraProviderNames.length === 0) {
+ if (strategy === "priority_order" && extraProviderNames.length === 0) {
return null;
}
return {
@@ -158,7 +157,7 @@ export function buildProviderSettingsWithChatGPTOAuthRouting(
const extraProviderNames = normalizeProviderNames(routing.extra_provider_names);
delete next.codex_pool;
- if (strategy !== "primary_first" || extraProviderNames.length > 0) {
+ if (extraProviderNames.length > 0) {
next.codex_pool = {
strategy,
extra_provider_names: extraProviderNames,
diff --git a/ui/web/src/types/session.ts b/ui/web/src/types/session.ts
index f043ab22..53cc2517 100644
--- a/ui/web/src/types/session.ts
+++ b/ui/web/src/types/session.ts
@@ -33,7 +33,7 @@ export interface Message {
tool_calls?: ToolCall[];
tool_call_id?: string;
is_error?: boolean;
- media_refs?: { id: string; mime_type: string; kind: string; path?: string }[];
+ media_refs?: { id: string; mime_type: string; kind: string; path?: string; prompt?: string }[];
created_at?: string; // ISO 8601 timestamp from server; absent for older messages
}