diff --git a/README.md b/README.md index a10056e..7e9d208 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для существующего: сообщить / заменить / оставить как есть. +- **ИИ-ассистент** (MVP) - круглая кнопка чата справа внизу; каждый включает себе сам в профиле. + Видит вкладки и активный терминал или папку, отвечает потоком, команды предлагает кнопкой + «Выполнить» - она отправляет команду в активный терминал с правами пользователя. Модель задаёт + администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию - прокси к Qwen Omni), + OpenAI-совместимый API или Anthropic; ключ хранится на сервере. - **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель. - **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера. - **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ. diff --git a/internal/server/ai.go b/internal/server/ai.go new file mode 100644 index 0000000..ccfde50 --- /dev/null +++ b/internal/server/ai.go @@ -0,0 +1,456 @@ +package server + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// The assistant: a chat in the panel that sees the workspace and suggests +// commands. The browser sends the talk and what it shows; the server asks +// the model - its address, kind and key are the admins', set in Настройки +// 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" + + // DefaultAIURL is the proxy to the Qwen space the assistant uses by default. + DefaultAIURL = "https://agentproxy.minomix.ru" +) + +// aiUserKey is the config key of a user's own switch: the assistant is off +// for everyone until they turn it on in their profile. +func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) } + +type aiConfig struct{ Type, URL, Key, Model string } + +type aiMsg struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func (s *Server) aiConfig() aiConfig { + get := func(k, def string) string { + if v, _ := s.db.GetConfig(k); v != "" { + return v + } + return def + } + return aiConfig{Type: get(cfgAIType, "gradio"), URL: strings.TrimRight(get(cfgAIURL, DefaultAIURL), "/"), Key: get(cfgAIKey, ""), Model: get(cfgAIModel, "")} +} + +var aiClient = &http.Client{Timeout: 10 * time.Minute} + +// aiStream asks the model for the answer to msgs (the last one the user's) +// under the system prompt, handing emit the text as it comes. +func aiStream(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, emit func(string)) error { + switch cfg.Type { + case "openai": + return aiOpenAI(ctx, cfg, system, msgs, emit) + case "anthropic": + return aiAnthropic(ctx, cfg, system, msgs, emit) + default: + return aiGradio(ctx, cfg, system, msgs, emit) + } +} + +// sseData calls fn with each "data:" payload of an event stream until fn +// says stop or the stream ends. +func sseData(r io.Reader, fn func(data string) (stop bool, err error)) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64<<10), 16<<20) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + stop, err := fn(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + if err != nil || stop { + return err + } + } + return sc.Err() +} + +func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*http.Response, error) { + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range hdr { + req.Header.Set(k, v) + } + resp, err := aiClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400)) + resp.Body.Close() + return nil, fmt.Errorf("модель ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + return resp, nil +} + +func aiOpenAI(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, emit func(string)) error { + all := append([]aiMsg{{Role: "system", Content: system}}, msgs...) + hdr := map[string]string{} + if cfg.Key != "" { + hdr["Authorization"] = "Bearer " + cfg.Key + } + resp, err := aiPost(ctx, cfg.URL+"/chat/completions", map[string]any{"model": cfg.Model, "stream": true, "messages": all}, hdr) + if err != nil { + return err + } + defer resp.Body.Close() + return sseData(resp.Body, func(d string) (bool, error) { + if d == "[DONE]" { + return true, nil + } + var m struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(d), &m) != nil { + return false, nil + } + if m.Error != nil { + return true, errors.New(m.Error.Message) + } + for _, c := range m.Choices { + if c.Delta.Content != "" { + emit(c.Delta.Content) + } + } + return false, nil + }) +} + +func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, emit func(string)) error { + model := cfg.Model + if model == "" { + model = "claude-sonnet-5-5" + } + resp, err := aiPost(ctx, cfg.URL+"/v1/messages", map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": msgs, "stream": true}, + map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"}) + if err != nil { + return err + } + defer resp.Body.Close() + return sseData(resp.Body, func(d string) (bool, error) { + var m struct { + Type string `json:"type"` + Delta struct { + Text string `json:"text"` + } `json:"delta"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal([]byte(d), &m) != nil { + return false, nil + } + switch m.Type { + case "content_block_delta": + emit(m.Delta.Text) + case "message_stop": + return true, nil + case "error": + return true, errors.New(m.Error.Message) + } + return false, nil + }) +} + +// aiGradio talks to a Gradio chat app (chat_predict of the Qwen Omni demo): +// the history and the new text go in through the queue, and the chatbot's +// history comes back - whole, or as diffs - with the answer growing at its +// end. +func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, emit func(string)) error { + hdr := map[string]string{} + if cfg.Key != "" { + hdr["X-Agent-Key"] = cfg.Key + } + get := func(url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + for k, v := range hdr { + req.Header.Set(k, v) + } + resp, err := aiClient.Do(req) + if err == nil && resp.StatusCode/100 != 2 { + resp.Body.Close() + return nil, fmt.Errorf("модель ответила %d", resp.StatusCode) + } + return resp, err + } + // Which function is chat_predict, from the app's config. + resp, err := get(cfg.URL + "/config") + if err != nil { + return err + } + var conf struct { + Dependencies []struct { + ID int `json:"id"` + APIName string `json:"api_name"` + Targets [][]any `json:"targets"` + } `json:"dependencies"` + } + err = json.NewDecoder(resp.Body).Decode(&conf) + resp.Body.Close() + if err != nil { + return errors.New("модель: не прочиталась её конфигурация") + } + fn, trigger := -1, any(nil) + for _, d := range conf.Dependencies { + if d.APIName == "chat_predict" { + fn = d.ID + if len(d.Targets) > 0 && len(d.Targets[0]) > 0 { + trigger = d.Targets[0][0] + } + } + } + if fn < 0 { + return errors.New("модель: нет функции chat_predict") + } + last := msgs[len(msgs)-1] + b := make([]byte, 8) + _, _ = rand.Read(b) + session := hex.EncodeToString(b) + jr, err := aiPost(ctx, cfg.URL+"/gradio_api/queue/join", map[string]any{ + "data": []any{last.Content, nil, nil, nil, msgs[:len(msgs)-1], system, 0.7, 0.8, 20}, + "fn_index": fn, "trigger_id": trigger, "event_data": nil, "session_hash": session, + }, hdr) + if err != nil { + return err + } + jr.Body.Close() + resp, err = get(cfg.URL + "/gradio_api/queue/data?session_hash=" + session) + if err != nil { + return err + } + defer resp.Body.Close() + sent := "" // what of the answer has gone out + show := func(text string) { + if strings.HasPrefix(text, sent) { + emit(text[len(sent):]) + sent = text + } + } + answer := "" // the answer as the chatbot has it now + return sseData(resp.Body, func(d string) (bool, error) { + var m struct { + Msg string `json:"msg"` + Success *bool `json:"success"` + Output struct { + Data []json.RawMessage `json:"data"` + Error string `json:"error"` + } `json:"output"` + } + if json.Unmarshal([]byte(d), &m) != nil { + return false, nil + } + switch m.Msg { + case "process_generating", "process_completed": + case "close_stream": + return true, nil + default: + return false, nil + } + if m.Success != nil && !*m.Success { + msg := m.Output.Error + if msg == "" { + msg = "модель не ответила" + } + return true, errors.New("модель: " + msg) + } + if len(m.Output.Data) > 4 { + answer = gradioAnswer(m.Output.Data[4], answer) + show(answer) + } + return m.Msg == "process_completed", nil + }) +} + +// gradioAnswer is the assistant's last message after one update of the +// chatbot: a whole history, or diffs ("add", "append", "replace") to it. +func gradioAnswer(raw json.RawMessage, cur string) string { + var whole []struct { + Role string `json:"role"` + Content any `json:"content"` + } + if json.Unmarshal(raw, &whole) == nil { + if n := len(whole); n > 0 { + if s, ok := whole[n-1].Content.(string); ok && whole[n-1].Role == "assistant" { + return s + } + } + return cur + } + var diffs [][]json.RawMessage + if json.Unmarshal(raw, &diffs) != nil { + return cur + } + for _, d := range diffs { + if len(d) < 3 { + continue + } + var op string + _ = json.Unmarshal(d[0], &op) + var path []any + _ = json.Unmarshal(d[1], &path) + switch { + case op == "add" && len(path) == 1: + var msg struct { + Role string `json:"role"` + Content any `json:"content"` + } + if json.Unmarshal(d[2], &msg) == nil && msg.Role == "assistant" { + s, _ := msg.Content.(string) + cur = s + } + case (op == "append" || op == "replace") && len(path) == 2 && path[1] == "content": + var s string + if json.Unmarshal(d[2], &s) == nil { + if op == "append" { + cur += s + } else { + cur = s + } + } + } + } + return cur +} + +const aiSystem = `Ты - ассистент панели LPRA (удалённый доступ к машинам: терминалы, файлы, порты). Отвечай по-русски, коротко и по делу. +Ниже - что пользователь сейчас видит в панели: вкладки, активная панель, текст терминала или список файлов. +Если нужно выполнить команду, дай её отдельным блоком +` + "```run\nкоманда\n```" + ` +Пользователь сам нажмёт «Выполнить» - команда уйдёт в активный терминал с его правами. Одна команда на блок, без пояснений внутри блока. +Не выдумывай вывод команд: после выполнения ты увидишь его в следующем сообщении. Опасные команды (удаление, перезапуск, изменение системы) - только с предупреждением.` + +// POST /api/panel/ai/chat - {messages, context}: the answer, streamed as +// plain text. A failure after the start ends the text with "[ошибка: …]". +func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { + a := s.currentAdmin(r) + if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" { + http.Error(w, "ассистент выключен в профиле", http.StatusForbidden) + return + } + var in struct { + Messages []aiMsg `json:"messages"` + Context string `json:"context"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || len(in.Messages) == 0 { + http.Error(w, "плохой запрос", http.StatusBadRequest) + return + } + // The last 30 messages are enough to go on. + if n := len(in.Messages); n > 30 { + in.Messages = in.Messages[n-30:] + } + for i := range in.Messages { + if in.Messages[i].Role != "assistant" { + in.Messages[i].Role = "user" + } + } + if len(in.Context) > 60000 { + in.Context = in.Context[len(in.Context)-60000:] + for !utf8.ValidString(in.Context) && in.Context != "" { + in.Context = in.Context[1:] + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + fl, _ := w.(http.Flusher) + wrote := false + emit := func(t string) { + if t == "" { + return + } + wrote = true + _, _ = io.WriteString(w, t) + if fl != nil { + fl.Flush() + } + } + err := aiStream(r.Context(), s.aiConfig(), aiSystem+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit) + if err != nil && r.Context().Err() == nil { + if !wrote { + w.WriteHeader(http.StatusBadGateway) + } + _, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]") + } +} + +// 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 == "1"}) +} + +// POST /api/panel/ai/toggle - {on}: the user's own switch. +func (s *Server) handleAIToggle(w http.ResponseWriter, r *http.Request) { + var in struct { + On bool `json:"on"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil { + writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"}) + return + } + v := "" + if in.On { + v = "1" + } + _ = s.db.SetConfig(aiUserKey(s.currentAdmin(r).ID), v) + writeJSON(w, map[string]any{"ok": true}) +} + +// POST /settings/ai - the model: kind, address, model name and key (an +// empty key keeps the one there). +func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) { + if !checkCSRF(r) { + http.Error(w, "bad csrf", http.StatusForbidden) + return + } + typ := r.FormValue("ai_type") + if typ != "openai" && typ != "anthropic" { + typ = "gradio" + } + _ = 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"))) + if k := strings.TrimSpace(r.FormValue("ai_key")); k != "" { + _ = s.db.SetConfig(cfgAIKey, k) + } + if r.FormValue("ai_key_clear") == "1" { + _ = s.db.SetConfig(cfgAIKey, "") + } + http.Redirect(w, r, "/settings#ai", http.StatusSeeOther) +} diff --git a/internal/server/ai_test.go b/internal/server/ai_test.go new file mode 100644 index 0000000..5c73838 --- /dev/null +++ b/internal/server/ai_test.go @@ -0,0 +1,149 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func collect(t *testing.T, cfg aiConfig) (string, error) { + t.Helper() + var b strings.Builder + err := aiStream(context.Background(), cfg, "sys", []aiMsg{{Role: "user", Content: "раз"}, {Role: "assistant", Content: "два"}, {Role: "user", Content: "три"}}, func(s string) { b.WriteString(s) }) + return b.String(), err +} + +// Gradio (the Qwen space behind the proxy): history in, the answer streamed +// as diffs of the chatbot, the key in X-Agent-Key. +func TestAIGradio(t *testing.T) { + var joined map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Agent-Key") != "k" { + http.Error(w, "no key", 403) + return + } + switch r.URL.Path { + case "/config": + fmt.Fprint(w, `{"dependencies":[{"id":3,"api_name":"other"},{"id":0,"api_name":"chat_predict","targets":[[21,"click"]]}]}`) + case "/gradio_api/queue/join": + _ = json.NewDecoder(r.Body).Decode(&joined) + fmt.Fprint(w, `{"event_id":"e"}`) + case "/gradio_api/queue/data": + w.Header().Set("Content-Type", "text/event-stream") + for _, m := range []string{ + `{"msg":"process_starts"}`, + `{"msg":"process_generating","output":{"data":[null,null,null,null,[{"role":"user","content":"три"}],{},{}]}}`, + `{"msg":"process_generating","output":{"data":[[],[],[],[],[["add",[3],{"role":"assistant","content":"При"}]],[],[]]}}`, + `{"msg":"process_generating","output":{"data":[[],[],[],[],[["append",[3,"content"],"вет"]],[],[]]}}`, + `{"msg":"process_completed","success":true,"output":{"data":[{},{},{},{},[{"role":"user","content":"три"},{"role":"assistant","content":"Привет!"}],{},{}]}}`, + } { + fmt.Fprintf(w, "data: %s\n\n", m) + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + got, err := collect(t, aiConfig{Type: "gradio", URL: srv.URL, Key: "k"}) + if err != nil || got != "Привет!" { + t.Fatalf("got %q, %v", got, err) + } + d := joined["data"].([]any) + if joined["fn_index"].(float64) != 0 || d[0] != "три" || d[5] != "sys" || len(d[4].([]any)) != 2 { + t.Fatalf("join: %v", joined) + } +} + +func TestAIGradioError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/config": + fmt.Fprint(w, `{"dependencies":[{"id":0,"api_name":"chat_predict"}]}`) + case "/gradio_api/queue/join": + fmt.Fprint(w, `{"event_id":"e"}`) + default: + fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":false,\"output\":{\"error\":\"GPU занят\"}}\n\n") + } + })) + defer srv.Close() + if _, err := collect(t, aiConfig{Type: "gradio", URL: srv.URL}); err == nil || !strings.Contains(err.Error(), "GPU занят") { + t.Fatalf("err: %v", err) + } +} + +func TestAIOpenAI(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" || r.Header.Get("Authorization") != "Bearer k" { + http.Error(w, "bad", 400) + return + } + _ = json.NewDecoder(r.Body).Decode(&body) + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"При\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"вет\"}}]}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL + "/v1", Key: "k", Model: "m"}) + if err != nil || got != "Привет" { + t.Fatalf("got %q, %v", got, err) + } + msgs := body["messages"].([]any) + if body["model"] != "m" || body["stream"] != true || len(msgs) != 4 || msgs[0].(map[string]any)["role"] != "system" { + t.Fatalf("body: %v", body) + } +} + +func TestAIAnthropic(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" || r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") == "" { + http.Error(w, "bad", 400) + return + } + _ = json.NewDecoder(r.Body).Decode(&body) + fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\"}\n\n"+ + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"При\"}}\n\n"+ + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"вет\"}}\n\n"+ + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + })) + defer srv.Close() + got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m"}) + if err != nil || got != "Привет" { + t.Fatalf("got %q, %v", got, err) + } + if body["system"] != "sys" || len(body["messages"].([]any)) != 3 { + t.Fatalf("body: %v", body) + } +} + +// The chat route: only for a user who turned the assistant on. +func TestAIChatNeedsUserOn(t *testing.T) { + s, c := newTestServer(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + _ = s.db.SetConfig(cfgAIType, "openai") + _ = s.db.SetConfig(cfgAIURL, srv.URL) + post := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"context":"x"}`)) + req.AddCookie(c) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + return w + } + if w := post(); w.Code != http.StatusForbidden { + t.Fatalf("off: %d %s", w.Code, w.Body.String()) + } + a, _ := s.db.AdminByUsername("adm") + _ = s.db.SetConfig(aiUserKey(a.ID), "1") + w := post() + b, _ := io.ReadAll(w.Body) + if w.Code != 200 || string(b) != "ok" { + t.Fatalf("on: %d %q", w.Code, b) + } +} diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index b28b51f..c02f0a3 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -208,6 +208,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) { "IsAdmin": s.currentAdmin(r).IsAdmin(), "CSRF": csrfToken(r), "ServerVersion": Version, + "AI": s.aiConfig(), "Outdated": a.Version != "" && a.Version != Version, "Updated": r.URL.Query().Get("updated") == "1", "UpdateMsg": r.URL.Query().Get("umsg"), diff --git a/internal/server/server.go b/internal/server/server.go index b67b5a5..ae39ed6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -250,6 +250,10 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit)) mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys)) mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) + mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState)) + mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle)) + mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat)) + mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete)) diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index 6e99f5f..df1ff6e 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -41,6 +41,8 @@ '
' + ' ' + + ' ' + '' + esc(code) + '' + + (lang === 'run' && closed && code.trim() ? '' : '') + '