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:
1 parent
351e4ad28d
commit
6a17f40042
6 files changed
+39
-23
No files matched your search
@@ -46,7 +46,7 @@
|
|||||||
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
|
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
|
||||||
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
|
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
|
||||||
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
|
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
|
||||||
название в чате, тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
|
название в чате (и короткое, до 7 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
|
||||||
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
|
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
|
||||||
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
|
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
|
||||||
можно показывать выбор всегда.
|
можно показывать выбор всегда.
|
||||||
|
|||||||
+16
-8
@@ -43,11 +43,13 @@ const (
|
|||||||
// default; an explicit "0" means the user turned it off.
|
// default; an explicit "0" means the user turned it off.
|
||||||
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
|
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
|
// aiConfig is one model the assistant can ask: its id and the names the chat
|
||||||
// shows, the kind of API, its address, model and key.
|
// 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 {
|
type aiConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
Short string `json:"short,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
@@ -75,6 +77,11 @@ func (c aiConfig) tidy() aiConfig {
|
|||||||
c.Type = "gradio"
|
c.Type = "gradio"
|
||||||
}
|
}
|
||||||
c.Name, c.Model, c.Key = strings.TrimSpace(c.Name), strings.TrimSpace(c.Model), strings.TrimSpace(c.Key)
|
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), "/")
|
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
|
||||||
if c.URL == "" && c.Type == "gradio" {
|
if c.URL == "" && c.Type == "gradio" {
|
||||||
c.URL = DefaultAIURL
|
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))
|
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
|
||||||
// The models to choose from: ids and names, never a key.
|
// The models to choose from: ids and names, never a key.
|
||||||
type pick struct {
|
type pick struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Short string `json:"short,omitempty"`
|
||||||
}
|
}
|
||||||
var ms []pick
|
var ms []pick
|
||||||
for _, m := range s.aiModels() {
|
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})
|
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;
|
// what it holds is the key (empty: none). A card left empty is dropped;
|
||||||
// a new one gets an id.
|
// a new one gets an id.
|
||||||
f := r.PostForm
|
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 {
|
at := func(v []string, i int) string {
|
||||||
if i < len(v) {
|
if i < len(v) {
|
||||||
return v[i]
|
return v[i]
|
||||||
@@ -978,8 +986,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
var ms []aiConfig
|
var ms []aiConfig
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for i := range types {
|
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)}
|
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.Type+m.URL+m.Model+m.Key) == "" {
|
if strings.TrimSpace(m.Name+m.Short+m.Type+m.URL+m.Model+m.Key) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m.ID == "" || seen[m.ID] || len(m.ID) > 32 {
|
if m.ID == "" || seen[m.ID] || len(m.ID) > 32 {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestAIModelsFromLegacy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postAIModels sends the settings form with the given models.
|
// 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()
|
t.Helper()
|
||||||
f := url.Values{"csrf": {"tok"}}
|
f := url.Values{"csrf": {"tok"}}
|
||||||
for _, m := range models {
|
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_url", m[3])
|
||||||
f.Add("m_model", m[4])
|
f.Add("m_model", m[4])
|
||||||
f.Add("m_key", m[5])
|
f.Add("m_key", m[5])
|
||||||
|
f.Add("m_short", m[6])
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
|
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
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.
|
// names and ids only, never a key.
|
||||||
func TestAIModelsSaveAndList(t *testing.T) {
|
func TestAIModelsSaveAndList(t *testing.T) {
|
||||||
s, c := newTestServer(t)
|
s, c := newTestServer(t)
|
||||||
postAIModels(t, s, c, [][6]string{
|
postAIModels(t, s, c, [][7]string{
|
||||||
{"", "Qwen", "gradio", "", "", ""},
|
{"", "Qwen", "gradio", "", "", "", ""},
|
||||||
{"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key"},
|
{"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", "Сонет 4.5 длинно"},
|
||||||
{"", "", "", "", "", ""}, // an empty card is dropped
|
{"", "", "", "", "", "", ""}, // an empty card is dropped
|
||||||
})
|
})
|
||||||
ms := s.aiModels()
|
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)
|
t.Fatalf("models: %+v", ms)
|
||||||
}
|
}
|
||||||
body := get(t, s, c, "/api/panel/ai").Body.String()
|
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)
|
t.Fatalf("state: %s", body)
|
||||||
}
|
}
|
||||||
// Saved again with its id, a model keeps it.
|
// 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" {
|
if got := s.aiModels(); len(got) != 1 || got[0].ID != ms[1].ID || got[0].Name != "Claude 2" {
|
||||||
t.Fatalf("after resave: %+v", got)
|
t.Fatalf("after resave: %+v", got)
|
||||||
}
|
}
|
||||||
@@ -87,9 +88,9 @@ func TestAIChatUsesChosenModel(t *testing.T) {
|
|||||||
one, two := srv("first"), srv("second")
|
one, two := srv("first"), srv("second")
|
||||||
defer one.Close()
|
defer one.Close()
|
||||||
defer two.Close()
|
defer two.Close()
|
||||||
postAIModels(t, s, c, [][6]string{
|
postAIModels(t, s, c, [][7]string{
|
||||||
{"", "One", "openai", one.URL, "m", ""},
|
{"", "One", "openai", one.URL, "m", "", ""},
|
||||||
{"", "Two", "openai", two.URL, "m", ""},
|
{"", "Two", "openai", two.URL, "m", "", ""},
|
||||||
})
|
})
|
||||||
ms := s.aiModels()
|
ms := s.aiModels()
|
||||||
ask := func(model string) string {
|
ask := func(model string) string {
|
||||||
|
|||||||
@@ -711,7 +711,9 @@
|
|||||||
const always = window.Prefs && Prefs.get().aiModelPick === 'always';
|
const always = window.Prefs && Prefs.get().aiModelPick === 'always';
|
||||||
mpick.hidden = !(models.length > 1 || (always && models.length));
|
mpick.hidden = !(models.length > 1 || (always && models.length));
|
||||||
const cur = modelId(), m = models.find(x => x.id === cur);
|
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 : 'Модель';
|
mbtn.title = m ? 'Модель: ' + m.name : 'Модель';
|
||||||
mmenu.innerHTML = '';
|
mmenu.innerHTML = '';
|
||||||
models.forEach(x => {
|
models.forEach(x => {
|
||||||
|
|||||||
@@ -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:
|
/* 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). */
|
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-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-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 { 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; }
|
.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. */
|
pointed at; the list opens upwards, as a small card. */
|
||||||
.ai-mpick { position:relative; margin-left:auto; min-width:0; }
|
.ai-mpick { position:relative; margin-left:auto; min-width:0; }
|
||||||
.ai-mpick[hidden] { display:none; }
|
.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; }
|
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: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); }
|
.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 { 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 { 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-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-model-del { flex:none; height:38px; }
|
||||||
.ai-models .ai-model:only-child .ai-model-del { display:none; }
|
.ai-models .ai-model:only-child .ai-model-del { display:none; }
|
||||||
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
|
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
{{range .AIModels}} <fieldset class="ai-model">
|
{{range .AIModels}} <fieldset class="ai-model">
|
||||||
<input type="hidden" name="m_id" value="{{.ID}}">
|
<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>
|
<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>
|
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
|
||||||
<label>Тип API<select name="m_type">
|
<label>Тип API<select name="m_type">
|
||||||
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
|
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
<fieldset class="ai-model">
|
<fieldset class="ai-model">
|
||||||
<input type="hidden" name="m_id" value="">
|
<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>
|
<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>
|
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
|
||||||
<label>Тип API<select name="m_type">
|
<label>Тип API<select name="m_type">
|
||||||
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
|
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
|
||||||
|
|||||||
Reference in new issue
Block a user