diff --git a/web/public/locales/en/translation.json b/web/public/locales/en/translation.json index 2cf41887..79b161b2 100644 --- a/web/public/locales/en/translation.json +++ b/web/public/locales/en/translation.json @@ -408,6 +408,9 @@ "updateTitle": "Update Model", "createDescription": "Add a new model to the system", "updateDescription": "Update the model information", + "confirmUpdateTitle": "Confirm model config update", + "confirmUpdateDescription": "Review these changes before saving. Existing fields that this form does not manage will be preserved.", + "confirmUpdate": "Confirm Update", "modelName": "Model Name", "modelNamePlaceholder": "Enter model name", "ownerPlaceholder": "Enter owner, optional", @@ -445,10 +448,20 @@ "recordClaudeLongContextDescription": "Track an extra summary bucket only when the model is Claude and input tokens are greater than 200,000.", "disableResolutionFuzzyMatch": "Disable Resolution Fuzzy Match", "disableResolutionFuzzyMatchDescription": "Require conditional prices to match resolution exactly. When disabled, common size formats can fall back to normalized resolution tiers.", + "timeoutConfig": "Timeout Config", "create": "Create", "update": "Update", "submitting": "Submitting...", "modelNameUpdateDisabled": "Model name cannot be modified in update mode", + "changeSummary": { + "field": "Field", + "before": "Current", + "after": "After Update", + "unset": "Not Set", + "none": "None", + "configured": "Configured", + "changed": "Changed" + }, "config": { "title": "Model Display Config", "description": "Configure descriptive display fields for the model", diff --git a/web/public/locales/zh/translation.json b/web/public/locales/zh/translation.json index 93156dbc..c6442d15 100644 --- a/web/public/locales/zh/translation.json +++ b/web/public/locales/zh/translation.json @@ -397,6 +397,9 @@ "updateTitle": "更新模型", "createDescription": "向系统添加新模型", "updateDescription": "更新模型信息", + "confirmUpdateTitle": "确认更新模型配置", + "confirmUpdateDescription": "保存前请确认下列变化。未在表单中管理的已有字段会保留。", + "confirmUpdate": "确认更新", "modelName": "模型名称", "modelNamePlaceholder": "输入模型名称", "ownerPlaceholder": "输入所有者,可留空", @@ -434,10 +437,20 @@ "recordClaudeLongContextDescription": "仅当模型为 Claude 且输入 Token 大于 200,000 时,记录额外的长上下文汇总桶。", "disableResolutionFuzzyMatch": "禁用分辨率模糊匹配", "disableResolutionFuzzyMatchDescription": "要求条件价格中的分辨率完全匹配。关闭时,常见尺寸格式可回退匹配到归一化后的分辨率档位。", + "timeoutConfig": "超时配置", "create": "创建", "update": "更新", "submitting": "提交中...", "modelNameUpdateDisabled": "模型名称在更新模式下不可修改", + "changeSummary": { + "field": "字段", + "before": "当前配置", + "after": "更新后", + "unset": "未设置", + "none": "无", + "configured": "已配置", + "changed": "已变化" + }, "config": { "title": "模型展示配置", "description": "配置模型的展示说明字段", diff --git a/web/src/feature/model/components/BuiltinModelsDialog.tsx b/web/src/feature/model/components/BuiltinModelsDialog.tsx index 386b8e14..cda13501 100644 --- a/web/src/feature/model/components/BuiltinModelsDialog.tsx +++ b/web/src/feature/model/components/BuiltinModelsDialog.tsx @@ -40,7 +40,7 @@ interface BuiltinModelsDialogProps { onOpenChange: (open: boolean) => void; existingModels: ModelConfig[]; onCreateFromBuiltin: (model: ModelConfig) => void; - onEditFromBuiltin: (model: ModelConfig) => void; + onEditFromBuiltin: (builtinModel: ModelConfig, existingModel: ModelConfig) => void; } interface BuiltinModelRow { @@ -102,13 +102,18 @@ export function BuiltinModelsDialog({ const [selectedModels, setSelectedModels] = useState>(new Set()); const [showExistingModels, setShowExistingModels] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [savingRowKey, setSavingRowKey] = useState(null); + const [savingRowKeys, setSavingRowKeys] = useState>(new Set()); const existingModelSet = useMemo( () => new Set(existingModels.map((model) => model.model)), [existingModels] ); + const existingModelMap = useMemo( + () => new Map(existingModels.map((model) => [model.model, model])), + [existingModels] + ); + const channelTypeOptions = useMemo(() => { return Object.keys(channelBuiltinModels || {}) .map(Number) @@ -292,7 +297,7 @@ export function BuiltinModelsDialog({ const handleAddBuiltin = async (row: BuiltinModelRow) => { const rowKey = getBuiltinModelRowKey(row); - setSavingRowKey(rowKey); + setSavingRowKeys((prev) => new Set(prev).add(rowKey)); try { await modelApi.saveModels([toModelSaveRequest(row.model)]); @@ -306,7 +311,11 @@ export function BuiltinModelsDialog({ } catch (error) { toast.error(error instanceof Error ? error.message : t("model.builtin.importFailed")); } finally { - setSavingRowKey(null); + setSavingRowKeys((prev) => { + const next = new Set(prev); + next.delete(rowKey); + return next; + }); } }; @@ -444,8 +453,10 @@ export function BuiltinModelsDialog({ const model = row.model; const rowKey = getBuiltinModelRowKey(row); const exists = existingModelSet.has(model.model); + const existingModel = existingModelMap.get(model.model); const selected = selectedModels.has(rowKey); const disabled = !isRowSelectable(row); + const rowSaving = savingRowKeys.has(rowKey); const configBadges = [ model.config?.vision && "vision", model.config?.tool_choice && "tool", @@ -499,7 +510,9 @@ export function BuiltinModelsDialog({ className="h-7 px-2 text-xs" onClick={(event) => { event.stopPropagation(); - onEditFromBuiltin(model); + if (existingModel) { + onEditFromBuiltin(model, existingModel); + } }} > {t("model.builtin.editFromThis")} @@ -522,15 +535,13 @@ export function BuiltinModelsDialog({ type="button" size="sm" className="h-7 px-2 text-xs" - disabled={savingRowKey === rowKey} + disabled={rowSaving} onClick={(event) => { event.stopPropagation(); void handleAddBuiltin(row); }} > - {savingRowKey === rowKey - ? t("model.builtin.importing") - : t("model.builtin.add")} + {rowSaving ? t("model.builtin.importing") : t("model.builtin.add")} )} diff --git a/web/src/feature/model/components/ModelDialog.tsx b/web/src/feature/model/components/ModelDialog.tsx index c7315af1..3c810285 100644 --- a/web/src/feature/model/components/ModelDialog.tsx +++ b/web/src/feature/model/components/ModelDialog.tsx @@ -23,6 +23,7 @@ interface ModelDialogProps { onOpenChange: (open: boolean) => void mode: 'create' | 'update' model?: ModelConfig | null + baseModelConfig?: ModelConfig | null preserveModelNameOnCreate?: boolean } @@ -31,6 +32,7 @@ export function ModelDialog({ onOpenChange, mode = 'create', model = null, + baseModelConfig = model, preserveModelNameOnCreate = false }: ModelDialogProps) { const { t } = useTranslation() @@ -80,7 +82,7 @@ export function ModelDialog({ onOpenChange(false)} /> diff --git a/web/src/feature/model/components/ModelForm.tsx b/web/src/feature/model/components/ModelForm.tsx index 53dc2858..05cb5bd1 100644 --- a/web/src/feature/model/components/ModelForm.tsx +++ b/web/src/feature/model/components/ModelForm.tsx @@ -16,6 +16,16 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Select, SelectContent, @@ -50,6 +60,18 @@ import { ENV } from '@/utils/env' import { PriceFormFields } from '@/components/price/PriceFormFields' import { ValidationErrorDisplay } from '@/components/common/error/validationErrorDisplay' +type PendingUpdate = { + model: string + data: Omit + changes: ChangeSummary[] +} + +type ChangeSummary = { + field: string + before: string + after: string +} + const KNOWN_PRICE_KEYS = new Set([ 'input_price', 'input_price_unit', @@ -156,6 +178,143 @@ const omitKeys = (obj: object, keys: string[]) => { return Object.fromEntries(Object.entries(obj).filter(([key]) => !omitted.has(key))) } +const normalizeForCompare = (value: unknown): unknown => { + if (value === undefined || value === null || value === '' || value === false) return null + if (Array.isArray(value)) { + const normalizedItems: unknown[] = value.map(normalizeForCompare).filter((item) => item !== null) + return normalizedItems.length > 0 ? normalizedItems : null + } + if (typeof value === 'object') { + const normalizedEntries: Array = Object.entries(value as Record) + .map(([key, item]) => [key, normalizeForCompare(item)] as const) + .filter(([, item]) => item !== null) + .sort(([left], [right]) => left.localeCompare(right)) + + return normalizedEntries.length > 0 ? Object.fromEntries(normalizedEntries) : null + } + return value +} + +const isSameValue = (before: unknown, after: unknown) => + JSON.stringify(normalizeForCompare(before)) === JSON.stringify(normalizeForCompare(after)) + +const formatScalar = (value: unknown, labels: Record) => { + if (value === undefined || value === null || value === '') return labels.unset + if (typeof value === 'boolean') return value ? labels.yes : labels.no + if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : labels.none + return String(value) +} + +const summarizeStructuredChange = ( + before: unknown, + after: unknown, + labels: Record +) => { + if (isSameValue(before, after)) return null + const normalizedBefore = normalizeForCompare(before) + const normalizedAfter = normalizeForCompare(after) + return { + before: normalizedBefore == null ? labels.unset : labels.configured, + after: normalizedAfter == null ? labels.unset : labels.changed, + } +} + +const pickManagedFields = ( + source: Record | undefined, + keys: Set +) => { + if (!source) return undefined + + const picked = Object.fromEntries( + Object.entries(source).filter(([key]) => keys.has(key)) + ) + + return Object.keys(picked).length > 0 ? picked : undefined +} + +const normalizePluginForCompare = (plugin: unknown) => { + const source = plugin as Partial>> | undefined + if (!source) return undefined + + return { + cache: pickManagedFields(source.cache, MANAGED_PLUGIN_KEYS.cache), + cachefollow: pickManagedFields(source.cachefollow, MANAGED_PLUGIN_KEYS.cachefollow), + 'web-search': pickManagedFields(source['web-search'], MANAGED_PLUGIN_KEYS['web-search']), + 'think-split': pickManagedFields(source['think-split'], MANAGED_PLUGIN_KEYS['think-split']), + 'stream-fake': pickManagedFields(source['stream-fake'], MANAGED_PLUGIN_KEYS['stream-fake']), + } +} + +const buildChangeSummaries = ( + original: ModelConfig | null | undefined, + next: Omit, + labels: Record, + options: { includePlugin?: boolean } = {} +): ChangeSummary[] => { + if (!original) return [] + + const changes: ChangeSummary[] = [] + const addScalar = (key: keyof ModelConfig, label: string, afterValue: unknown) => { + const beforeValue = original[key] + if (!isSameValue(beforeValue, afterValue)) { + changes.push({ + field: label, + before: formatScalar(beforeValue, labels), + after: formatScalar(afterValue, labels), + }) + } + } + + addScalar('owner', labels.owner, next.owner) + addScalar('type', labels.type, next.type) + addScalar('exclude_from_tests', labels.excludeFromTests, next.exclude_from_tests) + addScalar('rpm', labels.rpm, next.rpm) + addScalar('tpm', labels.tpm, next.tpm) + addScalar('retry_times', labels.retryTimes, next.retry_times) + addScalar('force_save_detail', labels.forceSaveDetail, next.force_save_detail) + addScalar('summary_service_tier', labels.summaryServiceTier, next.summary_service_tier) + addScalar('summary_claude_long_context', labels.summaryClaudeLongContext, next.summary_claude_long_context) + addScalar('disable_resolution_fuzzy_match', labels.disableResolutionFuzzyMatch, next.disable_resolution_fuzzy_match) + addScalar('allowed_resolutions', labels.allowedResolutions, next.allowed_resolutions) + addScalar('request_body_storage_max_size', labels.requestBodyStorageMaxSize, next.request_body_storage_max_size) + addScalar('response_body_storage_max_size', labels.responseBodyStorageMaxSize, next.response_body_storage_max_size) + addScalar('max_image_generation_count', labels.maxImageGenerationCount, next.max_image_generation_count) + addScalar('max_video_generation_seconds', labels.maxVideoGenerationSeconds, next.max_video_generation_seconds) + addScalar('max_video_generation_count', labels.maxVideoGenerationCount, next.max_video_generation_count) + + const timeoutChange = summarizeStructuredChange( + original.timeout_config, + next.timeout_config, + labels + ) + if (timeoutChange) { + changes.push({ field: labels.timeoutConfig, ...timeoutChange }) + } + + const configChange = summarizeStructuredChange(original.config, next.config, labels) + if (configChange) { + changes.push({ field: labels.displayConfig, ...configChange }) + } + + const priceChange = summarizeStructuredChange(original.price, next.price, labels) + if (priceChange) { + changes.push({ field: labels.priceConfig, ...priceChange }) + } + + if (options.includePlugin) { + const pluginChange = summarizeStructuredChange( + normalizePluginForCompare(original.plugin), + normalizePluginForCompare(next.plugin), + labels + ) + if (pluginChange) { + changes.push({ field: labels.pluginConfig, ...pluginChange }) + } + } + + return changes +} + interface ModelFormProps { mode?: 'create' | 'update' onSuccess?: () => void @@ -204,6 +363,7 @@ export function ModelForm({ const [priceExpanded, setPriceExpanded] = useState(false) const [cachePluginExpanded, setCachePluginExpanded] = useState(false) const [webSearchPluginExpanded, setWebSearchPluginExpanded] = useState(false) + const [pendingUpdate, setPendingUpdate] = useState(null) const [configExtrasText, setConfigExtrasText] = useState(() => { const extras = Object.fromEntries( Object.entries(defaultValues.config || {}).filter(([key]) => !KNOWN_CONFIG_KEYS.has(key)) @@ -293,6 +453,34 @@ export function ModelForm({ const supportVideoGenerationSecondsLimit = VIDEO_GENERATION_SECONDS_LIMIT_SUPPORTED_TYPES.has(watchedType) const supportVideoGenerationCountLimit = VIDEO_GENERATION_COUNT_LIMIT_SUPPORTED_TYPES.has(watchedType) const supportResolutionFuzzyMatchConfig = RESOLUTION_FUZZY_MATCH_SUPPORTED_TYPES.has(watchedType) + const changeSummaryLabels = { + owner: t("model.owner"), + type: t("model.dialog.modelType"), + excludeFromTests: t("model.dialog.excludeFromTests"), + rpm: t("model.dialog.rpm"), + tpm: t("model.dialog.tpm"), + retryTimes: t("model.dialog.retryTimes"), + forceSaveDetail: t("model.dialog.forceSaveDetail"), + summaryServiceTier: t("model.dialog.recordServiceTier"), + summaryClaudeLongContext: t("model.dialog.recordClaudeLongContext"), + disableResolutionFuzzyMatch: t("model.dialog.disableResolutionFuzzyMatch"), + allowedResolutions: t("model.dialog.config.allowedResolutions"), + requestBodyStorageMaxSize: t("model.dialog.requestBodyStorageMaxSize"), + responseBodyStorageMaxSize: t("model.dialog.responseBodyStorageMaxSize"), + maxImageGenerationCount: t("model.dialog.maxImageGenerationCount"), + maxVideoGenerationSeconds: t("model.dialog.maxVideoGenerationSeconds"), + maxVideoGenerationCount: t("model.dialog.maxVideoGenerationCount"), + timeoutConfig: t("model.dialog.timeoutConfig"), + displayConfig: t("model.dialog.config.title"), + priceConfig: t("group.price.title"), + pluginConfig: t("model.dialog.pluginConfiguration"), + unset: t("model.dialog.changeSummary.unset"), + none: t("model.dialog.changeSummary.none"), + yes: t("common.yes"), + no: t("common.no"), + configured: t("model.dialog.changeSummary.configured"), + changed: t("model.dialog.changeSummary.changed"), + } const configFieldVisibility = (() => { switch (watchedType) { @@ -408,6 +596,23 @@ export function ModelForm({ form.setValue('plugin.web-search.search_from', [...currentEngines, newEngine]) } + const submitUpdate = (model: string, data: Omit) => { + updateModel({ + model, + data, + }, { + onSuccess: () => { + if (onSuccess) onSuccess() + } + }) + } + + const confirmPendingUpdate = () => { + if (!pendingUpdate) return + submitUpdate(pendingUpdate.model, pendingUpdate.data) + setPendingUpdate(null) + } + // Remove search engine const removeSearchEngine = (index: number) => { const currentEngines = form.getValues('plugin.web-search.search_from') || [] @@ -754,21 +959,73 @@ export function ModelForm({ } }) } else { - // For update mode, use the model name as the identifier - updateModel({ - model: data.model, - data: formData - }, { - onSuccess: () => { - // Notify parent component - if (onSuccess) onSuccess() - } + const includePluginChange = !isSameValue( + normalizePluginForCompare(baseModelConfig?.plugin), + normalizePluginForCompare(defaultValues.plugin) + ) || form.formState.dirtyFields.plugin !== undefined + const changes = buildChangeSummaries(baseModelConfig, formData, changeSummaryLabels, { + includePlugin: includePluginChange, }) + if (changes.length > 0) { + setPendingUpdate({ + model: data.model, + data: formData, + changes, + }) + return + } + + submitUpdate(data.model, formData) } } return (
+ { + if (!open) { + setPendingUpdate(null) + } + }} + > + + + {t("model.dialog.confirmUpdateTitle")} + + {t("model.dialog.confirmUpdateDescription")} + + +
+
+ {t("model.dialog.changeSummary.field")} + {t("model.dialog.changeSummary.before")} + {t("model.dialog.changeSummary.after")} +
+
+ {pendingUpdate?.changes.map((change) => ( +
+ {change.field} + {change.before} + {change.after} +
+ ))} +
+
+ + + {t("common.cancel")} + + + {isLoading ? t("model.dialog.submitting") : t("model.dialog.confirmUpdate")} + + +
+
+ {/* 使用简化的验证错误显示组件 */} >} diff --git a/web/src/feature/model/components/ModelTable.tsx b/web/src/feature/model/components/ModelTable.tsx index 2c502ed0..302c5a95 100644 --- a/web/src/feature/model/components/ModelTable.tsx +++ b/web/src/feature/model/components/ModelTable.tsx @@ -76,6 +76,7 @@ export function ModelTable() { const [selectedModelId, setSelectedModelId] = useState(null); const [dialogMode, setDialogMode] = useState<"create" | "update">("create"); const [selectedModel, setSelectedModel] = useState(null); + const [baseModelConfig, setBaseModelConfig] = useState(null); const [preserveModelNameOnCreate, setPreserveModelNameOnCreate] = useState(false); const [isRefreshAnimating, setIsRefreshAnimating] = useState(false); const [isImporting, setIsImporting] = useState(false); @@ -554,6 +555,7 @@ export function ModelTable() { const openCreateDialog = () => { setDialogMode("create"); setSelectedModel(null); + setBaseModelConfig(null); setPreserveModelNameOnCreate(false); setModelDialogOpen(true); }; @@ -562,6 +564,7 @@ export function ModelTable() { const openUpdateDialog = (model: ModelConfig) => { setDialogMode("update"); setSelectedModel(model); + setBaseModelConfig(model); setPreserveModelNameOnCreate(false); setModelDialogOpen(true); }; @@ -570,6 +573,7 @@ export function ModelTable() { const openCopyDialog = (model: ModelConfig) => { setDialogMode("create"); setSelectedModel(model); + setBaseModelConfig(model); setPreserveModelNameOnCreate(false); setModelDialogOpen(true); }; @@ -577,13 +581,15 @@ export function ModelTable() { const openCreateFromBuiltinDialog = (model: ModelConfig) => { setDialogMode("create"); setSelectedModel(model); + setBaseModelConfig(model); setPreserveModelNameOnCreate(true); setModelDialogOpen(true); }; - const openEditFromBuiltinDialog = (model: ModelConfig) => { + const openEditFromBuiltinDialog = (builtinModel: ModelConfig, existingModel: ModelConfig) => { setDialogMode("update"); - setSelectedModel(model); + setSelectedModel(builtinModel); + setBaseModelConfig(existingModel); setPreserveModelNameOnCreate(false); setModelDialogOpen(true); }; @@ -828,6 +834,7 @@ export function ModelTable() { onOpenChange={setModelDialogOpen} mode={dialogMode} model={selectedModel} + baseModelConfig={baseModelConfig} preserveModelNameOnCreate={preserveModelNameOnCreate} />