feat: several models for the assistant, each with its own name, kind, address and key; a model menu in the chat when there are several (or always, from Аккаунт › Вид), the choice kept for the user

This commit is contained in:
redtea committed 2026-10-17 14:41:34 +03:00
1 parent ded86e1c3f
commit 983809714b
12 files changed
+411 -44

No files matched your search

+123 -26
View File
@@ -27,10 +27,13 @@ import (
// and never sent to the browser - and streams the answer back as text.
const (
cfgAIType = "ai_type" // gradio, openai or anthropic
cfgAIURL = "ai_url"
cfgAIKey = "ai_key"
cfgAIModel = "ai_model"
// cfgAIModels: the models to choose from, as JSON - the first is the
// default. The four keys after it are the one model of before.
cfgAIModels = "ai_models"
cfgAIType = "ai_type" // gradio, openai or anthropic
cfgAIURL = "ai_url"
cfgAIKey = "ai_key"
cfgAIModel = "ai_model"
// DefaultAIURL is the proxy to the Qwen space the assistant uses by default.
DefaultAIURL = "https://agentproxy.minomix.ru"
@@ -40,7 +43,44 @@ const (
// default; an explicit "0" means the user turned it off.
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
type aiConfig struct{ Type, URL, Key, Model string }
// aiConfig is one model the assistant can ask: its id and the name the chat
// shows, the kind of API, its address, model and key.
type aiConfig struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Key string `json:"key,omitempty"`
Model string `json:"model,omitempty"`
}
// label is the model's name in the chat: its own, else its model or kind.
func (c aiConfig) label() string {
switch {
case c.Name != "":
return c.Name
case c.Model != "":
return c.Model
case c.Type == "openai":
return "OpenAI"
case c.Type == "anthropic":
return "Anthropic"
}
return "Qwen Omni"
}
// tidy keeps a known kind and gives gradio its default address.
func (c aiConfig) tidy() aiConfig {
if c.Type != "openai" && c.Type != "anthropic" {
c.Type = "gradio"
}
c.Name, c.Model, c.Key = strings.TrimSpace(c.Name), strings.TrimSpace(c.Model), strings.TrimSpace(c.Key)
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
if c.URL == "" && c.Type == "gradio" {
c.URL = DefaultAIURL
}
return c
}
type aiAttachment struct {
Name string `json:"name"`
@@ -57,16 +97,37 @@ type aiMsg struct {
ThinkMS int64 `json:"think_ms,omitempty"`
}
func (s *Server) aiConfig() aiConfig {
get := func(k, def string) string {
if v, _ := s.db.GetConfig(k); v != "" {
return v
}
return def
// aiModels are the models set in Настройки, never none: before any is
// saved, the one of the older settings (or the default).
func (s *Server) aiModels() []aiConfig {
var ms []aiConfig
if v, _ := s.db.GetConfig(cfgAIModels); v != "" {
_ = json.Unmarshal([]byte(v), &ms)
}
return aiConfig{Type: get(cfgAIType, "gradio"), URL: strings.TrimRight(get(cfgAIURL, DefaultAIURL), "/"), Key: get(cfgAIKey, ""), Model: get(cfgAIModel, "")}
if len(ms) == 0 {
get := func(k string) string { v, _ := s.db.GetConfig(k); return v }
ms = []aiConfig{{ID: "1", Type: get(cfgAIType), URL: get(cfgAIURL), Key: get(cfgAIKey), Model: get(cfgAIModel)}}
}
for i := range ms {
ms[i] = ms[i].tidy()
}
return ms
}
// aiModel is the model of that id, or the first.
func (s *Server) aiModel(id string) aiConfig {
ms := s.aiModels()
for _, m := range ms {
if m.ID == id {
return m
}
}
return ms[0]
}
// aiConfig is the default model, the first.
func (s *Server) aiConfig() aiConfig { return s.aiModel("") }
var aiClient = &http.Client{Timeout: 10 * time.Minute}
// aiStream asks the model for the answer to msgs (the last one the user's)
@@ -647,6 +708,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
Search bool `json:"search"`
Chat int64 `json:"chat"`
Attachments []aiAttachment `json:"attachments"`
Model string `json:"model"` // the model's id; "" or unknown: the first
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 24<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "" && len(in.Attachments) == 0) {
http.Error(w, "плохой запрос", http.StatusBadRequest)
@@ -656,7 +718,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
http.Error(w, "можно прикрепить не больше 6 файлов", http.StatusBadRequest)
return
}
cfg := s.aiConfig()
cfg := s.aiModel(in.Model)
total := 0
mediaCounts := map[string]int{}
for i := range in.Attachments {
@@ -722,7 +784,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
s.aiAppendTo(a.ID, in.Chat, "user", stored)
if fresh {
// A new chat gets a short name from the model, aside.
go s.aiNameChat(a.ID, in.Chat, stored)
go s.aiNameChat(cfg, a.ID, in.Chat, stored)
}
msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000)
if len(msgs) > 0 && msgs[len(msgs)-1].Role == "user" {
@@ -767,7 +829,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
fl.Flush()
}
}
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
err := aiStream(r.Context(), cfg, system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
// The answer is kept whole, thinking too (to show it again); the model
// gets it without (aiWindow).
ans := aiMsg{Role: "assistant", Content: answer.String()}
@@ -788,14 +850,14 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
// aiNameChat asks the model for a two-or-three-word name of the chat its
// first question opened, and gives the chat that name.
func (s *Server) aiNameChat(owner, chat int64, question string) {
func (s *Server) aiNameChat(cfg aiConfig, owner, chat int64, question string) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if r := []rune(question); len(r) > 600 {
question = string(r[:600])
}
var b strings.Builder
err := aiStream(ctx, s.aiConfig(), "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
err := aiStream(ctx, cfg, "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
if err != nil {
log.Printf("ai: naming chat %d: %v", chat, err)
@@ -866,7 +928,16 @@ func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
// GET /api/panel/ai - whether the assistant is on for this user.
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
writeJSON(w, map[string]any{"on": on != "0"})
// The models to choose from: ids and names, never a key.
type pick struct {
ID string `json:"id"`
Name string `json:"name"`
}
var ms []pick
for _, m := range s.aiModels() {
ms = append(ms, pick{m.ID, m.label()})
}
writeJSON(w, map[string]any{"on": on != "0", "models": ms})
}
// POST /api/panel/ai/toggle - {on}: the user's own switch.
@@ -893,15 +964,34 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
typ := r.FormValue("ai_type")
if typ != "openai" && typ != "anthropic" {
typ = "gradio"
// The models, a card each, in order. The key field shows the key there;
// what it holds is the key (empty: none). A card left empty is dropped;
// a new one gets an id.
f := r.PostForm
ids, names, types, urls, models, keys := f["m_id"], f["m_name"], f["m_type"], f["m_url"], f["m_model"], f["m_key"]
at := func(v []string, i int) string {
if i < len(v) {
return v[i]
}
return ""
}
var ms []aiConfig
seen := map[string]bool{}
for i := range types {
m := aiConfig{ID: at(ids, i), Name: at(names, i), Type: at(types, i), URL: at(urls, i), Model: at(models, i), Key: at(keys, i)}
if strings.TrimSpace(m.Name+m.Type+m.URL+m.Model+m.Key) == "" {
continue
}
if m.ID == "" || seen[m.ID] || len(m.ID) > 32 {
m.ID = newAIModelID()
}
seen[m.ID] = true
ms = append(ms, m.tidy())
}
if len(ms) > 0 {
b, _ := json.Marshal(ms)
_ = s.db.SetConfig(cfgAIModels, string(b))
}
_ = s.db.SetConfig(cfgAIType, typ)
_ = s.db.SetConfig(cfgAIURL, strings.TrimSpace(r.FormValue("ai_url")))
_ = s.db.SetConfig(cfgAIModel, strings.TrimSpace(r.FormValue("ai_model")))
// The field shows the key there; what it holds is the key (empty: none).
_ = s.db.SetConfig(cfgAIKey, strings.TrimSpace(r.FormValue("ai_key")))
// The search engine for «Поиск».
eng := r.FormValue("ai_search")
if eng != "searxng" && eng != "brave" && eng != "tavily" {
@@ -1001,3 +1091,10 @@ func stripThink(s string) string {
}
return s
}
// newAIModelID is a short random id for a model saved for the first time.
func newAIModelID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}