Unify ComfyUI ingress model ID and fix seed validation errors.
CI / docker (push) Successful in 3m46s

Merge model_id/alias in the image provider UI, and ensure ComfyUI never receives seed -1 after cloneMap type coercion.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-24 14:17:49 +08:00
parent 76ba500417
commit e761074fb9
8 changed files with 192 additions and 34 deletions
+6
View File
@@ -275,6 +275,9 @@ func (h *Handler) CreateModel(c *gin.Context) {
VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON), VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON),
Enabled: true, Enabled: true,
} }
if prov.Category == model.CategoryImage {
m.Alias = ""
}
if m.DisplayName == "" { if m.DisplayName == "" {
m.DisplayName = req.ModelID m.DisplayName = req.ModelID
} }
@@ -347,6 +350,9 @@ func (h *Handler) UpdateModel(c *gin.Context) {
m.InputBindingJSON = string(b) m.InputBindingJSON = string(b)
} }
prov, _ := h.cache.GetProvider(m.ProviderID) prov, _ := h.cache.GetProvider(m.ProviderID)
if prov.Category == model.CategoryImage {
m.Alias = ""
}
if prov.Category == model.CategoryImage && m.WorkflowJSON != "" { if prov.Category == model.CategoryImage && m.WorkflowJSON != "" {
if err := validateImageModelCreate(struct { if err := validateImageModelCreate(struct {
ModelID, DisplayName string ModelID, DisplayName string
+53 -4
View File
@@ -22,10 +22,7 @@ func BuildPrompt(workflowJSON string, binding InputBinding, input map[string]any
continue continue
} }
if name == "seed" { if name == "seed" {
if iv, ok := val.(int); ok && iv < 0 { val = resolveSeed(val)
n, _ := rand.Int(rand.Reader, big.NewInt(1<<31-1))
val = int(n.Int64())
}
} }
node, ok := prompt[target.Node].(map[string]any) node, ok := prompt[target.Node].(map[string]any)
if !ok { if !ok {
@@ -37,9 +34,61 @@ func BuildPrompt(workflowJSON string, binding InputBinding, input map[string]any
} }
inputs[target.Field] = val inputs[target.Field] = val
} }
sanitizeSamplerSeeds(prompt)
return prompt, nil return prompt, nil
} }
// resolveSeed maps seed < 0 (random) to a non-negative value ComfyUI accepts.
func resolveSeed(val any) any {
n, ok := seedAsInt64(val)
if !ok || n >= 0 {
return val
}
r, err := rand.Int(rand.Reader, big.NewInt(1<<31-1))
if err != nil {
return int(0)
}
return int(r.Int64())
}
func seedAsInt64(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int64:
return n, true
case float64:
return int64(n), true
case json.Number:
i, err := n.Int64()
return i, err == nil
default:
return 0, false
}
}
func sanitizeSamplerSeeds(prompt map[string]any) {
seedFields := []string{"seed", "noise_seed"}
for _, raw := range prompt {
node, ok := raw.(map[string]any)
if !ok {
continue
}
if class, _ := node["class_type"].(string); class != "KSampler" {
continue
}
inputs, ok := node["inputs"].(map[string]any)
if !ok {
continue
}
for _, field := range seedFields {
if v, ok := inputs[field]; ok {
inputs[field] = resolveSeed(v)
}
}
}
}
func deepCopyMap(src map[string]any) map[string]any { func deepCopyMap(src map[string]any) map[string]any {
b, _ := json.Marshal(src) b, _ := json.Marshal(src)
var dst map[string]any var dst map[string]any
+75
View File
@@ -0,0 +1,75 @@
package prediction
import (
"encoding/json"
"testing"
)
func TestResolveSeedNegativeInt(t *testing.T) {
got := resolveSeed(-1)
n, ok := got.(int)
if !ok || n < 0 {
t.Fatalf("expected non-negative int, got %v", got)
}
}
func TestResolveSeedNegativeFloat64(t *testing.T) {
got := resolveSeed(float64(-1))
n, ok := got.(int)
if !ok || n < 0 {
t.Fatalf("expected non-negative int from float64 -1, got %v", got)
}
}
func TestResolveSeedExplicit(t *testing.T) {
if got := resolveSeed(42); got != 42 {
t.Fatalf("expected 42, got %v", got)
}
}
func TestBuildPromptSanitizesUnboundKSamplerSeed(t *testing.T) {
workflow := map[string]any{
"17": map[string]any{
"class_type": "KSampler",
"inputs": map[string]any{
"seed": float64(-1),
"steps": 20,
},
},
}
raw, _ := json.Marshal(workflow)
prompt, err := BuildPrompt(string(raw), InputBinding{}, map[string]any{})
if err != nil {
t.Fatal(err)
}
node := prompt["17"].(map[string]any)
inputs := node["inputs"].(map[string]any)
seed, ok := inputs["seed"].(int)
if !ok || seed < 0 {
t.Fatalf("expected sanitized seed, got %v", inputs["seed"])
}
}
func TestBuildPromptSeedBindingAfterCloneMap(t *testing.T) {
workflow := map[string]any{
"17": map[string]any{
"class_type": "KSampler",
"inputs": map[string]any{"seed": 0},
},
}
raw, _ := json.Marshal(workflow)
binding := InputBinding{
"seed": {Node: "17", Field: "seed"},
}
// cloneMap round-trip produces float64, as in execute().
input := cloneMap(map[string]any{"seed": -1})
prompt, err := BuildPrompt(string(raw), binding, input)
if err != nil {
t.Fatal(err)
}
inputs := prompt["17"].(map[string]any)["inputs"].(map[string]any)
seed, ok := inputs["seed"].(int)
if !ok || seed < 0 {
t.Fatalf("expected non-negative seed after binding, got %v", inputs["seed"])
}
}
+4 -8
View File
@@ -1,11 +1,9 @@
<template> <template>
<el-dialog v-model="visible" :title="t('providers.workflowDialogTitle')" width="720px" destroy-on-close @closed="reset"> <el-dialog v-model="visible" :title="t('providers.workflowDialogTitle')" width="720px" destroy-on-close @closed="reset">
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item :label="t('providers.modelId')"> <el-form-item :label="t('providers.ingressModelId')" required>
<el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" /> <el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" />
</el-form-item> <p class="form-hint">{{ t('providers.ingressModelIdHint') }}</p>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="form.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
</el-form-item> </el-form-item>
<el-form-item :label="t('providers.displayName')"> <el-form-item :label="t('providers.displayName')">
<el-input v-model="form.display_name" /> <el-input v-model="form.display_name" />
@@ -92,7 +90,6 @@ const bindings = ref<BindingRow[]>([])
const form = ref({ const form = ref({
model_id: '', model_id: '',
alias: '',
display_name: '', display_name: '',
workflow_type: 'txt2img', workflow_type: 'txt2img',
workflow_json: '', workflow_json: '',
@@ -103,7 +100,7 @@ const requiredParams = computed(() =>
) )
function reset() { function reset() {
form.value = { model_id: '', alias: '', display_name: '', workflow_type: 'txt2img', workflow_json: '' } form.value = { model_id: '', display_name: '', workflow_type: 'txt2img', workflow_json: '' }
nodes.value = [] nodes.value = []
bindings.value = [] bindings.value = []
} }
@@ -158,14 +155,13 @@ function buildBinding() {
async function save() { async function save() {
if (!form.value.model_id.trim()) { if (!form.value.model_id.trim()) {
ElMessage.warning(t('providers.modelIdRequired')) ElMessage.warning(t('providers.ingressModelIdRequired'))
return return
} }
saving.value = true saving.value = true
try { try {
await api.post(`/providers/${props.providerId}/models`, { await api.post(`/providers/${props.providerId}/models`, {
model_id: form.value.model_id.trim(), model_id: form.value.model_id.trim(),
alias: form.value.alias.trim(),
display_name: form.value.display_name || form.value.model_id, display_name: form.value.display_name || form.value.model_id,
workflow_type: form.value.workflow_type, workflow_type: form.value.workflow_type,
workflow_json: form.value.workflow_json, workflow_json: form.value.workflow_json,
+3
View File
@@ -122,6 +122,9 @@ export default {
addModel: 'Add Model', addModel: 'Add Model',
addModelDialogTitle: 'Add Model', addModelDialogTitle: 'Add Model',
modelId: 'Model ID', modelId: 'Model ID',
ingressModelId: 'Ingress Model ID',
ingressModelIdHint: 'Clients use this name in the version field of /v1/predictions',
ingressModelIdRequired: 'Ingress Model ID is required',
modelAlias: 'Ingress alias', modelAlias: 'Ingress alias',
modelAliasPlaceholder: 'Optional; ingress API uses this name', modelAliasPlaceholder: 'Optional; ingress API uses this name',
modelAliasHint: 'Leave empty to use upstream Model ID at ingress', modelAliasHint: 'Leave empty to use upstream Model ID at ingress',
+3
View File
@@ -144,6 +144,9 @@ export default {
confirmDeleteModel: '确认删除此模型?', confirmDeleteModel: '确认删除此模型?',
workflowDialogTitle: '注册 Workflow 模型', workflowDialogTitle: '注册 Workflow 模型',
modelId: 'Model ID', modelId: 'Model ID',
ingressModelId: '入口 Model ID',
ingressModelIdHint: '客户端在 /v1/predictions 的 version 字段使用此名称',
ingressModelIdRequired: '请填写入口 Model ID',
modelAlias: '入口别名', modelAlias: '入口别名',
modelAliasPlaceholder: '可选,入口 API 使用此名称', modelAliasPlaceholder: '可选,入口 API 使用此名称',
modelAliasHint: '留空则入口使用上游 Model ID', modelAliasHint: '留空则入口使用上游 Model ID',
+2 -4
View File
@@ -313,11 +313,9 @@ async function loadCatalog() {
const ingressId = m.alias || m.model_id const ingressId = m.alias || m.model_id
models.push({ models.push({
model_id: ingressId, model_id: ingressId,
display_name: m.display_name || m.model_id, display_name: m.display_name || ingressId,
provider_name: p.name, provider_name: p.name,
label: m.alias label: `${ingressId} (${p.name})`,
? `${m.alias}${m.model_id} (${p.name})`
: `${m.model_id} (${p.name})`,
}) })
} }
} }
+46 -18
View File
@@ -29,10 +29,17 @@
<div class="models-expand"> <div class="models-expand">
<div v-if="!row.models?.length" class="models-empty">{{ t('providers.modelsEmpty') }}</div> <div v-if="!row.models?.length" class="models-empty">{{ t('providers.modelsEmpty') }}</div>
<el-table v-else :data="row.models" size="small" class="models-table"> <el-table v-else :data="row.models" size="small" class="models-table">
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip /> <template v-if="row.category === 'image'">
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip> <el-table-column :label="t('providers.ingressModelId')" min-width="180" show-overflow-tooltip>
<template #default="{ row: m }">{{ m.alias || '—' }}</template> <template #default="{ row: m }">{{ modelIngressId(m) }}</template>
</el-table-column> </el-table-column>
</template>
<template v-else>
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row: m }">{{ m.alias || '—' }}</template>
</el-table-column>
</template>
<el-table-column v-if="row.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" /> <el-table-column v-if="row.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip /> <el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80"> <el-table-column :label="t('common.enabled')" width="80">
@@ -193,10 +200,17 @@
</span> </span>
</div> </div>
<el-table :data="providerModels" size="small" v-loading="modelsLoading"> <el-table :data="providerModels" size="small" v-loading="modelsLoading">
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip /> <template v-if="current?.category === 'image'">
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip> <el-table-column :label="t('providers.ingressModelId')" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.alias || '—' }}</template> <template #default="{ row }">{{ modelIngressId(row) }}</template>
</el-table-column> </el-table-column>
</template>
<template v-else>
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.alias || '—' }}</template>
</el-table-column>
</template>
<el-table-column v-if="current?.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" /> <el-table-column v-if="current?.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip /> <el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80"> <el-table-column :label="t('common.enabled')" width="80">
@@ -241,13 +255,21 @@
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close> <el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="110px"> <el-form label-width="110px">
<el-form-item :label="t('providers.upstreamModelId')"> <template v-if="current?.category === 'image'">
<el-input v-model="editModelForm.model_id" /> <el-form-item :label="t('providers.ingressModelId')" required>
</el-form-item> <el-input v-model="editModelForm.model_id" placeholder="luminary/flux-sdxl" />
<el-form-item :label="t('providers.modelAlias')"> <p class="form-hint">{{ t('providers.ingressModelIdHint') }}</p>
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" /> </el-form-item>
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p> </template>
</el-form-item> <template v-else>
<el-form-item :label="t('providers.upstreamModelId')">
<el-input v-model="editModelForm.model_id" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
</el-form-item>
</template>
<el-form-item :label="t('providers.displayName')"> <el-form-item :label="t('providers.displayName')">
<el-input v-model="editModelForm.display_name" /> <el-input v-model="editModelForm.display_name" />
</el-form-item> </el-form-item>
@@ -639,11 +661,16 @@ function openAddModel() {
showAddModel.value = true showAddModel.value = true
} }
function modelIngressId(m: ModelEntry) {
return m.alias || m.model_id
}
function openEditModel(m: ModelEntry) { function openEditModel(m: ModelEntry) {
const isImage = current.value?.category === 'image'
editModelForm.value = { editModelForm.value = {
id: m.id, id: m.id,
model_id: m.model_id, model_id: isImage ? modelIngressId(m) : m.model_id,
alias: m.alias || '', alias: isImage ? '' : (m.alias || ''),
display_name: m.display_name || '', display_name: m.display_name || '',
} }
showEditModel.value = true showEditModel.value = true
@@ -651,10 +678,11 @@ function openEditModel(m: ModelEntry) {
async function saveEditModel() { async function saveEditModel() {
if (!current.value || !editModelForm.value.id) return if (!current.value || !editModelForm.value.id) return
const isImage = current.value.category === 'image'
try { try {
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, { await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
model_id: editModelForm.value.model_id.trim(), model_id: editModelForm.value.model_id.trim(),
alias: editModelForm.value.alias.trim(), ...(isImage ? { alias: '' } : { alias: editModelForm.value.alias.trim() }),
display_name: editModelForm.value.display_name.trim(), display_name: editModelForm.value.display_name.trim(),
}) })
} catch (e: any) { } catch (e: any) {