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
+16
-8
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user