Unify ComfyUI ingress model ID and fix seed validation errors.
CI / docker (push) Successful in 3m46s
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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})`,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user