fix: Qwen Omni gets its files right - laid out by the app's own inputs (audio before image there, they were swapped), a video as Gradio 5 takes one ({video, subtitles}); the model is told it has the files, so it answers about them

This commit is contained in:
redtea committed 2026-10-17 15:42:37 +03:00
1 parent 03f963340e
commit 173f46a145
2 files changed
+105 -14

No files matched your search

+65 -14
View File
@@ -474,10 +474,15 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return err return err
} }
var conf struct { var conf struct {
Components []struct {
ID int `json:"id"`
Type string `json:"type"`
} `json:"components"`
Dependencies []struct { Dependencies []struct {
ID int `json:"id"` ID int `json:"id"`
APIName string `json:"api_name"` APIName string `json:"api_name"`
Targets [][]any `json:"targets"` Targets [][]any `json:"targets"`
Inputs []int `json:"inputs"`
} `json:"dependencies"` } `json:"dependencies"`
} }
err = json.NewDecoder(resp.Body).Decode(&conf) err = json.NewDecoder(resp.Body).Decode(&conf)
@@ -486,45 +491,79 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return errors.New("модель: не прочиталась её конфигурация") return errors.New("модель: не прочиталась её конфигурация")
} }
fn, trigger := -1, any(nil) fn, trigger := -1, any(nil)
var inputs []int
for _, d := range conf.Dependencies { for _, d := range conf.Dependencies {
if d.APIName == "chat_predict" { if d.APIName == "chat_predict" {
fn = d.ID fn = d.ID
inputs = d.Inputs
if len(d.Targets) > 0 && len(d.Targets[0]) > 0 { if len(d.Targets) > 0 && len(d.Targets[0]) > 0 {
trigger = d.Targets[0][0] trigger = d.Targets[0][0]
} }
} }
} }
// Its inputs in order, by kind - the files go where the app has them.
// An app that does not say: Qwen Omni's order.
kinds := []string{"textbox", "audio", "image", "video", "chatbot", "textbox", "slider", "slider", "slider"}
if len(inputs) > 0 {
types := map[int]string{}
for _, c := range conf.Components {
types[c.ID] = c.Type
}
kinds = kinds[:0:0]
for _, id := range inputs {
kinds = append(kinds, types[id])
}
}
if fn < 0 { if fn < 0 {
return errors.New("модель: нет функции chat_predict") return errors.New("модель: нет функции chat_predict")
} }
last := msgs[len(msgs)-1] last := msgs[len(msgs)-1]
media := []any{nil, nil, nil} media := map[string]any{}
for _, a := range last.Attachments { for _, a := range last.Attachments {
if a.Data == "" { kind := strings.SplitN(a.MIME, "/", 2)[0]
if a.Data == "" || (kind != "image" && kind != "audio" && kind != "video") {
continue continue
} }
slot := -1
switch {
case strings.HasPrefix(a.MIME, "image/"):
slot = 0
case strings.HasPrefix(a.MIME, "audio/"):
slot = 1
case strings.HasPrefix(a.MIME, "video/"):
slot = 2
}
if slot >= 0 {
fileData, err := aiGradioUpload(ctx, cfg, hdr, a) fileData, err := aiGradioUpload(ctx, cfg, hdr, a)
if err != nil { if err != nil {
return err return err
} }
media[slot] = fileData if kind == "video" {
// Gradio 5 takes a video with its subtitles.
media[kind] = map[string]any{"video": fileData, "subtitles": nil}
} else {
media[kind] = fileData
}
}
// The values in the inputs' order: the question in the first text box,
// the system prompt in the second, the talk in the chatbot, the files,
// and temperature, top p and top k in the sliders.
data := make([]any, len(kinds))
texts, sliders := 0, []any{0.7, 0.8, 20}
for i, k := range kinds {
switch k {
case "textbox":
if texts == 0 {
data[i] = last.Content
} else {
data[i] = system
}
texts++
case "image", "audio", "video":
data[i] = media[k]
case "chatbot":
data[i] = msgs[:len(msgs)-1]
case "slider":
if len(sliders) > 0 {
data[i], sliders = sliders[0], sliders[1:]
}
} }
} }
b := make([]byte, 8) b := make([]byte, 8)
_, _ = rand.Read(b) _, _ = rand.Read(b)
session := hex.EncodeToString(b) session := hex.EncodeToString(b)
jr, err := aiPost(ctx, cfg.URL+"/gradio_api/queue/join", map[string]any{ jr, err := aiPost(ctx, cfg.URL+"/gradio_api/queue/join", map[string]any{
"data": []any{last.Content, media[0], media[1], media[2], msgs[:len(msgs)-1], system, 0.7, 0.8, 20}, "data": data,
"fn_index": fn, "trigger_id": trigger, "event_data": nil, "session_hash": session, "fn_index": fn, "trigger_id": trigger, "event_data": nil, "session_hash": session,
}, hdr) }, hdr)
if err != nil { if err != nil {
@@ -893,6 +932,18 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
in.Context = utf8Safe(in.Context[len(in.Context)-30000:]) in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
} }
system := aiSystem system := aiSystem
// Pictures, sound and video go to the model itself: it is told so, or
// it takes them for something a panel assistant does not do.
var media []string
for _, f := range in.Attachments {
if f.Data != "" {
media = append(media, f.Name)
}
}
if len(media) > 0 {
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
}
if in.Agent { if in.Agent {
system += aiAgent system += aiAgent
} }
+40
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -393,3 +394,42 @@ func TestAIChatTitle(t *testing.T) {
} }
t.Fatalf("title: %+v", s.aiChats(a.ID)) t.Fatalf("title: %+v", s.aiChats(a.ID))
} }
// Qwen Omni's files go where its inputs are (audio before image there), and
// a video as Gradio 5 takes one: {video, subtitles}.
func TestAIGradioMediaSlots(t *testing.T) {
var joined map[string]any
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/config":
fmt.Fprint(w, `{"components":[{"id":20,"type":"textbox"},{"id":16,"type":"audio"},{"id":17,"type":"image"},{"id":18,"type":"video"},`+
`{"id":13,"type":"chatbot"},{"id":7,"type":"textbox"},{"id":8,"type":"slider"},{"id":9,"type":"slider"},{"id":10,"type":"slider"}],`+
`"dependencies":[{"id":0,"api_name":"chat_predict","targets":[[21,"click"]],"inputs":[20,16,17,18,13,7,8,9,10]}]}`)
case "/gradio_api/upload":
n++
fmt.Fprintf(w, `["/tmp/f%d"]`, n)
case "/gradio_api/queue/join":
_ = json.NewDecoder(r.Body).Decode(&joined)
fmt.Fprint(w, `{"event_id":"e"}`)
case "/gradio_api/queue/data":
fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":true,\"output\":{\"data\":[{},{},{},{},[{\"role\":\"assistant\",\"content\":\"ok\"}],{},{}]}}\n\n")
}
}))
defer srv.Close()
enc := base64.StdEncoding.EncodeToString([]byte("x"))
msgs := []aiMsg{{Role: "user", Content: "что тут", Attachments: []aiAttachment{
{Name: "a.png", MIME: "image/png", Data: enc},
{Name: "b.mp3", MIME: "audio/mpeg", Data: enc},
{Name: "c.mp4", MIME: "video/mp4", Data: enc},
}}}
if err := aiStream(context.Background(), aiConfig{Type: "gradio", URL: srv.URL}, "sys", msgs, func(string) {}); err != nil {
t.Fatal(err)
}
d := joined["data"].([]any)
path := func(v any) string { m, _ := v.(map[string]any); p, _ := m["path"].(string); return p }
video, _ := d[3].(map[string]any)
if path(d[1]) != "/tmp/f2" || path(d[2]) != "/tmp/f1" || path(video["video"]) != "/tmp/f3" || video["subtitles"] != nil || d[5] != "sys" {
t.Fatalf("data: %v", d)
}
}