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 @@ '

' + ' ' + + ' ' + '
Сменить пароль' + '
' + ' ' + @@ -191,6 +193,14 @@ $('.acct-pindel').onclick = function () { if (confirm('Убрать PIN-код?')) savePin(''); }; + // The assistant: each user's own switch; the chat button follows it. + fetch('/api/panel/ai', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { $('.acct-ai .sw').checked = !!d.on; }).catch(function () {}); + $('.acct-ai .sw').onchange = function (ev) { + var on = ev.target.checked; + fetch('/api/panel/ai/toggle', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({on: on})}) + .then(function (r) { return r.json(); }) + .then(function (r) { if (!r.ok) { ev.target.checked = !on; msg(r.error, true); } else document.dispatchEvent(new CustomEvent('lpra:ai', {detail: on})); }); + }; $('.acct-pw .sw').onchange = function (ev) { const off = !ev.target.checked; if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js new file mode 100644 index 0000000..2287686 --- /dev/null +++ b/internal/server/web/static/ai.js @@ -0,0 +1,165 @@ +// The assistant: a round button in the bottom right corner opens a chat. Each +// question goes with what the panel shows - the tabs, the active pane, the +// text of its terminal or its list of files - and the answer streams in. +// Commands the model suggests come as ```run``` blocks with «Выполнить»: a +// click sends the command to the active terminal, with the user's rights. +// Off until the user turns it on in the profile. +(function () { + 'use strict'; + const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); + const fab = document.createElement('button'); + fab.type = 'button'; + fab.className = 'ai-fab'; + fab.hidden = true; + fab.title = 'ИИ-ассистент'; + fab.setAttribute('aria-label', 'ИИ-ассистент'); + fab.innerHTML = ''; + const box = document.createElement('div'); + box.className = 'ai-box'; + box.hidden = true; + box.innerHTML = + '
Ассистент' + + '' + + '
' + + '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить».

' + + '' + + '
'; + document.body.append(fab, box); + const q = s => box.querySelector(s); + const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'); + const talk = []; // {role, content} + let ac = null; + + function setOn(on) { fab.hidden = !on; if (!on) box.hidden = true; } + fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); + document.addEventListener('lpra:ai', ev => setOn(ev.detail)); + fab.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) { where(); input.focus(); } }; + q('.ai-close').onclick = () => { box.hidden = true; }; + q('.ai-clear').onclick = () => { + if (ac) ac.abort(); + talk.length = 0; + list.querySelectorAll('.ai-msg').forEach(m => m.remove()); + q('.ai-hello').hidden = false; + }; + + // ---------- what the panel shows ---------- + const W = () => window.Workspace; + function termText(p, lines) { + const b = p.term && p.term.buffer && p.term.buffer.active; + if (!b) return ''; + const out = []; + for (let i = Math.max(0, b.length - lines); i < b.length; i++) { + const l = b.getLine(i); + if (l) out.push(l.translateToString(true)); + } + while (out.length && !out[out.length - 1].trim()) out.pop(); + return out.join('\n'); + } + function paneLine(p) { + const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind; + return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')'; + } + function context() { + const view = document.querySelector('main#app').dataset.view; + const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)]; + const ws = W(); + if (!ws) return out.join('\n'); + const panes = ws.panes(), act = ws.activePane(); + out.push('Панели (' + panes.length + '):'); + panes.forEach(p => out.push((p === act ? '* ' : '- ') + paneLine(p))); + if (act && act.term) { + out.push('', 'Активный терминал, последние строки:', '```', termText(act, 120), '```'); + } else if (act && act.entries) { + out.push('', 'Активная файловая панель: ' + act.cwd + (act.selected.size ? ' · выбрано: ' + [...act.selected].join(', ') : '')); + out.push(act.entries.slice(0, 200).map(e => (e.is_dir ? e.name + '/' : e.name + ' (' + e.size + ' B)')).join('\n')); + } + return out.join('\n'); + } + function where() { + const act = W() && W().activePane(); + q('.ai-where').textContent = act ? '· ' + (act.agent ? act.agent.name : '') : ''; + } + + // ---------- messages ---------- + // render: text with ``` blocks; ```run``` ones get «Выполнить». + function render(el, text, done) { + const parts = text.split(/```/); + let html = ''; + parts.forEach((part, i) => { + if (i % 2 === 0) { html += '
' + esc(part).replace(/\n/g, '
') + '
'; return; } + const m = /^(\w*)\n?([\s\S]*)$/.exec(part); + const lang = m[1], code = m[2].replace(/\n$/, ''); + const closed = i < parts.length - 1 || done; + html += '
' + esc(code) + '
' + + (lang === 'run' && closed && code.trim() ? '' : '') + '
'; + }); + el.innerHTML = html; + } + function add(role, text) { + q('.ai-hello').hidden = true; + const el = document.createElement('div'); + el.className = 'ai-msg ' + role; + if (role === 'user') el.textContent = text; else render(el, text, true); + list.appendChild(el); + list.scrollTop = list.scrollHeight; + return el; + } + list.addEventListener('click', ev => { + const b = ev.target.closest('.ai-run'); + if (!b) return; + const ws = W(), act = ws && ws.activePane(); + let t = act && act.term ? act : null; + if (!t && ws) t = ws.panes().find(p => p.term && ws.tabOf(p) === ws.tabOf(act)) || null; + if (!t) { FileViews.toast('Откройте терминал: команда выполняется в активном терминале', true); return; } + t.send(b.dataset.cmd.replace(/\n/g, '\r') + '\r'); + b.textContent = 'Выполнено в ' + (t.agent ? t.agent.name : 'терминале'); + b.disabled = true; + }); + + async function ask(text) { + talk.push({role: 'user', content: text}); + add('user', text); + const el = add('assistant', ''); + el.classList.add('wait'); + ac = new AbortController(); + send.textContent = 'Стоп'; + let answer = ''; + try { + const r = await fetch('/api/panel/ai/chat', { + method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal, + body: JSON.stringify({messages: talk, context: context()}) + }); + if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; } + if (!r.ok && !r.body) throw new Error('ошибка ' + r.status); + const rd = r.body.getReader(), dec = new TextDecoder(); + for (;;) { + const {done, value} = await rd.read(); + if (done) break; + answer += dec.decode(value, {stream: true}); + el.classList.remove('wait'); + render(el, answer, false); + list.scrollTop = list.scrollHeight; + } + } catch (e) { + if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']'; + } finally { + ac = null; + send.textContent = 'Отправить'; + el.classList.remove('wait'); + render(el, answer || '[нет ответа]', true); + talk.push({role: 'assistant', content: answer}); + } + } + q('.ai-form-in').onsubmit = ev => { + ev.preventDefault(); + if (ac) { ac.abort(); return; } + const text = input.value.trim(); + if (!text) return; + input.value = ''; + where(); + ask(text); + }; + input.addEventListener('keydown', ev => { + if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); } + }); +})(); diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html index a4cc425..e2c1963 100644 --- a/internal/server/web/templates/app.html +++ b/internal/server/web/templates/app.html @@ -210,6 +210,7 @@ + {{end}} diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 5b3b1eb..286d3f2 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -116,6 +116,26 @@ {{end}} +
+

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

+

Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые + пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере + и в браузер не уходит.

+
+ + + + + + {{if .AI.Key}}{{end}} + +
+
+

Enroll-секрет

Общий секрет для регистрации новых агентов.