From 6a17f40042cbc0ef38d1145a8da6f5f3fc7885be Mon Sep 17 00:00:00 2001 From: iRedTea Date: Sat, 17 Oct 2026 14:58:04 +0300 Subject: [PATCH] feat: a short name of up to 7 characters for each model, on the chat's model button, always whole; the message field back to the normal 14px --- README.md | 2 +- internal/server/ai.go | 24 ++++++++++++++------- internal/server/aimodels_test.go | 23 ++++++++++---------- internal/server/web/static/ai.js | 4 +++- internal/server/web/static/app.css | 7 ++++-- internal/server/web/templates/settings.html | 2 ++ 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ff19da7..0b16e15 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам, спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё - название в чате, тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic, + название в чате (и короткое, до 7 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic, адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид» можно показывать выбор всегда. diff --git a/internal/server/ai.go b/internal/server/ai.go index e659f38..5d185a5 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -43,11 +43,13 @@ const ( // default; an explicit "0" means the user turned it off. func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) } -// 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. +// aiConfig is one model the assistant can ask: its id and the names the chat +// shows (Short, up to 7 characters, on the button of the model in use), the +// kind of API, its address, model and key. type aiConfig struct { ID string `json:"id"` Name string `json:"name,omitempty"` + Short string `json:"short,omitempty"` Type string `json:"type"` URL string `json:"url,omitempty"` Key string `json:"key,omitempty"` @@ -75,6 +77,11 @@ func (c aiConfig) tidy() aiConfig { c.Type = "gradio" } c.Name, c.Model, c.Key = strings.TrimSpace(c.Name), strings.TrimSpace(c.Model), strings.TrimSpace(c.Key) + if r := []rune(strings.TrimSpace(c.Short)); len(r) > 7 { + c.Short = strings.TrimSpace(string(r[:7])) + } else { + c.Short = string(r) + } c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/") if c.URL == "" && c.Type == "gradio" { c.URL = DefaultAIURL @@ -930,12 +937,13 @@ func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) { on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID)) // The models to choose from: ids and names, never a key. type pick struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Short string `json:"short,omitempty"` } var ms []pick for _, m := range s.aiModels() { - ms = append(ms, pick{m.ID, m.label()}) + ms = append(ms, pick{m.ID, m.label(), m.Short}) } writeJSON(w, map[string]any{"on": on != "0", "models": ms}) } @@ -968,7 +976,7 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) { // 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"] + ids, names, shorts, types, urls, models, keys := f["m_id"], f["m_name"], f["m_short"], 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] @@ -978,8 +986,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) { 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) == "" { + m := aiConfig{ID: at(ids, i), Name: at(names, i), Short: at(shorts, i), Type: at(types, i), URL: at(urls, i), Model: at(models, i), Key: at(keys, i)} + if strings.TrimSpace(m.Name+m.Short+m.Type+m.URL+m.Model+m.Key) == "" { continue } if m.ID == "" || seen[m.ID] || len(m.ID) > 32 { diff --git a/internal/server/aimodels_test.go b/internal/server/aimodels_test.go index 7eb8509..a76e059 100644 --- a/internal/server/aimodels_test.go +++ b/internal/server/aimodels_test.go @@ -27,7 +27,7 @@ func TestAIModelsFromLegacy(t *testing.T) { } // postAIModels sends the settings form with the given models. -func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][6]string) { +func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][7]string) { t.Helper() f := url.Values{"csrf": {"tok"}} for _, m := range models { @@ -37,6 +37,7 @@ func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][6]string) { f.Add("m_url", m[3]) f.Add("m_model", m[4]) f.Add("m_key", m[5]) + f.Add("m_short", m[6]) } req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -53,21 +54,21 @@ func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][6]string) { // names and ids only, never a key. func TestAIModelsSaveAndList(t *testing.T) { s, c := newTestServer(t) - postAIModels(t, s, c, [][6]string{ - {"", "Qwen", "gradio", "", "", ""}, - {"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key"}, - {"", "", "", "", "", ""}, // an empty card is dropped + postAIModels(t, s, c, [][7]string{ + {"", "Qwen", "gradio", "", "", "", ""}, + {"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", "Сонет 4.5 длинно"}, + {"", "", "", "", "", "", ""}, // an empty card is dropped }) ms := s.aiModels() - if len(ms) != 2 || ms[0].Name != "Qwen" || ms[1].Name != "Claude" || ms[1].Key != "secret-key" || ms[0].ID == "" || ms[0].ID == ms[1].ID { + if len(ms) != 2 || ms[0].Name != "Qwen" || ms[1].Name != "Claude" || ms[1].Key != "secret-key" || ms[0].ID == "" || ms[0].ID == ms[1].ID || ms[1].Short != "Сонет 4" { t.Fatalf("models: %+v", ms) } body := get(t, s, c, "/api/panel/ai").Body.String() - if strings.Contains(body, "secret-key") || !strings.Contains(body, `"name":"Claude"`) || !strings.Contains(body, ms[1].ID) { + if strings.Contains(body, "secret-key") || !strings.Contains(body, `"name":"Claude"`) || !strings.Contains(body, `"short":"Сонет 4"`) || !strings.Contains(body, ms[1].ID) { t.Fatalf("state: %s", body) } // Saved again with its id, a model keeps it. - postAIModels(t, s, c, [][6]string{{ms[1].ID, "Claude 2", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key"}}) + postAIModels(t, s, c, [][7]string{{ms[1].ID, "Claude 2", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", ""}}) if got := s.aiModels(); len(got) != 1 || got[0].ID != ms[1].ID || got[0].Name != "Claude 2" { t.Fatalf("after resave: %+v", got) } @@ -87,9 +88,9 @@ func TestAIChatUsesChosenModel(t *testing.T) { one, two := srv("first"), srv("second") defer one.Close() defer two.Close() - postAIModels(t, s, c, [][6]string{ - {"", "One", "openai", one.URL, "m", ""}, - {"", "Two", "openai", two.URL, "m", ""}, + postAIModels(t, s, c, [][7]string{ + {"", "One", "openai", one.URL, "m", "", ""}, + {"", "Two", "openai", two.URL, "m", "", ""}, }) ms := s.aiModels() ask := func(model string) string { diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 671678d..44f00d0 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -711,7 +711,9 @@ const always = window.Prefs && Prefs.get().aiModelPick === 'always'; mpick.hidden = !(models.length > 1 || (always && models.length)); const cur = modelId(), m = models.find(x => x.id === cur); - q('.ai-mname').textContent = m ? m.name : ''; + // The button: the short name if there is one; the list: the full names. + q('.ai-mname').textContent = m ? (m.short || m.name) : ''; + mpick.classList.toggle('short', !!(m && m.short)); mbtn.title = m ? 'Модель: ' + m.name : 'Модель'; mmenu.innerHTML = ''; models.forEach(x => { diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 2305b14..0b50d5c 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -901,7 +901,7 @@ body.touch.term-active .ai-fab { bottom:104px; } /* The clip and send keep to the last line of the text, as in Telegram: at the bottom, centred on that line (a line's field is their height). */ .ai-entry { display:flex; align-items:flex-end; gap:5px; min-width:0; } -.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:16px; line-height:22px; padding:5px 6px; height:32px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; } +.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:14px; line-height:20px; padding:6px; height:32px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; } .ai-form-in textarea:focus { outline:none; border-color:transparent; } .ai-attach { flex:none; width:32px; height:32px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; } .ai-attach:hover { color:var(--text); background:#1b222c; } @@ -963,7 +963,9 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p pointed at; the list opens upwards, as a small card. */ .ai-mpick { position:relative; margin-left:auto; min-width:0; } .ai-mpick[hidden] { display:none; } -.ai-mbtn { display:inline-flex; align-items:center; gap:3px; max-width:100%; height:28px; padding:0 6px 0 9px; border:0; border-radius:8px; +/* A short name (7 characters at most) is shown whole. */ +.ai-mpick.short { flex-shrink:0; } +.ai-mbtn { display:inline-flex; align-items:center; gap:3px; max-width:100%; height:28px; padding:0 5px 0 7px; border:0; border-radius:8px; background:transparent; color:var(--muted); font-size:12.5px; font-weight:500; transition:background-color .15s, color .15s; } .ai-mbtn:hover, .ai-mpick.open .ai-mbtn { color:var(--text); background:#1b222c; } .ai-mbtn:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); } @@ -1127,6 +1129,7 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot .ai-model { display:grid; gap:10px; min-width:0; margin:0; padding:12px; border:1px solid var(--border); border-radius:10px; } .ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:12px 0 4px; } .ai-model-head .ai-model-name { flex:1; min-width:0; margin:0; } +.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; } .ai-model-del { flex:none; height:38px; } .ai-models .ai-model:only-child .ai-model-del { display:none; } .ai-form:not([data-search="searxng"]) .s-url { display:none; } diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 9a46d01..545cb6e 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -157,6 +157,7 @@ {{range .AIModels}}
+