From 983809714b825aff2f1e52f9d353baa4717fb1fc Mon Sep 17 00:00:00 2001 From: iRedTea Date: Sat, 17 Oct 2026 14:41:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20several=20models=20for=20the=20assistan?= =?UTF-8?q?t,=20each=20with=20its=20own=20name,=20kind,=20address=20and=20?= =?UTF-8?q?key;=20a=20model=20menu=20in=20the=20chat=20when=20there=20are?= =?UTF-8?q?=20several=20(or=20always,=20from=20=D0=90=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=20=E2=80=BA=20=D0=92=D0=B8=D0=B4),=20the=20c?= =?UTF-8?q?hoice=20kept=20for=20the=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- internal/server/ai.go | 149 ++++++++++++++++---- internal/server/aimodels_test.go | 109 ++++++++++++++ internal/server/frag_test.go | 8 ++ internal/server/handlers_panel.go | 2 +- internal/server/prefs.go | 15 +- internal/server/web/static/account.js | 1 + internal/server/web/static/ai.js | 67 ++++++++- internal/server/web/static/app.css | 38 ++++- internal/server/web/static/pages.js | 14 ++ internal/server/web/static/prefs.js | 2 + internal/server/web/templates/settings.html | 43 ++++-- 12 files changed, 411 insertions(+), 44 deletions(-) create mode 100644 internal/server/aimodels_test.go diff --git a/README.md b/README.md index 7ed22c1..ff19da7 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,11 @@ отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать, показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам, спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над - ответом). Модель задаёт администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию - - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic; ключ хранится на сервере. + ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё + название в чате, тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic, + адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с + плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид» + можно показывать выбор всегда. Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo (по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo. diff --git a/internal/server/ai.go b/internal/server/ai.go index 56a5792..e659f38 100644 --- a/internal/server/ai.go +++ b/internal/server/ai.go @@ -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) +} diff --git a/internal/server/aimodels_test.go b/internal/server/aimodels_test.go new file mode 100644 index 0000000..7eb8509 --- /dev/null +++ b/internal/server/aimodels_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// The model set before there could be several is the first of the list. +func TestAIModelsFromLegacy(t *testing.T) { + s, _ := newTestServer(t) + _ = s.db.SetConfig(cfgAIType, "openai") + _ = s.db.SetConfig(cfgAIURL, "https://api.example/v1/") + _ = s.db.SetConfig(cfgAIModel, "gpt-x") + _ = s.db.SetConfig(cfgAIKey, "k1") + ms := s.aiModels() + if len(ms) != 1 || ms[0].Type != "openai" || ms[0].URL != "https://api.example/v1" || ms[0].Model != "gpt-x" || ms[0].Key != "k1" || ms[0].ID == "" { + t.Fatalf("models: %+v", ms) + } + if got := s.aiModel("nope"); got.ID != ms[0].ID { + t.Fatalf("an unknown id is not the first model: %+v", got) + } +} + +// postAIModels sends the settings form with the given models. +func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][6]string) { + t.Helper() + f := url.Values{"csrf": {"tok"}} + for _, m := range models { + f.Add("m_id", m[0]) + f.Add("m_name", m[1]) + f.Add("m_type", m[2]) + f.Add("m_url", m[3]) + f.Add("m_model", m[4]) + f.Add("m_key", m[5]) + } + req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(c) + req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"}) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if w.Code != http.StatusSeeOther { + t.Fatalf("save: %d %s", w.Code, w.Body) + } +} + +// Настройки keep several models with their names; the chat learns the +// 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 + }) + 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 { + 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) { + 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"}}) + if got := s.aiModels(); len(got) != 1 || got[0].ID != ms[1].ID || got[0].Name != "Claude 2" { + t.Fatalf("after resave: %+v", got) + } + if !strings.Contains(get(t, s, c, "/settings?frag=1").Body.String(), `value="Claude 2"`) { + t.Fatal("the settings page does not show the model's name") + } +} + +// The chat asks the model it names. +func TestAIChatUsesChosenModel(t *testing.T) { + s, c := newTestServer(t) + srv := func(word string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"%s\"}}]}\n\ndata: [DONE]\n\n", word) + })) + } + 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", ""}, + }) + ms := s.aiModels() + ask := func(model string) string { + b, _ := json.Marshal(map[string]any{"message": "hi", "context": "x", "model": model}) + req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(string(b))) + req.AddCookie(c) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + return w.Body.String() + } + if got := ask(ms[1].ID); !strings.Contains(got, "second") { + t.Fatalf("model Two: %q", got) + } + if got := ask(""); !strings.Contains(got, "first") { + t.Fatalf("no model named - the first: %q", got) + } +} diff --git a/internal/server/frag_test.go b/internal/server/frag_test.go index 2541a8f..95b659c 100644 --- a/internal/server/frag_test.go +++ b/internal/server/frag_test.go @@ -144,6 +144,14 @@ func TestViewPrefs(t *testing.T) { if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"openHere":"off"`) { t.Fatalf("openHere off not kept: %s", got) } + post(`{"aiModelPick":"always","aiModel":"a1b2c3"}`) + if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"aiModelPick":"always"`) || !strings.Contains(got, `"aiModel":"a1b2c3"`) { + t.Fatalf("the model choice not kept: %s", got) + } + post(`{"aiModelPick":"often","aiModel":""}`) + if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "aiModel") { + t.Fatalf("an unknown model choice was kept: %s", got) + } post(`{"openHere":"sometimes"}`) if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") { t.Fatalf("an unknown openHere was kept: %s", got) diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index ea34272..367d8c6 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -443,7 +443,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "ServerVersion": Version, - "AI": s.aiConfig(), + "AIModels": s.aiModels(), "Search": s.searchConf(), "DBNow": dbNow, "DBChosen": dbChosen, diff --git a/internal/server/prefs.go b/internal/server/prefs.go index 486a656..842b9c1 100644 --- a/internal/server/prefs.go +++ b/internal/server/prefs.go @@ -4,12 +4,15 @@ import ( "encoding/json" "net/http" "strconv" + "strings" ) // Each user's look of the panel (Аккаунт › Вид), kept on the server so it // is the same on every device: where a tab's icons go, the terminal's font // size, a compact file list, the time column, animations, and whether a -// terminal or file pane opened «Здесь» starts in the active pane's folder. +// terminal or file pane opened «Здесь» starts in the active pane's folder; +// and the assistant's model: which one, and whether the chat always offers +// the choice. type viewPrefs struct { TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default @@ -17,6 +20,10 @@ type viewPrefs struct { FileTime string `json:"fileTime,omitempty"` // show (default), hide Motion string `json:"motion,omitempty"` // on (default), off OpenHere string `json:"openHere,omitempty"` // on (default), off + // AIModelPick: the model menu in the chat - when there are several + // (default) or always. AIModel: the id of the model chosen. + AIModelPick string `json:"aiModelPick,omitempty"` + AIModel string `json:"aiModel,omitempty"` } func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) } @@ -38,6 +45,12 @@ func (p viewPrefs) clean() viewPrefs { if p.OpenHere != "off" { p.OpenHere = "" } + if p.AIModelPick != "always" { + p.AIModelPick = "" + } + if len(p.AIModel) > 32 || strings.Trim(p.AIModel, "0123456789abcdefghijklmnopqrstuvwxyz") != "" { + p.AIModel = "" + } return p } diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index d926175..00d56a1 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -62,6 +62,7 @@ ' ' + ' ' + ' ' + + ' ' + ' ' + ' ' + '
' + diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 5102c94..0997947 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -34,6 +34,10 @@ 'Поиск' + '' + + // The model, when there is a choice: its name; a click opens the list. + '' + '
' + '
' + '
' + @@ -73,6 +77,7 @@ const isOpen = () => box.classList.contains('on'); function show() { loadTalk(); + loadState(); placeBox(); box.classList.add('on'); fab.classList.add('away'); @@ -102,7 +107,12 @@ window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); }); function setOn(on) { fab.hidden = !on; if (!on) hide(); } const desk = !matchMedia('(pointer: coarse)').matches; - fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); + // The models to choose from come with the state, again on every opening + // (an admin may have changed them). + let models = []; + const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()) + .then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {}); + loadState(); document.addEventListener('lpra:ai', ev => setOn(ev.detail)); fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); }; @@ -652,7 +662,7 @@ await loadMachines(); const r = await fetch('/api/panel/ai/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal, - body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || []}) + body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || [], model: modelId()}) }); // A new chat gets its id (and its name) from the answer. const cid = Number(r.headers.get('X-Chat-Id')) || 0; @@ -688,6 +698,59 @@ const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true'; const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true'; const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true'; + // ---------- the model ---------- + // Shown when there are several models, or always if the user asked for it + // (Аккаунт › Вид); the choice is the user's, kept on the server. + const mpick = q('.ai-mpick'), mbtn = q('.ai-mbtn'), mmenu = q('.ai-mmenu'); + const CHECK = ''; + function modelId() { + const want = window.Prefs ? Prefs.get().aiModel : ''; + return (models.find(m => m.id === want) || models[0] || {}).id || ''; + } + function drawModels() { + 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 : ''; + mbtn.title = m ? 'Модель: ' + m.name : 'Модель'; + mmenu.innerHTML = ''; + models.forEach(x => { + const b = document.createElement('button'); + b.type = 'button'; b.className = 'ai-mitem'; b.setAttribute('role', 'option'); + b.setAttribute('aria-selected', String(x.id === cur)); + b.innerHTML = '' + CHECK + ''; + b.querySelector('.ai-mitem-name').textContent = x.name; + b.onclick = () => { + closeModels(); + if (window.Prefs) Prefs.set('aiModel', x.id); + drawModels(); + input.focus(); + }; + mmenu.appendChild(b); + }); + } + function openModels() { + mpick.classList.add('open'); + mbtn.setAttribute('aria-expanded', 'true'); + const sel = mmenu.querySelector('[aria-selected="true"]') || mmenu.firstChild; + if (sel) sel.focus(); + } + function closeModels() { + mpick.classList.remove('open'); + mbtn.setAttribute('aria-expanded', 'false'); + } + mbtn.onclick = () => mpick.classList.contains('open') ? closeModels() : openModels(); + document.addEventListener('pointerdown', ev => { if (!mpick.contains(ev.target)) closeModels(); }, true); + mpick.addEventListener('keydown', ev => { + if (ev.key === 'Escape' && mpick.classList.contains('open')) { ev.stopPropagation(); closeModels(); mbtn.focus(); return; } + if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return; + ev.preventDefault(); + if (!mpick.classList.contains('open')) { openModels(); return; } + const items = [...mmenu.children], i = items.indexOf(document.activeElement); + const n = items[(i + (ev.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]; + if (n) n.focus(); + }); + document.addEventListener('lpra:prefs', drawModels); // The modes are kept in this browser. box.querySelectorAll('.ai-pill').forEach(b => { try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ } diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index f080972..f530743 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -844,7 +844,7 @@ dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; ov display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); } .ai-fab[hidden] { display:none; } body.touch.term-active .ai-fab { bottom:104px; } -.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px)); +.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:384px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px)); display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px; box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right; opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96); @@ -957,8 +957,33 @@ dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560p .ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; } .ai-cap { font-size:12px; margin-bottom:4px; } /* Modes as pills: on - lit in the accent, off - a quiet outline. */ -.ai-pills { display:flex; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); } -.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 12px; border-radius:999px; font-size:12.5px; font-weight:500; +.ai-pills { display:flex; align-items:center; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); } +.ai-pills .ai-pill { flex:none; } +/* The model: its name and a chevron at the right of the pills, quiet until + 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; + 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); } +.ai-mname { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.ai-mchev { flex:none; opacity:.8; transition:transform .2s ease; } +.ai-mpick.open .ai-mchev { transform:rotate(180deg); } +.ai-mmenu { position:absolute; right:0; bottom:calc(100% + 8px); z-index:6; min-width:210px; max-width:280px; max-height:260px; overflow:auto; + display:grid; gap:2px; padding:5px; background:var(--panel); border:1px solid var(--border); border-radius:12px; + box-shadow:0 12px 32px rgba(0,0,0,.5); transform-origin:bottom right; + opacity:0; visibility:hidden; transform:translateY(6px) scale(.98); pointer-events:none; + transition:opacity .16s ease, transform .16s ease, visibility 0s linear .16s; } +.ai-mmenu::before { content:'Модель'; padding:5px 10px 4px; font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); } +.ai-mpick.open .ai-mmenu { opacity:1; visibility:visible; transform:none; pointer-events:auto; transition:opacity .16s ease, transform .16s ease; } +.ai-mitem { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; padding:8px 10px; border:0; border-radius:8px; + background:transparent; color:var(--text); font-size:13.5px; font-weight:450; text-align:left; } +.ai-mitem:hover, .ai-mitem:focus-visible { outline:none; background:#1b222c; } +.ai-mitem-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.ai-mcheck { flex:none; display:flex; color:var(--accent); visibility:hidden; } +.ai-mitem[aria-selected="true"] .ai-mcheck { visibility:visible; } +.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 10px; border-radius:999px; font-size:12.5px; font-weight:500; background:transparent; color:var(--muted); border:1px solid var(--border); transition:background .18s, color .18s, border-color .18s; } .ai-pill:hover { color:var(--text); border-color:#3a4554; } .ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); } @@ -1097,6 +1122,13 @@ body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-mot .fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; } /* Настройки › ИИ-ассистент › поиск: the fields of the engine chosen. */ .ai-form .ai-sub { margin:10px 0 0; font-size:15px; } +/* Настройки: the models, a card each. */ +.ai-models { display:grid; gap: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 .ai-model-name { flex:1; min-width:0; 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; } .ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; } /* A question about an action: where, the command in mono, a note. */ diff --git a/internal/server/web/static/pages.js b/internal/server/web/static/pages.js index 67b089e..a2d45ec 100644 --- a/internal/server/web/static/pages.js +++ b/internal/server/web/static/pages.js @@ -23,6 +23,20 @@ }, e => alert('Не скопировалось: ' + e.message)); }); if (window.UsersTable) window.UsersTable.mount(root); + // ИИ-ассистент: a card per model, added from the blank one and taken + // away by its «Удалить» (the last one stays). + const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new'); + if (list && blank) { + root.querySelector('.ai-model-add').onclick = () => { + const card = blank.content.firstElementChild.cloneNode(true); + list.appendChild(card); + card.querySelector('[name=m_name]').focus(); + }; + list.addEventListener('click', ev => { + const del = ev.target.closest('.ai-model-del'); + if (del && list.children.length > 1) del.closest('.ai-model').remove(); + }); + } } }); diff --git a/internal/server/web/static/prefs.js b/internal/server/web/static/prefs.js index 4b791ea..db5ee76 100644 --- a/internal/server/web/static/prefs.js +++ b/internal/server/web/static/prefs.js @@ -5,6 +5,8 @@ let p = Object.assign({}, (window.APP && window.APP.prefs) || {}); function apply() { const b = document.body; + // Parts that follow a setting of their own (the chat's model menu). + document.dispatchEvent(new CustomEvent('lpra:prefs')); b.dataset.tabicons = p.tabIcons || 'left'; b.classList.toggle('compact', !!p.compact); b.classList.toggle('no-ftime', p.fileTime === 'hide'); diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 57dbbea..9a46d01 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -152,15 +152,40 @@

ИИ-ассистент

- - - - +

Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.

+
+{{range .AIModels}}
+ +
+
+ + + + +
+{{end}}
+ +

Поиск в интернете

Запасной - DuckDuckGo.