feat: several models for the assistant, each with its own name, kind, address and key; a model menu in the chat when there are several (or always, from Аккаунт › Вид), the choice kept for the user

This commit is contained in:
redtea committed 2026-10-17 14:41:34 +03:00
1 parent ded86e1c3f
commit 983809714b
12 files changed
+404 -37

No files matched your search

+5 -2
View File
@@ -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.
+118 -21
View File
@@ -27,6 +27,9 @@ import (
// and never sent to the browser - and streams the answer back as text.
const (
// 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"
@@ -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
// 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 def
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)}}
}
return aiConfig{Type: get(cfgAIType, "gradio"), URL: strings.TrimRight(get(cfgAIURL, DefaultAIURL), "/"), 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)
}
+109
View File
@@ -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)
}
}
+8
View File
@@ -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":"<x>"}`)
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)
+1 -1
View File
@@ -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,
+14 -1
View File
@@ -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
}
+1
View File
@@ -62,6 +62,7 @@
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
' <label><span>Выбор модели в чате</span><select data-pref="aiModelPick"><option value="">когда моделей несколько</option><option value="always">всегда</option></select></label>' +
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
' </div></details>' +
' <div class="acct-keysec">' +
+65 -2
View File
@@ -34,6 +34,10 @@
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
// The model, when there is a choice: its name; a click opens the list.
'<div class="ai-mpick" hidden><button type="button" class="ai-mbtn" aria-haspopup="listbox" aria-expanded="false" title="Модель">' +
'<span class="ai-mname"></span><svg class="ai-mchev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>' +
'<div class="ai-mmenu" role="listbox" aria-label="Модель"></div></div>' +
'</div>' +
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.pdf,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
@@ -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 = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
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 = '<span class="ai-mitem-name"></span><span class="ai-mcheck">' + CHECK + '</span>';
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 */ }
+35 -3
View File
@@ -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. */
+14
View File
@@ -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();
});
}
}
});
+2
View File
@@ -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');
+32 -7
View File
@@ -152,15 +152,40 @@
<h2>ИИ-ассистент</h2>
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Тип API<select name="ai_type">
<option value="gradio"{{if eq .AI.Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
<option value="openai"{{if eq .AI.Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
<option value="anthropic"{{if eq .AI.Type "anthropic"}} selected{{end}}>Anthropic</option>
<p class="muted" style="margin:0">Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.</p>
<div class="ai-models">
{{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>
<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>
<option value="openai"{{if eq .Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
<option value="anthropic"{{if eq .Type "anthropic"}} selected{{end}}>Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="ai_url" value="{{.AI.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="ai_model" value="{{.AI.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<label>Адрес<input type="url" name="m_url" value="{{.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="{{.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="{{.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</fieldset>
{{end}} </div>
<template class="ai-model-new">
<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>
<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>
<option value="openai">OpenAI-совместимый</option>
<option value="anthropic">Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="m_url" value="" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</fieldset>
</template>
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
<h3 class="ai-sub">Поиск в интернете</h3>
<p class="muted" style="margin:0">Запасной - DuckDuckGo.</p>
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">