new-api/web/default/src/features/channels/components/dialogs/param-override-editor-dialog.tsx

3316 lines
110 KiB
TypeScript
Vendored

import {
type DragEvent,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import {
ChevronDown,
ChevronUp,
Copy,
GripVertical,
Plus,
Search,
Trash2,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ParamOverrideCondition = {
id: string
path: string
mode: string
value_text: string
invert: boolean
pass_missing_key: boolean
}
type ParamOverrideOperation = {
id: string
description: string
path: string
mode: string
from: string
to: string
value_text: string
keep_origin: boolean
logic: string
conditions: ParamOverrideCondition[]
}
export type ParamOverrideEditorDialogProps = {
open: boolean
value: string
onOpenChange: (open: boolean) => void
onSave: (value: string) => void
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OPERATION_MODE_OPTIONS = [
{ label: 'Set Field', value: 'set' },
{ label: 'Delete Field', value: 'delete' },
{ label: 'Append to End', value: 'append' },
{ label: 'Prepend to Start', value: 'prepend' },
{ label: 'Copy Field', value: 'copy' },
{ label: 'Move Field', value: 'move' },
{ label: 'String Replace', value: 'replace' },
{ label: 'Regex Replace', value: 'regex_replace' },
{ label: 'Trim Prefix', value: 'trim_prefix' },
{ label: 'Trim Suffix', value: 'trim_suffix' },
{ label: 'Ensure Prefix', value: 'ensure_prefix' },
{ label: 'Ensure Suffix', value: 'ensure_suffix' },
{ label: 'Trim Space', value: 'trim_space' },
{ label: 'To Lowercase', value: 'to_lower' },
{ label: 'To Uppercase', value: 'to_upper' },
{ label: 'Return Custom Error', value: 'return_error' },
{ label: 'Prune Object Items', value: 'prune_objects' },
{ label: 'Pass Through Headers', value: 'pass_headers' },
{ label: 'Sync Fields', value: 'sync_fields' },
{ label: 'Set Request Header', value: 'set_header' },
{ label: 'Delete Request Header', value: 'delete_header' },
{ label: 'Copy Request Header', value: 'copy_header' },
{ label: 'Move Request Header', value: 'move_header' },
]
const OPERATION_MODE_VALUES = new Set(
OPERATION_MODE_OPTIONS.map((o) => o.value)
)
const OPERATION_MODE_LABEL_MAP = OPERATION_MODE_OPTIONS.reduce<
Record<string, string>
>((acc, item) => {
acc[item.value] = item.label
return acc
}, {})
const CONDITION_MODE_OPTIONS = [
{ label: 'Exact Match', value: 'full' },
{ label: 'Prefix', value: 'prefix' },
{ label: 'Suffix', value: 'suffix' },
{ label: 'Contains', value: 'contains' },
{ label: 'Greater Than', value: 'gt' },
{ label: 'Greater Than or Equal', value: 'gte' },
{ label: 'Less Than', value: 'lt' },
{ label: 'Less Than or Equal', value: 'lte' },
]
const CONDITION_MODE_VALUES = new Set(
CONDITION_MODE_OPTIONS.map((o) => o.value)
)
const MODE_META: Record<
string,
{
path?: boolean
pathOptional?: boolean
value?: boolean
from?: boolean
to?: boolean
keepOrigin?: boolean
pathAlias?: boolean
}
> = {
delete: { path: true },
set: { path: true, value: true, keepOrigin: true },
append: { path: true, value: true, keepOrigin: true },
prepend: { path: true, value: true, keepOrigin: true },
copy: { from: true, to: true },
move: { from: true, to: true },
replace: { path: true, from: true, to: false },
regex_replace: { path: true, from: true, to: false },
trim_prefix: { path: true, value: true },
trim_suffix: { path: true, value: true },
ensure_prefix: { path: true, value: true },
ensure_suffix: { path: true, value: true },
trim_space: { path: true },
to_lower: { path: true },
to_upper: { path: true },
return_error: { value: true },
prune_objects: { pathOptional: true, value: true },
pass_headers: { value: true, keepOrigin: true },
sync_fields: { from: true, to: true },
set_header: { path: true, value: true, keepOrigin: true },
delete_header: { path: true },
copy_header: { from: true, to: true, keepOrigin: true, pathAlias: true },
move_header: { from: true, to: true, keepOrigin: true, pathAlias: true },
}
const VALUE_REQUIRED_MODES = new Set([
'trim_prefix',
'trim_suffix',
'ensure_prefix',
'ensure_suffix',
'set_header',
'return_error',
'prune_objects',
'pass_headers',
])
const FROM_REQUIRED_MODES = new Set([
'copy',
'move',
'replace',
'regex_replace',
'copy_header',
'move_header',
'sync_fields',
])
const TO_REQUIRED_MODES = new Set([
'copy',
'move',
'copy_header',
'move_header',
'sync_fields',
])
const MODE_DESCRIPTIONS: Record<string, string> = {
set: 'Write value to the target field',
delete: 'Remove the target field',
append: 'Append value to array / string / object end',
prepend: 'Prepend value to array / string / object start',
copy: 'Copy source field to target field',
move: 'Move source field to target field',
replace: 'Do string replacement in the target field',
regex_replace: 'Do regex replacement in the target field',
trim_prefix: 'Remove string prefix',
trim_suffix: 'Remove string suffix',
ensure_prefix: 'Ensure the string has a specified prefix',
ensure_suffix: 'Ensure the string has a specified suffix',
trim_space: 'Trim leading/trailing whitespace',
to_lower: 'Convert string to lowercase',
to_upper: 'Convert string to uppercase',
return_error: 'Return a custom error immediately',
prune_objects: 'Prune object items by conditions',
pass_headers: 'Pass specified request headers to upstream',
sync_fields: 'Auto-fill when one field exists and another is missing',
set_header:
'Set runtime request header: override entire value, or manipulate comma-separated tokens',
delete_header: 'Delete a runtime request header',
copy_header: 'Copy a request header',
move_header: 'Move a request header',
}
const SYNC_TARGET_TYPE_OPTIONS = [
{ label: 'Request Body Field', value: 'json' },
{ label: 'Request Header Field', value: 'header' },
]
// Templates
const LEGACY_TEMPLATE = { temperature: 0, max_tokens: 1000 }
const OPERATION_TEMPLATE = {
operations: [
{
description: 'Set default temperature for openai/* models.',
path: 'temperature',
mode: 'set',
value: 0.7,
conditions: [{ path: 'model', mode: 'prefix', value: 'openai/' }],
logic: 'AND',
},
],
}
const HEADER_PASSTHROUGH_TEMPLATE = {
operations: [
{
description: 'Pass through X-Request-Id header to upstream.',
mode: 'pass_headers',
value: ['X-Request-Id'],
keep_origin: true,
},
],
}
const GEMINI_IMAGE_4K_TEMPLATE = {
operations: [
{
description:
'Set imageSize to 4K when model contains gemini/image and ends with 4k.',
mode: 'set',
path: 'generationConfig.imageConfig.imageSize',
value: '4K',
conditions: [
{ path: 'original_model', mode: 'contains', value: 'gemini' },
{ path: 'original_model', mode: 'contains', value: 'image' },
{ path: 'original_model', mode: 'suffix', value: '4k' },
],
logic: 'AND',
},
],
}
const CODEX_CLI_HEADER_PASSTHROUGH_HEADERS = [
'Originator',
'Session_id',
'User-Agent',
'X-Codex-Beta-Features',
'X-Codex-Turn-Metadata',
]
const CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS = [
'X-Stainless-Arch',
'X-Stainless-Lang',
'X-Stainless-Os',
'X-Stainless-Package-Version',
'X-Stainless-Retry-Count',
'X-Stainless-Runtime',
'X-Stainless-Runtime-Version',
'X-Stainless-Timeout',
'User-Agent',
'X-App',
'Anthropic-Beta',
'Anthropic-Dangerous-Direct-Browser-Access',
'Anthropic-Version',
]
const buildPassHeadersTemplate = (headers: string[]) => ({
operations: [
{ mode: 'pass_headers', value: [...headers], keep_origin: true },
],
})
const CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate(
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS
)
const CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE = buildPassHeadersTemplate(
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS
)
const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
operations: [
{
description:
'Normalize anthropic-beta header tokens for Bedrock compatibility.',
mode: 'set_header',
path: 'anthropic-beta',
value: {
'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19',
bash_20241022: null,
bash_20250124: null,
'code-execution-2025-08-25': null,
'compact-2026-01-12': 'compact-2026-01-12',
'computer-use-2025-01-24': 'computer-use-2025-01-24',
'computer-use-2025-11-24': 'computer-use-2025-11-24',
'context-1m-2025-08-07': 'context-1m-2025-08-07',
'context-management-2025-06-27': 'context-management-2025-06-27',
'effort-2025-11-24': null,
'fast-mode-2026-02-01': null,
'files-api-2025-04-14': null,
'fine-grained-tool-streaming-2025-05-14': null,
'interleaved-thinking-2025-05-14': 'interleaved-thinking-2025-05-14',
'mcp-client-2025-11-20': null,
'mcp-client-2025-04-04': null,
'mcp-servers-2025-12-04': null,
'output-128k-2025-02-19': null,
'structured-output-2024-03-01': null,
'prompt-caching-scope-2026-01-05': null,
'skills-2025-10-02': null,
'structured-outputs-2025-11-13': null,
text_editor_20241022: null,
text_editor_20250124: null,
'token-efficient-tools-2025-02-19': null,
'tool-search-tool-2025-10-19': 'tool-search-tool-2025-10-19',
'web-fetch-2025-09-10': null,
'web-search-2025-03-05': null,
'oauth-2025-04-20': null,
},
},
{
description:
'Remove all tools[*].custom.input_examples before upstream relay.',
mode: 'delete',
path: 'tools.*.custom.input_examples',
},
],
}
type TemplatePresetConfig = {
label: string
kind: 'operations' | 'legacy'
payload: Record<string, unknown>
}
const TEMPLATE_PRESET_CONFIG: Record<string, TemplatePresetConfig> = {
operations_default: {
label: 'New Format Template',
kind: 'operations',
payload: OPERATION_TEMPLATE,
},
legacy_default: {
label: 'Legacy Format Template',
kind: 'legacy',
payload: LEGACY_TEMPLATE,
},
pass_headers_auth: {
label: 'Header Passthrough (X-Request-Id)',
kind: 'operations',
payload: HEADER_PASSTHROUGH_TEMPLATE,
},
gemini_image_4k: {
label: 'Gemini Image 4K',
kind: 'operations',
payload: GEMINI_IMAGE_4K_TEMPLATE,
},
claude_cli_headers_passthrough: {
label: 'Claude CLI Header Passthrough',
kind: 'operations',
payload: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
},
codex_cli_headers_passthrough: {
label: 'Codex CLI Header Passthrough',
kind: 'operations',
payload: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
},
aws_bedrock_anthropic_beta_override: {
label: 'AWS Bedrock Claude Compat',
kind: 'operations',
payload: AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE,
},
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let localIdSeed = 0
const nextLocalId = () => `po_${Date.now()}_${localIdSeed++}`
const toValueText = (value: unknown): string => {
if (value === undefined) return ''
if (typeof value === 'string') return value
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
const parseLooseValue = (valueText: string): unknown => {
const raw = String(valueText ?? '').trim()
if (raw === '') return ''
try {
return JSON.parse(raw)
} catch {
return raw
}
}
const verifyJSON = (text: string): boolean => {
try {
JSON.parse(text)
return true
} catch {
return false
}
}
const normalizeCondition = (
condition: Record<string, unknown> = {}
): ParamOverrideCondition => ({
id: nextLocalId(),
path: typeof condition.path === 'string' ? condition.path : '',
mode: CONDITION_MODE_VALUES.has(condition.mode as string)
? (condition.mode as string)
: 'full',
value_text: toValueText(condition.value),
invert: condition.invert === true,
pass_missing_key: condition.pass_missing_key === true,
})
const createDefaultCondition = (): ParamOverrideCondition =>
normalizeCondition({})
const normalizeOperation = (
operation: Record<string, unknown> = {}
): ParamOverrideOperation => ({
id: nextLocalId(),
description:
typeof operation.description === 'string' ? operation.description : '',
path: typeof operation.path === 'string' ? operation.path : '',
mode: OPERATION_MODE_VALUES.has(operation.mode as string)
? (operation.mode as string)
: 'set',
value_text: toValueText(operation.value),
keep_origin: operation.keep_origin === true,
from: typeof operation.from === 'string' ? operation.from : '',
to: typeof operation.to === 'string' ? operation.to : '',
logic: String(operation.logic || 'OR').toUpperCase() === 'AND' ? 'AND' : 'OR',
conditions: Array.isArray(operation.conditions)
? (operation.conditions as Record<string, unknown>[]).map(
normalizeCondition
)
: [],
})
const createDefaultOperation = (): ParamOverrideOperation =>
normalizeOperation({ mode: 'set' })
const reorderOperations = (
ops: ParamOverrideOperation[],
sourceId: string,
targetId: string,
position: 'before' | 'after' = 'before'
): ParamOverrideOperation[] => {
if (!sourceId || !targetId || sourceId === targetId) return ops
const srcIdx = ops.findIndex((o) => o.id === sourceId)
if (srcIdx < 0) return ops
const next = [...ops]
const [moved] = next.splice(srcIdx, 1)
let insertIdx = next.findIndex((o) => o.id === targetId)
if (insertIdx < 0) return ops
if (position === 'after') insertIdx += 1
next.splice(insertIdx, 0, moved)
return next
}
const isOperationBlank = (operation: ParamOverrideOperation): boolean => {
const hasCondition = operation.conditions.some(
(c) =>
c.path.trim() ||
c.value_text.trim() ||
c.mode !== 'full' ||
c.invert ||
c.pass_missing_key
)
return (
operation.mode === 'set' &&
!operation.path.trim() &&
!operation.from.trim() &&
!operation.to.trim() &&
operation.value_text.trim() === '' &&
!operation.keep_origin &&
!hasCondition
)
}
const getOperationSummary = (
operation: ParamOverrideOperation,
index: number
): string => {
const mode = operation.mode || 'set'
const modeLabel = OPERATION_MODE_LABEL_MAP[mode] || mode
if (mode === 'sync_fields') {
const from = operation.from.trim()
const to = operation.to.trim()
return `${index + 1}. ${modeLabel} · ${from || to || '-'}`
}
const path = operation.path.trim()
const from = operation.from.trim()
const to = operation.to.trim()
return `${index + 1}. ${modeLabel} · ${path || from || to || '-'}`
}
const getModeTagTailwind = (mode: string): string => {
if (mode.includes('header'))
return 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/20'
if (mode.includes('replace') || mode.includes('trim'))
return 'bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/20'
if (mode.includes('copy') || mode.includes('move'))
return 'bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/20'
if (mode.includes('error') || mode.includes('prune'))
return 'bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/20'
if (mode.includes('sync'))
return 'bg-green-500/15 text-green-700 dark:text-green-300 border-green-500/20'
return 'bg-muted text-muted-foreground'
}
const getModePathLabel = (mode: string): string => {
if (mode === 'set_header' || mode === 'delete_header') return 'Header Name'
if (mode === 'prune_objects') return 'Target Path (optional)'
return 'Target Field Path'
}
const getModePathPlaceholder = (mode: string): string => {
if (mode === 'set_header') return 'Authorization'
if (mode === 'delete_header') return 'X-Debug-Mode'
if (mode === 'prune_objects') return 'messages'
return 'temperature'
}
const getModeFromLabel = (mode: string): string => {
if (mode === 'replace') return 'Match Text'
if (mode === 'regex_replace') return 'Regex Pattern'
if (mode === 'copy_header' || mode === 'move_header') return 'Source Header'
return 'Source Field'
}
const getModeFromPlaceholder = (mode: string): string => {
if (mode === 'replace') return 'openai/'
if (mode === 'regex_replace') return '^gpt-'
if (mode === 'copy_header' || mode === 'move_header') return 'Authorization'
return 'model'
}
const getModeToLabel = (mode: string): string => {
if (mode === 'replace' || mode === 'regex_replace') return 'Replace With'
if (mode === 'copy_header' || mode === 'move_header') return 'Target Header'
return 'Target Field'
}
const getModeToPlaceholder = (mode: string): string => {
if (mode === 'replace') return '(leave empty to delete)'
if (mode === 'regex_replace') return 'openai/gpt-'
if (mode === 'copy_header' || mode === 'move_header') return 'X-Upstream-Auth'
return 'original_model'
}
const getModeValueLabel = (mode: string): string => {
if (mode === 'set_header')
return 'Header Value (supports string or JSON mapping)'
if (mode === 'pass_headers')
return 'Pass-through Headers (comma-separated or JSON array)'
if (
mode === 'trim_prefix' ||
mode === 'trim_suffix' ||
mode === 'ensure_prefix' ||
mode === 'ensure_suffix'
)
return 'Prefix/Suffix Text'
if (mode === 'prune_objects') return 'Prune Rule (string or JSON object)'
return 'Value (supports JSON or plain text)'
}
const getModeValuePlaceholder = (mode: string): string => {
if (mode === 'set_header') return 'Bearer sk-xxx'
if (mode === 'pass_headers') return 'Authorization, X-Request-Id'
if (
mode === 'trim_prefix' ||
mode === 'trim_suffix' ||
mode === 'ensure_prefix' ||
mode === 'ensure_suffix'
)
return 'openai/'
if (mode === 'prune_objects') return '{"type":"redacted_thinking"}'
return '0.7'
}
const parseSyncTargetSpec = (spec: string): { type: string; key: string } => {
const raw = String(spec ?? '').trim()
if (!raw) return { type: 'json', key: '' }
const idx = raw.indexOf(':')
if (idx < 0) return { type: 'json', key: raw }
const prefix = raw.slice(0, idx).trim().toLowerCase()
const key = raw.slice(idx + 1).trim()
return prefix === 'header' ? { type: 'header', key } : { type: 'json', key }
}
const buildSyncTargetSpec = (type: string, key: string): string => {
const normalizedType = type === 'header' ? 'header' : 'json'
const normalizedKey = String(key ?? '').trim()
if (!normalizedKey) return ''
return `${normalizedType}:${normalizedKey}`
}
// return_error helpers
type ReturnErrorDraft = {
message: string
statusCode: number
code: string
type: string
skipRetry: boolean
simpleMode: boolean
}
const parseReturnErrorDraft = (valueText: string): ReturnErrorDraft => {
const defaults: ReturnErrorDraft = {
message: '',
statusCode: 400,
code: '',
type: '',
skipRetry: true,
simpleMode: true,
}
const raw = String(valueText ?? '').trim()
if (!raw) return defaults
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const statusRaw =
parsed.status_code !== undefined ? parsed.status_code : parsed.status
const statusValue = Number(statusRaw)
return {
...defaults,
message: String(
(parsed.message as string) || (parsed.msg as string) || ''
).trim(),
statusCode:
Number.isInteger(statusValue) &&
statusValue >= 100 &&
statusValue <= 599
? statusValue
: 400,
code: String((parsed.code as string) || '').trim(),
type: String((parsed.type as string) || '').trim(),
skipRetry: parsed.skip_retry !== false,
simpleMode: false,
}
}
} catch {
/* treat as plain text */
}
return { ...defaults, message: raw, simpleMode: true }
}
const buildReturnErrorValueText = (
draft: Partial<ReturnErrorDraft>
): string => {
const message = String(draft.message || '').trim()
if (draft.simpleMode) return message
const statusCode = Number(draft.statusCode)
const payload: Record<string, unknown> = {
message,
status_code:
Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599
? statusCode
: 400,
}
const code = String(draft.code || '').trim()
const type = String(draft.type || '').trim()
if (code) payload.code = code
if (type) payload.type = type
if (draft.skipRetry === false) payload.skip_retry = false
return JSON.stringify(payload)
}
// prune_objects helpers
type PruneRule = {
id: string
path: string
mode: string
value_text: string
invert: boolean
pass_missing_key: boolean
}
type PruneObjectsDraft = {
simpleMode: boolean
typeText: string
logic: string
recursive: boolean
rules: PruneRule[]
}
const normalizePruneRule = (rule: Record<string, unknown> = {}): PruneRule => ({
id: nextLocalId(),
path: typeof rule.path === 'string' ? rule.path : '',
mode: CONDITION_MODE_VALUES.has(rule.mode as string)
? (rule.mode as string)
: 'full',
value_text: toValueText(rule.value),
invert: rule.invert === true,
pass_missing_key: rule.pass_missing_key === true,
})
const parsePruneObjectsDraft = (valueText: string): PruneObjectsDraft => {
const defaults: PruneObjectsDraft = {
simpleMode: true,
typeText: '',
logic: 'AND',
recursive: true,
rules: [],
}
const raw = String(valueText ?? '').trim()
if (!raw) return defaults
try {
const parsed = JSON.parse(raw)
if (typeof parsed === 'string')
return { ...defaults, typeText: parsed.trim() }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const rules: PruneRule[] = []
if (
parsed.where &&
typeof parsed.where === 'object' &&
!Array.isArray(parsed.where)
) {
for (const [path, value] of Object.entries(
parsed.where as Record<string, unknown>
)) {
rules.push(normalizePruneRule({ path, mode: 'full', value }))
}
}
if (Array.isArray(parsed.conditions)) {
for (const item of parsed.conditions) {
if (item && typeof item === 'object')
rules.push(normalizePruneRule(item))
}
} else if (
parsed.conditions &&
typeof parsed.conditions === 'object' &&
!Array.isArray(parsed.conditions)
) {
for (const [path, value] of Object.entries(
parsed.conditions as Record<string, unknown>
)) {
rules.push(normalizePruneRule({ path, mode: 'full', value }))
}
}
const typeText =
parsed.type === undefined ? '' : String(parsed.type).trim()
const logic =
String(parsed.logic || 'AND').toUpperCase() === 'OR' ? 'OR' : 'AND'
const recursive = parsed.recursive !== false
const hasAdvancedFields =
parsed.logic !== undefined ||
parsed.recursive !== undefined ||
parsed.where !== undefined ||
parsed.conditions !== undefined
return {
...defaults,
simpleMode: !hasAdvancedFields,
typeText,
logic,
recursive,
rules,
}
}
return { ...defaults, typeText: String(parsed ?? '').trim() }
} catch {
return { ...defaults, typeText: raw }
}
}
const buildPruneObjectsValueText = (draft: PruneObjectsDraft): string => {
const typeText = String(draft.typeText || '').trim()
if (draft.simpleMode) return typeText
const payload: Record<string, unknown> = {}
if (typeText) payload.type = typeText
if (String(draft.logic || 'AND').toUpperCase() === 'OR') payload.logic = 'OR'
if (draft.recursive === false) payload.recursive = false
const conditions = (draft.rules || [])
.filter((rule) => String(rule.path || '').trim())
.map((rule) => {
const conditionPayload: Record<string, unknown> = {
path: String(rule.path || '').trim(),
mode: CONDITION_MODE_VALUES.has(rule.mode) ? rule.mode : 'full',
}
const valueRaw = String(rule.value_text || '').trim()
if (valueRaw !== '') conditionPayload.value = parseLooseValue(valueRaw)
if (rule.invert) conditionPayload.invert = true
if (rule.pass_missing_key) conditionPayload.pass_missing_key = true
return conditionPayload
})
if (conditions.length > 0) payload.conditions = conditions
if (!payload.type && !payload.conditions)
return JSON.stringify({ logic: 'AND' })
return JSON.stringify(payload)
}
// pass_headers helpers
const parsePassHeaderNames = (rawValue: unknown): string[] => {
if (Array.isArray(rawValue))
return rawValue.map((i) => String(i ?? '').trim()).filter(Boolean)
if (rawValue && typeof rawValue === 'object') {
const obj = rawValue as Record<string, unknown>
if (Array.isArray(obj.headers))
return obj.headers.map((i) => String(i ?? '').trim()).filter(Boolean)
if (obj.header !== undefined) {
const single = String(obj.header ?? '').trim()
return single ? [single] : []
}
return []
}
if (typeof rawValue === 'string')
return rawValue
.split(',')
.map((i) => i.trim())
.filter(Boolean)
return []
}
// Condition payload builder
const buildConditionPayload = (
condition: ParamOverrideCondition
): Record<string, unknown> | null => {
const path = condition.path.trim()
if (!path) return null
const payload: Record<string, unknown> = {
path,
mode: condition.mode || 'full',
value: parseLooseValue(condition.value_text),
}
if (condition.invert) payload.invert = true
if (condition.pass_missing_key) payload.pass_missing_key = true
return payload
}
// Validation
const validateOperations = (
operations: ParamOverrideOperation[],
t: (key: string, options?: Record<string, unknown>) => string
): string => {
for (let i = 0; i < operations.length; i++) {
const op = operations[i]
const mode = op.mode || 'set'
const meta = MODE_META[mode] || MODE_META.set
const line = i + 1
const pathValue = op.path.trim()
const fromValue = op.from.trim()
const toValue = op.to.trim()
if (meta.path && !pathValue)
return t('Rule {{line}} is missing target path', { line })
if (FROM_REQUIRED_MODES.has(mode) && !fromValue) {
if (!(meta.pathAlias && pathValue))
return t('Rule {{line}} is missing source field', { line })
}
if (TO_REQUIRED_MODES.has(mode) && !toValue) {
if (!(meta.pathAlias && pathValue))
return t('Rule {{line}} is missing target field', { line })
}
if (VALUE_REQUIRED_MODES.has(mode) && op.value_text.trim() === '')
return t('Rule {{line}} is missing value', { line })
if (mode === 'return_error') {
const raw = op.value_text.trim()
if (!raw) return t('Rule {{line}} is missing value', { line })
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
if (!String((parsed as Record<string, unknown>).message || '').trim())
return t('Rule {{line}} return_error requires a message field', {
line,
})
}
} catch {
/* plain string is allowed */
}
}
if (mode === 'prune_objects') {
const raw = op.value_text.trim()
if (!raw)
return t('Rule {{line}} prune_objects is missing conditions', { line })
}
if (mode === 'pass_headers') {
const raw = op.value_text.trim()
if (!raw)
return t('Rule {{line}} pass_headers is missing header names', { line })
const parsed = parseLooseValue(raw)
const headers = parsePassHeaderNames(parsed)
if (headers.length === 0)
return t('Rule {{line}} pass_headers format is invalid', { line })
}
}
return ''
}
// Parse initial state
type EditorState = {
editMode: 'visual' | 'json'
visualMode: 'operations' | 'legacy'
legacyValue: string
operations: ParamOverrideOperation[]
jsonText: string
jsonError: string
}
const parseInitialState = (rawValue: string): EditorState => {
const text = typeof rawValue === 'string' ? rawValue : ''
const trimmed = text.trim()
if (!trimmed) {
return {
editMode: 'visual',
visualMode: 'operations',
legacyValue: '',
operations: [createDefaultOperation()],
jsonText: '',
jsonError: '',
}
}
if (!verifyJSON(trimmed)) {
return {
editMode: 'json',
visualMode: 'operations',
legacyValue: '',
operations: [createDefaultOperation()],
jsonText: text,
jsonError: 'Invalid JSON format',
}
}
const parsed = JSON.parse(trimmed) as Record<string, unknown>
const pretty = JSON.stringify(parsed, null, 2)
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Array.isArray(parsed.operations)
) {
return {
editMode: 'visual',
visualMode: 'operations',
legacyValue: '',
operations:
(parsed.operations as Record<string, unknown>[]).length > 0
? (parsed.operations as Record<string, unknown>[]).map(
normalizeOperation
)
: [createDefaultOperation()],
jsonText: pretty,
jsonError: '',
}
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return {
editMode: 'visual',
visualMode: 'legacy',
legacyValue: pretty,
operations: [createDefaultOperation()],
jsonText: pretty,
jsonError: '',
}
}
return {
editMode: 'json',
visualMode: 'operations',
legacyValue: '',
operations: [createDefaultOperation()],
jsonText: pretty,
jsonError: '',
}
}
// Build operations JSON
const buildOperationsJson = (
sourceOperations: ParamOverrideOperation[],
options: { validate: boolean },
t: (key: string, options?: Record<string, unknown>) => string
): string => {
const filteredOps = sourceOperations.filter((o) => !isOperationBlank(o))
if (filteredOps.length === 0) return ''
if (options.validate) {
const message = validateOperations(filteredOps, t)
if (message) throw new Error(message)
}
const payloadOps = filteredOps.map((operation) => {
const mode = operation.mode || 'set'
const meta = MODE_META[mode] || MODE_META.set
const descriptionValue = String(operation.description || '').trim()
const pathValue = operation.path.trim()
const fromValue = operation.from.trim()
const toValue = operation.to.trim()
const payload: Record<string, unknown> = { mode }
if (descriptionValue) payload.description = descriptionValue
if (meta.path) payload.path = pathValue
if (meta.pathOptional && pathValue) payload.path = pathValue
if (meta.value) payload.value = parseLooseValue(operation.value_text)
if (meta.keepOrigin && operation.keep_origin) payload.keep_origin = true
if (meta.from) payload.from = fromValue
if (!meta.to && operation.to.trim()) payload.to = toValue
if (meta.to) payload.to = toValue
if (meta.pathAlias) {
if (!payload.from && pathValue) payload.from = pathValue
if (!payload.to && pathValue) payload.to = pathValue
}
const conditions = operation.conditions
.map(buildConditionPayload)
.filter(Boolean)
if (conditions.length > 0) {
payload.conditions = conditions
payload.logic = operation.logic === 'AND' ? 'AND' : 'OR'
}
return payload
})
return JSON.stringify({ operations: payloadOps }, null, 2)
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ParamOverrideEditorDialog(
props: ParamOverrideEditorDialogProps
) {
const { t } = useTranslation()
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
const [visualMode, setVisualMode] = useState<'operations' | 'legacy'>(
'operations'
)
const [legacyValue, setLegacyValue] = useState('')
const [operations, setOperations] = useState<ParamOverrideOperation[]>([
createDefaultOperation(),
])
const [jsonText, setJsonText] = useState('')
const [jsonError, setJsonError] = useState('')
const [operationSearch, setOperationSearch] = useState('')
const [selectedOperationId, setSelectedOperationId] = useState('')
const [expandedConditions, setExpandedConditions] = useState<
Record<string, boolean>
>({})
const [draggedOperationId, setDraggedOperationId] = useState('')
const [dragOverOperationId, setDragOverOperationId] = useState('')
const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after'>(
'before'
)
const [templatePresetKey, setTemplatePresetKey] =
useState('operations_default')
// Initialize state when dialog opens
useEffect(() => {
if (!props.open) return
const state = parseInitialState(props.value)
setEditMode(state.editMode)
setVisualMode(state.visualMode)
setLegacyValue(state.legacyValue)
setOperations(state.operations)
setJsonText(state.jsonText)
setJsonError(state.jsonError)
setOperationSearch('')
setSelectedOperationId(state.operations[0]?.id || '')
setExpandedConditions({})
setDraggedOperationId('')
setDragOverOperationId('')
setDragOverPosition('before')
if (state.visualMode === 'legacy') {
setTemplatePresetKey('legacy_default')
} else {
setTemplatePresetKey('operations_default')
}
}, [props.open, props.value])
// Keep selectedOperationId valid
useEffect(() => {
if (operations.length === 0) {
setSelectedOperationId('')
return
}
if (!operations.some((o) => o.id === selectedOperationId)) {
setSelectedOperationId(operations[0].id)
}
}, [operations, selectedOperationId])
// Template preset options filtered by group
const templatePresetOptions = useMemo(
() =>
Object.entries(TEMPLATE_PRESET_CONFIG).map(([value, config]) => ({
value,
label: config.label,
})),
[]
)
const operationCount = useMemo(
() => operations.filter((o) => !isOperationBlank(o)).length,
[operations]
)
const filteredOperations = useMemo(() => {
const keyword = operationSearch.trim().toLowerCase()
if (!keyword) return operations
return operations.filter((op) => {
const searchableText = [
op.description,
op.mode,
op.path,
op.from,
op.to,
op.value_text,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(keyword)
})
}, [operationSearch, operations])
const selectedOperation = useMemo(
() => operations.find((o) => o.id === selectedOperationId),
[operations, selectedOperationId]
)
const selectedOperationIndex = useMemo(
() => operations.findIndex((o) => o.id === selectedOperationId),
[operations, selectedOperationId]
)
const returnErrorDraft = useMemo(() => {
if (!selectedOperation || selectedOperation.mode !== 'return_error')
return null
return parseReturnErrorDraft(selectedOperation.value_text)
}, [selectedOperation])
const pruneObjectsDraft = useMemo(() => {
if (!selectedOperation || selectedOperation.mode !== 'prune_objects')
return null
return parsePruneObjectsDraft(selectedOperation.value_text)
}, [selectedOperation])
const topOperationModes = useMemo(() => {
const counts: Record<string, number> = {}
for (const op of operations) {
const mode = op.mode || 'set'
counts[mode] = (counts[mode] || 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
}, [operations])
// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------
const updateOperation = useCallback(
(operationId: string, patch: Partial<ParamOverrideOperation>) => {
setOperations((prev) =>
prev.map((o) => (o.id === operationId ? { ...o, ...patch } : o))
)
},
[]
)
const addOperation = useCallback(() => {
const created = createDefaultOperation()
setOperations((prev) => [...prev, created])
setSelectedOperationId(created.id)
}, [])
const duplicateOperation = useCallback((operationId: string) => {
let insertedId = ''
setOperations((prev) => {
const idx = prev.findIndex((o) => o.id === operationId)
if (idx < 0) return prev
const source = prev[idx]
const cloned = normalizeOperation({
description: source.description,
path: source.path,
mode: source.mode,
value: parseLooseValue(source.value_text),
keep_origin: source.keep_origin,
from: source.from,
to: source.to,
logic: source.logic,
conditions: source.conditions.map((c) => ({
path: c.path,
mode: c.mode,
value: parseLooseValue(c.value_text),
invert: c.invert,
pass_missing_key: c.pass_missing_key,
})),
})
insertedId = cloned.id
const next = [...prev]
next.splice(idx + 1, 0, cloned)
return next
})
if (insertedId) setSelectedOperationId(insertedId)
}, [])
const removeOperation = useCallback((operationId: string) => {
setOperations((prev) => {
if (prev.length <= 1) return [createDefaultOperation()]
return prev.filter((o) => o.id !== operationId)
})
}, [])
// Conditions
const addCondition = useCallback((operationId: string) => {
const created = createDefaultCondition()
setOperations((prev) =>
prev.map((op) =>
op.id === operationId
? { ...op, conditions: [...op.conditions, created] }
: op
)
)
setExpandedConditions((prev) => ({ ...prev, [created.id]: true }))
}, [])
const updateCondition = useCallback(
(
operationId: string,
conditionId: string,
patch: Partial<ParamOverrideCondition>
) => {
setOperations((prev) =>
prev.map((op) =>
op.id === operationId
? {
...op,
conditions: op.conditions.map((c) =>
c.id === conditionId ? { ...c, ...patch } : c
),
}
: op
)
)
},
[]
)
const removeCondition = useCallback(
(operationId: string, conditionId: string) => {
setOperations((prev) =>
prev.map((op) =>
op.id === operationId
? {
...op,
conditions: op.conditions.filter((c) => c.id !== conditionId),
}
: op
)
)
},
[]
)
// return_error draft
const updateReturnErrorDraft = useCallback(
(operationId: string, draftPatch: Partial<ReturnErrorDraft>) => {
setOperations((prev) =>
prev.map((op) => {
if (op.id !== operationId) return op
const draft = parseReturnErrorDraft(op.value_text)
const nextDraft = { ...draft, ...draftPatch }
return {
...op,
value_text: buildReturnErrorValueText(nextDraft),
}
})
)
},
[]
)
// prune_objects draft
const updatePruneObjectsDraft = useCallback(
(
operationId: string,
updater:
| Partial<PruneObjectsDraft>
| ((draft: PruneObjectsDraft) => PruneObjectsDraft)
) => {
setOperations((prev) =>
prev.map((op) => {
if (op.id !== operationId) return op
const draft = parsePruneObjectsDraft(op.value_text)
const nextDraft =
typeof updater === 'function'
? updater(draft)
: { ...draft, ...updater }
return {
...op,
value_text: buildPruneObjectsValueText(nextDraft),
}
})
)
},
[]
)
const addPruneRule = useCallback(
(operationId: string) => {
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
simpleMode: false,
rules: [...draft.rules, normalizePruneRule({})],
}))
},
[updatePruneObjectsDraft]
)
const updatePruneRule = useCallback(
(operationId: string, ruleId: string, patch: Partial<PruneRule>) => {
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
rules: draft.rules.map((r) =>
r.id === ruleId ? { ...r, ...patch } : r
),
}))
},
[updatePruneObjectsDraft]
)
const removePruneRule = useCallback(
(operationId: string, ruleId: string) => {
updatePruneObjectsDraft(operationId, (draft) => ({
...draft,
rules: draft.rules.filter((r) => r.id !== ruleId),
}))
},
[updatePruneObjectsDraft]
)
// Drag and drop
const resetDragState = useCallback(() => {
setDraggedOperationId('')
setDragOverOperationId('')
setDragOverPosition('before')
}, [])
const handleDragStart = useCallback(
(event: DragEvent, operationId: string) => {
setDraggedOperationId(operationId)
setSelectedOperationId(operationId)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', operationId)
},
[]
)
const handleDragOver = useCallback(
(event: DragEvent, operationId: string) => {
event.preventDefault()
if (!draggedOperationId || draggedOperationId === operationId) return
const rect = event.currentTarget.getBoundingClientRect()
const position: 'before' | 'after' =
event.clientY - rect.top > rect.height / 2 ? 'after' : 'before'
setDragOverOperationId(operationId)
setDragOverPosition(position)
event.dataTransfer.dropEffect = 'move'
},
[draggedOperationId]
)
const handleDrop = useCallback(
(event: DragEvent, operationId: string) => {
event.preventDefault()
const sourceId =
draggedOperationId || event.dataTransfer.getData('text/plain')
const position =
dragOverOperationId === operationId ? dragOverPosition : 'before'
if (sourceId && operationId && sourceId !== operationId) {
setOperations((prev) =>
reorderOperations(prev, sourceId, operationId, position)
)
setSelectedOperationId(sourceId)
}
resetDragState()
},
[draggedOperationId, dragOverOperationId, dragOverPosition, resetDragState]
)
// ---------------------------------------------------------------------------
// Mode switching & templates
// ---------------------------------------------------------------------------
const buildVisualJson = useCallback((): string => {
if (visualMode === 'legacy') {
const trimmed = legacyValue.trim()
if (!trimmed) return ''
if (!verifyJSON(trimmed))
throw new Error(t('Parameter override must be valid JSON format'))
const parsed = JSON.parse(trimmed) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new Error(t('Legacy format must be a JSON object'))
return JSON.stringify(parsed, null, 2)
}
return buildOperationsJson(operations, { validate: true }, t)
}, [legacyValue, operations, t, visualMode])
const switchToJsonMode = useCallback(() => {
if (editMode === 'json') return
try {
setJsonText(buildVisualJson())
setJsonError('')
} catch (error) {
toast.error((error as Error).message)
if (visualMode === 'legacy') {
setJsonText(legacyValue)
} else {
setJsonText(buildOperationsJson(operations, { validate: false }, t))
}
setJsonError(
(error as Error).message || t('Parameter configuration error')
)
}
setEditMode('json')
}, [buildVisualJson, editMode, legacyValue, operations, t, visualMode])
const switchToVisualMode = useCallback(() => {
if (editMode === 'visual') return
const trimmed = jsonText.trim()
if (!trimmed) {
const fallback = createDefaultOperation()
setVisualMode('operations')
setOperations([fallback])
setSelectedOperationId(fallback.id)
setLegacyValue('')
setJsonError('')
setEditMode('visual')
return
}
if (!verifyJSON(trimmed)) {
toast.error(t('Parameter override must be valid JSON format'))
return
}
const parsed = JSON.parse(trimmed) as Record<string, unknown>
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Array.isArray(parsed.operations)
) {
const nextOps =
(parsed.operations as Record<string, unknown>[]).length > 0
? (parsed.operations as Record<string, unknown>[]).map(
normalizeOperation
)
: [createDefaultOperation()]
setVisualMode('operations')
setOperations(nextOps)
setSelectedOperationId(nextOps[0]?.id || '')
setLegacyValue('')
setJsonError('')
setEditMode('visual')
setTemplatePresetKey('operations_default')
return
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const fallback = createDefaultOperation()
setVisualMode('legacy')
setLegacyValue(JSON.stringify(parsed, null, 2))
setOperations([fallback])
setSelectedOperationId(fallback.id)
setJsonError('')
setEditMode('visual')
setTemplatePresetKey('legacy_default')
return
}
toast.error(t('Parameter override must be a valid JSON object'))
}, [editMode, jsonText, t])
const fillTemplate = useCallback(
(mode: 'fill' | 'append') => {
const preset =
TEMPLATE_PRESET_CONFIG[templatePresetKey] ||
TEMPLATE_PRESET_CONFIG.operations_default
const payload = preset.payload as Record<string, unknown>
if (preset.kind === 'legacy') {
if (mode === 'append' && visualMode === 'legacy') {
const trimmed = legacyValue.trim()
let parsedCurrent: Record<string, unknown> = {}
if (trimmed) {
if (!verifyJSON(trimmed)) {
toast.error(t('Current legacy JSON is invalid, cannot append'))
return
}
parsedCurrent = JSON.parse(trimmed) as Record<string, unknown>
}
const merged = { ...(payload || {}), ...parsedCurrent }
const text = JSON.stringify(merged, null, 2)
setVisualMode('legacy')
setLegacyValue(text)
setOperations([createDefaultOperation()])
setJsonText(text)
setJsonError('')
setEditMode('visual')
} else {
const text = JSON.stringify(payload, null, 2)
setVisualMode('legacy')
setLegacyValue(text)
setOperations([createDefaultOperation()])
setJsonText(text)
setJsonError('')
setEditMode('visual')
}
return
}
const operationsPayload = ((payload as Record<string, unknown>)
.operations || []) as Record<string, unknown>[]
if (mode === 'append') {
const appended = operationsPayload.map(normalizeOperation)
const existing =
visualMode === 'operations'
? operations.filter((o) => !isOperationBlank(o))
: []
const nextOps = [...existing, ...appended]
setVisualMode('operations')
setOperations(nextOps.length > 0 ? nextOps : appended)
setSelectedOperationId(nextOps[0]?.id || appended[0]?.id || '')
setLegacyValue('')
setJsonError('')
setEditMode('visual')
setJsonText('')
} else {
const nextOps = operationsPayload.map(normalizeOperation)
const finalOps =
nextOps.length > 0 ? nextOps : [createDefaultOperation()]
setVisualMode('operations')
setOperations(finalOps)
setSelectedOperationId(finalOps[0]?.id || '')
setJsonText(JSON.stringify({ operations: operationsPayload }, null, 2))
setJsonError('')
setEditMode('visual')
}
},
[legacyValue, operations, templatePresetKey, visualMode, t]
)
const resetEditorState = useCallback(() => {
const fallback = createDefaultOperation()
setVisualMode('operations')
setLegacyValue('')
setOperations([fallback])
setSelectedOperationId(fallback.id)
setJsonText('')
setJsonError('')
setTemplatePresetKey('operations_default')
setEditMode('visual')
}, [])
// JSON mode
const handleJsonChange = useCallback(
(nextValue: string) => {
setJsonText(nextValue)
const trimmed = nextValue.trim()
if (!trimmed) {
setJsonError('')
return
}
setJsonError(verifyJSON(trimmed) ? '' : t('JSON format error'))
},
[t]
)
const formatJson = useCallback(() => {
const trimmed = jsonText.trim()
if (!trimmed) return
if (!verifyJSON(trimmed)) {
toast.error(t('Parameter override must be valid JSON format'))
return
}
setJsonText(JSON.stringify(JSON.parse(trimmed), null, 2))
setJsonError('')
}, [jsonText, t])
const visualValidationError = useMemo(() => {
if (editMode !== 'visual') return ''
try {
buildVisualJson()
return ''
} catch (error) {
return (error as Error)?.message || t('Parameter configuration error')
}
}, [buildVisualJson, editMode, t])
// Save
const handleSave = useCallback(() => {
try {
let result = ''
if (editMode === 'json') {
const trimmed = jsonText.trim()
if (trimmed) {
if (!verifyJSON(trimmed))
throw new Error(t('Parameter override must be valid JSON format'))
result = JSON.stringify(JSON.parse(trimmed), null, 2)
}
} else {
result = buildVisualJson()
}
props.onSave(result)
props.onOpenChange(false)
} catch (error) {
toast.error((error as Error).message)
}
}, [buildVisualJson, editMode, jsonText, props, t])
// Expand/collapse all conditions
const expandAllConditions = useCallback(() => {
if (!selectedOperation) return
const map: Record<string, boolean> = {}
for (const c of selectedOperation.conditions) map[c.id] = true
setExpandedConditions((prev) => ({ ...prev, ...map }))
}, [selectedOperation])
const collapseAllConditions = useCallback(() => {
if (!selectedOperation) return
const map: Record<string, boolean> = {}
for (const c of selectedOperation.conditions) map[c.id] = false
setExpandedConditions((prev) => ({ ...prev, ...map }))
}, [selectedOperation])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle>{t('Parameter Override')}</DialogTitle>
<DialogDescription>
{t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
</DialogDescription>
</DialogHeader>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// RuleEditor sub-component
// ---------------------------------------------------------------------------
type RuleEditorProps = {
operation: ParamOverrideOperation
operationIndex: number
operations: ParamOverrideOperation[]
returnErrorDraft: ReturnErrorDraft | null
pruneObjectsDraft: PruneObjectsDraft | null
expandedConditions: Record<string, boolean>
setExpandedConditions: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>
updateOperation: (
operationId: string,
patch: Partial<ParamOverrideOperation>
) => void
duplicateOperation: (operationId: string) => void
removeOperation: (operationId: string) => void
addCondition: (operationId: string) => void
updateCondition: (
operationId: string,
conditionId: string,
patch: Partial<ParamOverrideCondition>
) => void
removeCondition: (operationId: string, conditionId: string) => void
updateReturnErrorDraft: (
operationId: string,
draftPatch: Partial<ReturnErrorDraft>
) => void
updatePruneObjectsDraft: (
operationId: string,
updater:
| Partial<PruneObjectsDraft>
| ((draft: PruneObjectsDraft) => PruneObjectsDraft)
) => void
addPruneRule: (operationId: string) => void
updatePruneRule: (
operationId: string,
ruleId: string,
patch: Partial<PruneRule>
) => void
removePruneRule: (operationId: string, ruleId: string) => void
expandAllConditions: () => void
collapseAllConditions: () => void
}
function RuleEditor(ruleEditorProps: RuleEditorProps) {
const { t } = useTranslation()
const operation = ruleEditorProps.operation
const mode = operation.mode || 'set'
const meta = MODE_META[mode] || MODE_META.set
const conditions = operation.conditions
const syncFromTarget =
mode === 'sync_fields' ? parseSyncTargetSpec(operation.from) : null
const syncToTarget =
mode === 'sync_fields' ? parseSyncTargetSpec(operation.to) : null
return (
<ScrollArea className='flex-1'>
<div className='space-y-4 p-4'>
{/* Header */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Badge variant='outline'>
#{ruleEditorProps.operationIndex + 1}
</Badge>
<span className='text-muted-foreground line-clamp-1 text-xs'>
{getOperationSummary(operation, ruleEditorProps.operationIndex)}
</span>
</div>
<div className='flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => ruleEditorProps.duplicateOperation(operation.id)}
>
<Copy className='mr-1 h-3.5 w-3.5' />
{t('Duplicate')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => ruleEditorProps.removeOperation(operation.id)}
>
<Trash2 className='mr-1 h-3.5 w-3.5' />
{t('Delete')}
</Button>
</div>
</div>
{/* Operation type + path */}
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1.5'>
<label className='text-xs font-medium'>{t('Operation Type')}</label>
<Select
items={[
...OPERATION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={mode}
onValueChange={(nextMode) =>
nextMode !== null &&
ruleEditorProps.updateOperation(operation.id, {
mode: nextMode,
})
}
>
<SelectTrigger className='h-9'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{OPERATION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{(meta.path || meta.pathOptional) && (
<div className='space-y-1.5'>
<label className='text-xs font-medium'>
{t(getModePathLabel(mode))}
</label>
<Input
value={operation.path}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
path: e.target.value,
})
}
placeholder={getModePathPlaceholder(mode)}
className='h-9'
/>
</div>
)}
</div>
{/* Mode description */}
{MODE_DESCRIPTIONS[mode] && (
<p className='text-muted-foreground text-xs'>
{t(MODE_DESCRIPTIONS[mode])}
</p>
)}
{/* Description */}
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<label className='text-xs font-medium'>
{t('Rule Description (optional)')}
</label>
<span className='text-muted-foreground text-[10px]'>
{operation.description.length}/180
</span>
</div>
<Input
value={operation.description}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
description: e.target.value,
})
}
placeholder={t(
'e.g. Clean tool parameters to avoid upstream validation errors'
)}
maxLength={180}
className='h-9'
/>
</div>
{/* Value section */}
{meta.value &&
(mode === 'return_error' && ruleEditorProps.returnErrorDraft ? (
<ReturnErrorEditor
operationId={operation.id}
draft={ruleEditorProps.returnErrorDraft}
updateDraft={ruleEditorProps.updateReturnErrorDraft}
/>
) : mode === 'prune_objects' && ruleEditorProps.pruneObjectsDraft ? (
<PruneObjectsEditor
operationId={operation.id}
draft={ruleEditorProps.pruneObjectsDraft}
updateDraft={ruleEditorProps.updatePruneObjectsDraft}
addRule={ruleEditorProps.addPruneRule}
updateRule={ruleEditorProps.updatePruneRule}
removeRule={ruleEditorProps.removePruneRule}
/>
) : (
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<label className='text-xs font-medium'>
{t(getModeValueLabel(mode))}
</label>
{operation.value_text.trim().startsWith('{') && (
<Button
type='button'
variant='ghost'
size='sm'
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs'
onClick={() => {
try {
const parsed = JSON.parse(operation.value_text)
ruleEditorProps.updateOperation(operation.id, {
value_text: JSON.stringify(parsed, null, 2),
})
} catch (_e) {
/* not valid JSON */
}
}}
>
{t('Format')}
</Button>
)}
</div>
<Textarea
value={operation.value_text}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
value_text: e.target.value,
})
}
placeholder={getModeValuePlaceholder(mode)}
rows={3}
className='max-h-[200px] resize-y overflow-y-auto font-mono text-xs'
/>
</div>
))}
{/* keep_origin */}
{meta.keepOrigin && (
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<p className='text-sm font-medium'>
{t('Keep original value (skip if target exists)')}
</p>
<Switch
checked={operation.keep_origin}
onCheckedChange={(checked) =>
ruleEditorProps.updateOperation(operation.id, {
keep_origin: checked,
})
}
/>
</div>
)}
{/* sync_fields */}
{mode === 'sync_fields' && syncFromTarget && syncToTarget ? (
<SyncFieldsEditor
operationId={operation.id}
syncFromTarget={syncFromTarget}
syncToTarget={syncToTarget}
updateOperation={ruleEditorProps.updateOperation}
/>
) : (meta.from || meta.to !== undefined) && mode !== 'sync_fields' ? (
<div className='grid gap-3 sm:grid-cols-2'>
{(meta.from || meta.to === false) && (
<div className='space-y-1.5'>
<label className='text-xs font-medium'>
{t(getModeFromLabel(mode))}
</label>
<Input
value={operation.from}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
from: e.target.value,
})
}
placeholder={getModeFromPlaceholder(mode)}
className='h-9'
/>
</div>
)}
{(meta.to || meta.to === false) && (
<div className='space-y-1.5'>
<label className='text-xs font-medium'>
{t(getModeToLabel(mode))}
</label>
<Input
value={operation.to}
onChange={(e) =>
ruleEditorProps.updateOperation(operation.id, {
to: e.target.value,
})
}
placeholder={getModeToPlaceholder(mode)}
className='h-9'
/>
</div>
)}
</div>
) : null}
{/* Conditions */}
<div className='rounded-lg border p-3'>
<div className='mb-2 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Conditions')}</span>
<Select
items={[
{ value: 'OR', label: t('Match Any (OR)') },
{ value: 'AND', label: t('Match All (AND)') },
]}
value={operation.logic || 'OR'}
onValueChange={(v) =>
v !== null &&
ruleEditorProps.updateOperation(operation.id, {
logic: v,
})
}
>
<SelectTrigger className='h-7 w-[120px] text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='OR'>{t('Match Any (OR)')}</SelectItem>
<SelectItem value='AND'>{t('Match All (AND)')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-1'>
{conditions.length > 0 && (
<>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={ruleEditorProps.expandAllConditions}
>
<ChevronDown className='mr-1 h-3 w-3' />
{t('Expand All')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={ruleEditorProps.collapseAllConditions}
>
<ChevronUp className='mr-1 h-3 w-3' />
{t('Collapse All')}
</Button>
</>
)}
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => ruleEditorProps.addCondition(operation.id)}
>
<Plus className='mr-1 h-3 w-3' />
{t('Add Condition')}
</Button>
</div>
</div>
{conditions.length === 0 ? (
<p className='text-muted-foreground text-xs'>
{t('When no conditions are set, the operation always executes.')}
</p>
) : (
<div className='space-y-2'>
{conditions.map((condition, conditionIndex) => (
<ConditionEditor
key={condition.id}
condition={condition}
conditionIndex={conditionIndex}
operationId={operation.id}
expanded={
ruleEditorProps.expandedConditions[condition.id] ?? false
}
onExpandedChange={(expanded) =>
ruleEditorProps.setExpandedConditions((prev) => ({
...prev,
[condition.id]: expanded,
}))
}
updateCondition={ruleEditorProps.updateCondition}
removeCondition={ruleEditorProps.removeCondition}
/>
))}
</div>
)}
</div>
</div>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// ConditionEditor
// ---------------------------------------------------------------------------
type ConditionEditorProps = {
condition: ParamOverrideCondition
conditionIndex: number
operationId: string
expanded: boolean
onExpandedChange: (expanded: boolean) => void
updateCondition: (
operationId: string,
conditionId: string,
patch: Partial<ParamOverrideCondition>
) => void
removeCondition: (operationId: string, conditionId: string) => void
}
function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
const { t } = useTranslation()
const condition = conditionEditorProps.condition
return (
<Collapsible
open={conditionEditorProps.expanded}
onOpenChange={conditionEditorProps.onExpandedChange}
>
<div className='rounded-md border'>
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between px-3 py-2'>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='text-[10px]'>
C{conditionEditorProps.conditionIndex + 1}
</Badge>
<span className='text-muted-foreground text-xs'>
{condition.path || t('Path not set')}
</span>
</div>
{conditionEditorProps.expanded ? (
<ChevronUp className='text-muted-foreground h-3.5 w-3.5' />
) : (
<ChevronDown className='text-muted-foreground h-3.5 w-3.5' />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='space-y-3 border-t px-3 py-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-xs'>
{t('Condition Settings')}
</span>
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 text-xs'
onClick={() =>
conditionEditorProps.removeCondition(
conditionEditorProps.operationId,
condition.id
)
}
>
<Trash2 className='mr-1 h-3 w-3' />
{t('Delete Condition')}
</Button>
</div>
<div className='grid gap-2 sm:grid-cols-3'>
<div className='space-y-1'>
<label className='text-[10px] font-medium'>
{t('Field Path')}
</label>
<Input
value={condition.path}
onChange={(e) =>
conditionEditorProps.updateCondition(
conditionEditorProps.operationId,
condition.id,
{ path: e.target.value }
)
}
placeholder='model'
className='h-8 text-xs'
/>
</div>
<div className='space-y-1'>
<label className='text-[10px] font-medium'>
{t('Match Mode')}
</label>
<Select
items={[
...CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={condition.mode}
onValueChange={(v) =>
v !== null &&
conditionEditorProps.updateCondition(
conditionEditorProps.operationId,
condition.id,
{ mode: v }
)
}
>
<SelectTrigger className='h-8 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONDITION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<label className='text-[10px] font-medium'>
{t('Match Value')}
</label>
<Input
value={condition.value_text}
onChange={(e) =>
conditionEditorProps.updateCondition(
conditionEditorProps.operationId,
condition.id,
{ value_text: e.target.value }
)
}
placeholder='gpt'
className='h-8 text-xs'
/>
</div>
</div>
<div className='flex flex-wrap gap-4'>
<label className='flex items-center gap-2 text-xs'>
<Switch
checked={condition.invert}
onCheckedChange={(checked) =>
conditionEditorProps.updateCondition(
conditionEditorProps.operationId,
condition.id,
{ invert: checked }
)
}
/>
{t('Invert match')}
</label>
<label className='flex items-center gap-2 text-xs'>
<Switch
checked={condition.pass_missing_key}
onCheckedChange={(checked) =>
conditionEditorProps.updateCondition(
conditionEditorProps.operationId,
condition.id,
{ pass_missing_key: checked }
)
}
/>
{t('Pass when key is missing')}
</label>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}
// ---------------------------------------------------------------------------
// ReturnErrorEditor
// ---------------------------------------------------------------------------
type ReturnErrorEditorProps = {
operationId: string
draft: ReturnErrorDraft
updateDraft: (
operationId: string,
draftPatch: Partial<ReturnErrorDraft>
) => void
}
function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
const { t } = useTranslation()
const draft = returnErrorEditorProps.draft
return (
<div className='rounded-lg border p-3'>
<div className='mb-2 flex items-center justify-between'>
<span className='text-sm font-medium'>
{t('Custom Error Response')}
</span>
<div className='flex items-center gap-1'>
<span className='text-muted-foreground text-xs'>{t('Mode')}</span>
<Button
type='button'
variant={draft.simpleMode ? 'default' : 'outline'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ simpleMode: true }
)
}
>
{t('Simple')}
</Button>
<Button
type='button'
variant={draft.simpleMode ? 'outline' : 'default'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ simpleMode: false }
)
}
>
{t('Advanced')}
</Button>
</div>
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium'>
{t('Error Message (required)')}
</label>
<Textarea
value={draft.message}
onChange={(e) =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ message: e.target.value }
)
}
placeholder={t('e.g. This request does not meet access policy')}
rows={2}
className='text-xs'
/>
</div>
{draft.simpleMode ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Simple mode only returns message; status code and error type use system defaults.'
)}
</p>
) : (
<>
<div className='mt-3 grid gap-3 sm:grid-cols-3'>
<div className='space-y-1'>
<label className='text-xs font-medium'>{t('Status Code')}</label>
<Input
value={String(draft.statusCode ?? '')}
onChange={(e) =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ statusCode: parseInt(e.target.value, 10) || 400 }
)
}
placeholder='400'
className='h-8 text-xs'
/>
</div>
<div className='space-y-1'>
<label className='text-xs font-medium'>
{t('Error Code (optional)')}
</label>
<Input
value={draft.code}
onChange={(e) =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ code: e.target.value }
)
}
placeholder='forced_bad_request'
className='h-8 text-xs'
/>
</div>
<div className='space-y-1'>
<label className='text-xs font-medium'>
{t('Error Type (optional)')}
</label>
<Input
value={draft.type}
onChange={(e) =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ type: e.target.value }
)
}
placeholder='invalid_request_error'
className='h-8 text-xs'
/>
</div>
</div>
<div className='mt-2 flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{t('Retry Suggestion')}
</span>
<Button
type='button'
variant={draft.skipRetry ? 'default' : 'outline'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ skipRetry: true }
)
}
>
{t('Stop Retry')}
</Button>
<Button
type='button'
variant={draft.skipRetry ? 'outline' : 'default'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{ skipRetry: false }
)
}
>
{t('Allow Retry')}
</Button>
</div>
<div className='mt-2 flex flex-wrap gap-1'>
{[
{
label: 'Bad Request',
statusCode: 400,
code: 'invalid_request',
type: 'invalid_request_error',
},
{
label: 'Unauthorized',
statusCode: 401,
code: 'unauthorized',
type: 'authentication_error',
},
{
label: 'Rate Limited',
statusCode: 429,
code: 'rate_limited',
type: 'rate_limit_error',
},
].map((preset) => (
<Button
key={preset.code}
type='button'
variant='outline'
size='sm'
className='h-6 text-[10px]'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
{
statusCode: preset.statusCode,
code: preset.code,
type: preset.type,
}
)
}
>
{t(preset.label)}
</Button>
))}
</div>
</>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// PruneObjectsEditor
// ---------------------------------------------------------------------------
type PruneObjectsEditorProps = {
operationId: string
draft: PruneObjectsDraft
updateDraft: (
operationId: string,
updater:
| Partial<PruneObjectsDraft>
| ((draft: PruneObjectsDraft) => PruneObjectsDraft)
) => void
addRule: (operationId: string) => void
updateRule: (
operationId: string,
ruleId: string,
patch: Partial<PruneRule>
) => void
removeRule: (operationId: string, ruleId: string) => void
}
function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
const { t } = useTranslation()
const draft = pruneObjectsEditorProps.draft
return (
<div className='rounded-lg border p-3'>
<div className='mb-2 flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Object Prune Rules')}</span>
<div className='flex items-center gap-1'>
<span className='text-muted-foreground text-xs'>{t('Mode')}</span>
<Button
type='button'
variant={draft.simpleMode ? 'default' : 'outline'}
size='sm'
className='h-7 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
{ simpleMode: true }
)
}
>
{t('Simple')}
</Button>
<Button
type='button'
variant={draft.simpleMode ? 'outline' : 'default'}
size='sm'
className='h-7 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
{ simpleMode: false }
)
}
>
{t('Advanced')}
</Button>
</div>
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium'>{t('Type (common)')}</label>
<Input
value={draft.typeText}
onChange={(e) =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
{ typeText: e.target.value }
)
}
placeholder='redacted_thinking'
className='h-8 text-xs'
/>
</div>
{draft.simpleMode ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t('Simple mode: prune objects by type, e.g. redacted_thinking.')}
</p>
) : (
<>
<div className='mt-3 grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<label className='text-xs font-medium'>{t('Logic')}</label>
<Select
items={[
{ value: 'AND', label: t('All Must Match (AND)') },
{ value: 'OR', label: t('Any Match (OR)') },
]}
value={draft.logic}
onValueChange={(v) =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
{ logic: v || 'AND' }
)
}
>
<SelectTrigger className='h-8 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='AND'>
{t('All Must Match (AND)')}
</SelectItem>
<SelectItem value='OR'>{t('Any Match (OR)')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<label className='text-xs font-medium'>
{t('Recursion Strategy')}
</label>
<div className='flex gap-1'>
<Button
type='button'
variant={draft.recursive ? 'default' : 'outline'}
size='sm'
className='h-8 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
{ recursive: true }
)
}
>
{t('Recursive')}
</Button>
<Button
type='button'
variant={draft.recursive ? 'outline' : 'default'}
size='sm'
className='h-8 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
{ recursive: false }
)
}
>
{t('Current Level Only')}
</Button>
</div>
</div>
</div>
<div className='bg-muted/30 mt-3 rounded-md border p-2'>
<div className='mb-2 flex items-center justify-between'>
<span className='text-xs font-medium'>
{t('Additional Conditions')}
</span>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
pruneObjectsEditorProps.addRule(
pruneObjectsEditorProps.operationId
)
}
>
<Plus className='mr-1 h-3 w-3' />
{t('Add Condition')}
</Button>
</div>
{draft.rules.length === 0 ? (
<p className='text-muted-foreground text-xs'>
{t(
'Without additional conditions, only the type above is used for pruning.'
)}
</p>
) : (
<div className='space-y-2'>
{draft.rules.map((rule, ruleIndex) => (
<div
key={rule.id}
className='bg-background rounded-md border p-2'
>
<div className='mb-1 flex items-center justify-between'>
<Badge variant='outline' className='text-[10px]'>
R{ruleIndex + 1}
</Badge>
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-6 text-[10px]'
onClick={() =>
pruneObjectsEditorProps.removeRule(
pruneObjectsEditorProps.operationId,
rule.id
)
}
>
<Trash2 className='mr-1 h-3 w-3' />
{t('Delete')}
</Button>
</div>
<div className='grid gap-2 sm:grid-cols-3'>
<div className='space-y-0.5'>
<label className='text-[10px] font-medium'>
{t('Field Path')}
</label>
<Input
value={rule.path}
onChange={(e) =>
pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId,
rule.id,
{ path: e.target.value }
)
}
placeholder='type'
className='h-7 text-xs'
/>
</div>
<div className='space-y-0.5'>
<label className='text-[10px] font-medium'>
{t('Match Mode')}
</label>
<Select
items={[
...CONDITION_MODE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={rule.mode}
onValueChange={(v) =>
v !== null &&
pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId,
rule.id,
{ mode: v }
)
}
>
<SelectTrigger className='h-7 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONDITION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-0.5'>
<label className='text-[10px] font-medium'>
{t('Match Value (optional)')}
</label>
<Input
value={rule.value_text}
onChange={(e) =>
pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId,
rule.id,
{ value_text: e.target.value }
)
}
placeholder='redacted_thinking'
className='h-7 text-xs'
/>
</div>
</div>
<div className='mt-1.5 flex flex-wrap gap-3'>
<label className='flex items-center gap-1.5 text-[10px]'>
<Switch
checked={rule.invert}
onCheckedChange={(checked) =>
pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId,
rule.id,
{ invert: checked }
)
}
/>
{t('Invert match')}
</label>
<label className='flex items-center gap-1.5 text-[10px]'>
<Switch
checked={rule.pass_missing_key}
onCheckedChange={(checked) =>
pruneObjectsEditorProps.updateRule(
pruneObjectsEditorProps.operationId,
rule.id,
{ pass_missing_key: checked }
)
}
/>
{t('Pass when key is missing')}
</label>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SyncFieldsEditor
// ---------------------------------------------------------------------------
type SyncFieldsEditorProps = {
operationId: string
syncFromTarget: { type: string; key: string }
syncToTarget: { type: string; key: string }
updateOperation: (
operationId: string,
patch: Partial<ParamOverrideOperation>
) => void
}
function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
const { t } = useTranslation()
return (
<div className='space-y-3'>
<label className='text-xs font-medium'>{t('Sync Endpoints')}</label>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1.5'>
<label className='text-[10px] font-medium'>
{t('Source Endpoint')}
</label>
<div className='flex gap-2'>
<Select
items={[
...SYNC_TARGET_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={syncFieldsEditorProps.syncFromTarget.type || 'json'}
onValueChange={(v) =>
v !== null &&
syncFieldsEditorProps.updateOperation(
syncFieldsEditorProps.operationId,
{
from: buildSyncTargetSpec(
v,
syncFieldsEditorProps.syncFromTarget.key
),
}
)
}
>
<SelectTrigger className='h-8 w-[110px] text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{SYNC_TARGET_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
value={syncFieldsEditorProps.syncFromTarget.key}
onChange={(e) =>
syncFieldsEditorProps.updateOperation(
syncFieldsEditorProps.operationId,
{
from: buildSyncTargetSpec(
syncFieldsEditorProps.syncFromTarget.type,
e.target.value
),
}
)
}
placeholder='session_id'
className='h-8 text-xs'
/>
</div>
</div>
<div className='space-y-1.5'>
<label className='text-[10px] font-medium'>
{t('Target Endpoint')}
</label>
<div className='flex gap-2'>
<Select
items={[
...SYNC_TARGET_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={syncFieldsEditorProps.syncToTarget.type || 'json'}
onValueChange={(v) =>
v !== null &&
syncFieldsEditorProps.updateOperation(
syncFieldsEditorProps.operationId,
{
to: buildSyncTargetSpec(
v,
syncFieldsEditorProps.syncToTarget.key
),
}
)
}
>
<SelectTrigger className='h-8 w-[110px] text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{SYNC_TARGET_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
value={syncFieldsEditorProps.syncToTarget.key}
onChange={(e) =>
syncFieldsEditorProps.updateOperation(
syncFieldsEditorProps.operationId,
{
to: buildSyncTargetSpec(
syncFieldsEditorProps.syncToTarget.type,
e.target.value
),
}
)
}
placeholder='prompt_cache_key'
className='h-8 text-xs'
/>
</div>
</div>
</div>
<div className='flex flex-wrap gap-1'>
{[
{
label: 'header:session_id -> json:prompt_cache_key',
from: 'header:session_id',
to: 'json:prompt_cache_key',
},
{
label: 'json:prompt_cache_key -> header:session_id',
from: 'json:prompt_cache_key',
to: 'header:session_id',
},
].map((preset) => (
<Button
key={preset.label}
type='button'
variant='outline'
size='sm'
className='h-6 text-[10px]'
onClick={() =>
syncFieldsEditorProps.updateOperation(
syncFieldsEditorProps.operationId,
{ from: preset.from, to: preset.to }
)
}
>
{preset.label}
</Button>
))}
</div>
</div>
)
}