Keep Gemini image edits role-aware on the server path

The server now resolves source, identity, and style artifacts separately, emits role-labeled Gemini parts, and logs the effective request shape so deployed debugging can confirm which references reached upstream.

Constraint: The test deployment must stay on gemini-3.1-flash-image-preview while gaining better multi-image editing semantics
Rejected: Change the default model to gemini-3-pro-image-preview | test-server parity required keeping flash as default
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve the source/identity/style logging until the upstream gateway exposes equivalent structured traces
Tested: go test ./internal/server
Tested: image img2img against http://101.42.99.35:18080/v1 (job_5af212df2c27)
Not-tested: Non-Gemini multi-image providers still fall back to the first reference only
This commit is contained in:
jiajia
2026-04-16 18:49:05 +08:00
parent 531ffc6ee8
commit dcd21746d2
3 changed files with 247 additions and 38 deletions
+138 -27
View File
@@ -1159,7 +1159,7 @@ func (s *Server) executeImageToImageJob(record *job) {
defer cancel()
input := buildImageToImageInput(record)
ref, err := s.resolveImageToImageReference(ctx, record, input)
imageRefs, err := s.resolveImageToImageReferences(ctx, record, input)
if err != nil {
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
"skill_id": record.SkillID,
@@ -1169,15 +1169,23 @@ func (s *Server) executeImageToImageJob(record *job) {
}
return
}
log.Printf(
"popiartServer: image job %s using model=%s refs=%s preserve_composition=%t strength=%v",
record.JobID,
modelID,
summarizeImageEditReferenceRoles(imageRefs),
boolValue(input["preserve_composition"]),
input["strength"],
)
var (
refs []resultRef
usage map[string]any
resultRefs []resultRef
usage map[string]any
)
if useMiniMaxImageGenerations(modelID) {
refs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, []imageEditReference{ref})
resultRefs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
} else {
refs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, ref)
resultRefs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
}
if err != nil {
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
@@ -1189,7 +1197,7 @@ func (s *Server) executeImageToImageJob(record *job) {
return
}
refs, err = s.persistResultRefs(ctx, record, refs)
resultRefs, err = s.persistResultRefs(ctx, record, resultRefs)
if err != nil {
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
"details": err.Error(),
@@ -1201,7 +1209,7 @@ func (s *Server) executeImageToImageJob(record *job) {
return
}
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
if err := s.store.completeJobWithResults(record.JobID, resultRefs, usage); err != nil {
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
"details": err.Error(),
@@ -1394,33 +1402,45 @@ func inferRouteKeyForModel(modelID string, input map[string]any) string {
return "image.text2image"
}
func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, input map[string]any) (imageEditReference, error) {
func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
if record == nil {
return imageEditReference{}, fmt.Errorf("job record is required")
return nil, fmt.Errorf("job record is required")
}
if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" {
item, ref, exists, err := s.store.artifactRef(record.UserID, artifactID)
sourceRef, err := s.resolveArtifactImageReference(ctx, record, artifactID, "source")
if err != nil {
return imageEditReference{}, err
return nil, err
}
if !exists {
return imageEditReference{}, fmt.Errorf("source artifact not found: %s", artifactID)
refs := []imageEditReference{sourceRef}
seen := map[string]struct{}{artifactID: {}}
var appendArtifactRefs = func(role string, values []string) error {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
ref, err := s.resolveArtifactImageReference(ctx, record, value, role)
if err != nil {
return err
}
seen[value] = struct{}{}
refs = append(refs, ref)
}
return nil
}
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
if err != nil {
return imageEditReference{}, err
if err := appendArtifactRefs("identity", stringSliceValue(input["identity_reference_artifact_ids"])); err != nil {
return nil, err
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
return imageEditReference{}, err
if err := appendArtifactRefs("style", stringSliceValue(input["style_reference_artifact_ids"])); err != nil {
return nil, err
}
return imageEditReference{
Filename: item.Filename,
ContentType: defaultString(contentType, item.ContentType),
Content: content,
URL: item.URL,
}, nil
if err := appendArtifactRefs("reference", stringSliceValue(input["reference_artifact_ids"])); err != nil {
return nil, err
}
return refs, nil
}
refURL := strings.TrimSpace(stringValue(
@@ -1432,9 +1452,25 @@ func (s *Server) resolveImageToImageReference(ctx context.Context, record *job,
refURL = defaultAliceReferenceURL
}
if refURL == "" {
return nil, fmt.Errorf("reference image is required")
}
ref, err := s.downloadReferenceImage(ctx, refURL)
if err != nil {
return nil, err
}
ref.Role = "source"
return []imageEditReference{ref}, nil
}
func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, input map[string]any) (imageEditReference, error) {
refs, err := s.resolveImageToImageReferences(ctx, record, input)
if err != nil {
return imageEditReference{}, err
}
if len(refs) == 0 {
return imageEditReference{}, fmt.Errorf("reference image is required")
}
return s.downloadReferenceImage(ctx, refURL)
return refs[0], nil
}
func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
@@ -1651,6 +1687,32 @@ func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string {
return strings.Join(parts, ", ")
}
func (s *Server) resolveArtifactImageReference(ctx context.Context, record *job, artifactID, role string) (imageEditReference, error) {
item, ref, exists, err := s.store.artifactRef(record.UserID, artifactID)
if err != nil {
return imageEditReference{}, err
}
if !exists {
return imageEditReference{}, fmt.Errorf("%s artifact not found: %s", defaultString(role, "reference"), artifactID)
}
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
if err != nil {
return imageEditReference{}, err
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
return imageEditReference{}, err
}
return imageEditReference{
Role: role,
Filename: item.Filename,
ContentType: defaultString(contentType, item.ContentType),
Content: content,
URL: item.URL,
}, nil
}
func buildGenericImageToImagePrompt(input map[string]any, prompt string) string {
parts := make([]string, 0, 6)
if prompt != "" {
@@ -1668,6 +1730,21 @@ func buildGenericImageToImagePrompt(input map[string]any, prompt string) string
return strings.Join(parts, ", ")
}
func summarizeImageEditReferenceRoles(refs []imageEditReference) string {
if len(refs) == 0 {
return "none"
}
parts := make([]string, 0, len(refs))
for _, ref := range refs {
role := strings.TrimSpace(ref.Role)
if role == "" {
role = "reference"
}
parts = append(parts, role)
}
return strings.Join(parts, ",")
}
func stringValue(values ...any) string {
for _, value := range values {
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
@@ -1677,6 +1754,40 @@ func stringValue(values ...any) string {
return ""
}
func boolValue(value any) bool {
switch typed := value.(type) {
case bool:
return typed
default:
return false
}
}
func stringSliceValue(value any) []string {
switch typed := value.(type) {
case []string:
return cleanedStringValues(typed)
case []any:
items := make([]string, 0, len(typed))
for _, item := range typed {
items = append(items, strings.TrimSpace(fmt.Sprint(item)))
}
return cleanedStringValues(items)
default:
return nil
}
}
func cleanedStringValues(values []string) []string {
items := make([]string, 0, len(values))
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
items = append(items, trimmed)
}
}
return items
}
func buildVideoResultRef(baseURL, taskID, modelID string, task *videoTaskResult) resultRef {
urlValue := strings.TrimSpace(task.URL)
if urlValue == "" {