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:
1 parent
ded86e1c3f
commit
983809714b
12 files changed
+411
-44
No files matched your search
+123
-26
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user