diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-config.ts b/ui/web/src/pages/channels/channel-detail/channel-advanced-config.ts new file mode 100644 index 00000000..f6a30691 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-config.ts @@ -0,0 +1,36 @@ +import { flattenConfig, unflattenConfig } from "@/lib/config-flatten"; +import { normalizeReasoningDeliveryConfig } from "../reasoning-delivery-config"; + +export const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]); + +function isAdvancedConfigKey(key: string): boolean { + return !ESSENTIAL_CONFIG_KEYS.has(key) && key !== "groups" && !key.startsWith("groups."); +} + +export function deriveAdvancedInitialValues(config: Record | null | undefined): Record { + const normalized = normalizeReasoningDeliveryConfig({ ...((config ?? {}) as Record) }); + const flat = flattenConfig(normalized); + // Only keep advanced keys (exclude essential + group overrides). + return Object.fromEntries( + Object.entries(flat).filter(([k]) => isAdvancedConfigKey(k)), + ); +} + +export function buildAdvancedConfigUpdate( + existingConfig: Record | null | undefined, + values: Record, +): Record { + const normalized = normalizeReasoningDeliveryConfig({ ...((existingConfig ?? {}) as Record) }); + const flat = flattenConfig(normalized); + + for (const [key, value] of Object.entries(values)) { + if (!isAdvancedConfigKey(key)) continue; + if (value === undefined || value === "" || value === null) { + delete flat[key]; + continue; + } + flat[key] = value; + } + + return normalizeReasoningDeliveryConfig(unflattenConfig(flat)); +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts new file mode 100644 index 00000000..7b6068a5 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { buildAdvancedConfigUpdate, deriveAdvancedInitialValues } from "./channel-advanced-config"; + +describe("channel advanced config payload", () => { + it("removes a stale allow_from entry when the allowed users picker is cleared", () => { + const existing = { + dm_policy: "pairing", + group_policy: "open", + allow_from: ["195835936795841454"], + groups: { + "-100123": { allow_from: ["group-user"] }, + }, + }; + + const values = deriveAdvancedInitialValues(existing); + const next = buildAdvancedConfigUpdate(existing, { ...values, allow_from: undefined }); + + expect(next).not.toHaveProperty("allow_from"); + expect(next.dm_policy).toBe("pairing"); + expect(next.group_policy).toBe("open"); + expect(next.groups).toEqual(existing.groups); + }); + + it("persists an explicit empty allow_from array when a tags field emits one", () => { + const existing = { + allow_from: ["195835936795841454"], + proxy: "http://localhost:8080", + }; + + const next = buildAdvancedConfigUpdate(existing, { + allow_from: [], + proxy: "", + }); + + expect(next.allow_from).toEqual([]); + expect(next).not.toHaveProperty("proxy"); + }); + + it("flattens nested delivery config for editing and writes it back nested", () => { + const existing = { + chat_behavior: { + final_split: { + enabled: "inherit", + }, + }, + }; + + const values = deriveAdvancedInitialValues(existing); + expect(values["chat_behavior.final_split.enabled"]).toBe("inherit"); + + const next = buildAdvancedConfigUpdate(existing, { + ...values, + "chat_behavior.final_split.enabled": "off", + }); + + expect(next).toEqual({ + chat_behavior: { + final_split: { + enabled: "off", + }, + }, + }); + }); +}); diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx index 66f910d6..f0b44a4e 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -11,7 +11,11 @@ import { import { ConfigGroupHeader } from "@/components/shared/config-group-header"; import { ChannelFields } from "../channel-fields"; import { configSchema } from "../channel-schemas"; -import { normalizeReasoningDeliveryConfig } from "../reasoning-delivery-config"; +import { + buildAdvancedConfigUpdate, + deriveAdvancedInitialValues, + ESSENTIAL_CONFIG_KEYS, +} from "./channel-advanced-config"; import type { ChannelInstanceData } from "@/types/channel"; interface ChannelAdvancedDialogProps { @@ -21,8 +25,6 @@ interface ChannelAdvancedDialogProps { onUpdate: (updates: Record) => Promise; } -const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]); - const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]); const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]); const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_delivery", "native_stream", "debounce_delay", "thread_ttl"]); @@ -41,14 +43,6 @@ function getAdvancedFields(channelType: string) { }; } -function deriveInitialValues(instance: ChannelInstanceData): Record { - const config = normalizeReasoningDeliveryConfig((instance.config ?? {}) as Record); - // Only keep advanced keys (exclude essential + groups) - return Object.fromEntries( - Object.entries(config).filter(([k]) => !ESSENTIAL_CONFIG_KEYS.has(k) && k !== "groups"), - ); -} - export function ChannelAdvancedDialog({ open, onOpenChange, @@ -58,14 +52,13 @@ export function ChannelAdvancedDialog({ const { t } = useTranslation("channels"); const groups = getAdvancedFields(instance.channel_type); - const [values, setValues] = useState>(() => deriveInitialValues(instance)); + const [values, setValues] = useState>(() => deriveAdvancedInitialValues(instance.config)); const [saving, setSaving] = useState(false); // Re-sync local state when dialog opens useEffect(() => { if (!open) return; - setValues(deriveInitialValues(instance)); - + setValues(deriveAdvancedInitialValues(instance.config)); }, [open, instance]); const handleChange = useCallback((key: string, value: unknown) => { @@ -75,13 +68,7 @@ export function ChannelAdvancedDialog({ const handleSave = async () => { setSaving(true); try { - const existingConfig = (instance.config ?? {}) as Record; - const cleanAdvanced = Object.fromEntries( - Object.entries(values).filter(([, v]) => v !== undefined && v !== "" && v !== null), - ); - // Merge: preserve essential keys and groups from existing, overwrite advanced keys - const merged = normalizeReasoningDeliveryConfig({ ...existingConfig, ...cleanAdvanced }); - await onUpdate({ config: merged }); + await onUpdate({ config: buildAdvancedConfigUpdate(instance.config, values) }); onOpenChange(false); } catch { // toast shown by hook } finally {