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

This commit is contained in:
redtea committed 2026-10-17 14:58:04 +03:00
1 parent 351e4ad28d
commit 6a17f40042
6 files changed
+39 -23

No files matched your search

+1 -1
View File
@@ -46,7 +46,7 @@
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
название в чате, тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
название в чате (и короткое, до 7 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
можно показывать выбор всегда.
+16 -8
View File
@@ -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 {
+12 -11
View File
@@ -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 {
+3 -1
View File
@@ -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 => {
+5 -2
View File
@@ -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; }
@@ -157,6 +157,7 @@
{{range .AIModels}} <fieldset class="ai-model">
<input type="hidden" name="m_id" value="{{.ID}}">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="{{.Name}}" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="{{.Short}}" maxlength="7" placeholder="до 7" title="Название на кнопке модели в чате, до 7 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
@@ -173,6 +174,7 @@
<fieldset class="ai-model">
<input type="hidden" name="m_id" value="">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="" maxlength="7" placeholder="до 7" title="Название на кнопке модели в чате, до 7 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio">Gradio (Qwen Omni через прокси)</option>