feat: AI assistant (MVP) - chat button per user, sees the workspace, streams answers, runs suggested commands in the active terminal; Gradio, OpenAI and Anthropic models set in Настройки

This commit is contained in:
redtea committed 2026-10-15 22:10:47 +03:00
1 parent 9572a35ad5
commit 46948f5ddc
9 files changed
+811

No files matched your search

+5
View File
@@ -40,6 +40,11 @@
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
существующего: сообщить / заменить / оставить как есть. существующего: сообщить / заменить / оставить как есть.
- **ИИ-ассистент** (MVP) - круглая кнопка чата справа внизу; каждый включает себе сам в профиле.
Видит вкладки и активный терминал или папку, отвечает потоком, команды предлагает кнопкой
«Выполнить» - она отправляет команду в активный терминал с правами пользователя. Модель задаёт
администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию - прокси к Qwen Omni),
OpenAI-совместимый API или Anthropic; ключ хранится на сервере.
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель. - **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера. - **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ. - **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
+456
View File
@@ -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)
}
+149
View File
@@ -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)
}
}
+1
View File
@@ -208,6 +208,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
"IsAdmin": s.currentAdmin(r).IsAdmin(), "IsAdmin": s.currentAdmin(r).IsAdmin(),
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
"ServerVersion": Version, "ServerVersion": Version,
"AI": s.aiConfig(),
"Outdated": a.Version != "" && a.Version != Version, "Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1", "Updated": r.URL.Query().Get("updated") == "1",
"UpdateMsg": r.URL.Query().Get("umsg"), "UpdateMsg": r.URL.Query().Get("umsg"),
+4
View File
@@ -250,6 +250,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit)) mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys)) 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/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/begin", s.requireAdmin(s.handleKeyRegisterBegin))
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) 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)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
+10
View File
@@ -41,6 +41,8 @@
' <p class="acct-msg"></p>' + ' <p class="acct-msg"></p>' +
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' + ' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
' <input type="checkbox" class="sw" role="switch"></label>' + ' <input type="checkbox" class="sw" role="switch"></label>' +
' <label class="acct-pw acct-ai"><span><b>ИИ-ассистент</b><small class="muted">кнопка чата в правом нижнем углу</small></span>' +
' <input type="checkbox" class="sw" role="switch"></label>' +
' <details class="acct-chpw"><summary>Сменить пароль</summary>' + ' <details class="acct-chpw"><summary>Сменить пароль</summary>' +
' <form class="acct-pwform" autocomplete="on">' + ' <form class="acct-pwform" autocomplete="on">' +
' <input type="text" name="username" autocomplete="username" hidden>' + ' <input type="text" name="username" autocomplete="username" hidden>' +
@@ -191,6 +193,14 @@
$('.acct-pindel').onclick = function () { $('.acct-pindel').onclick = function () {
if (confirm('Убрать PIN-код?')) savePin(''); 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) { $('.acct-pw .sw').onchange = function (ev) {
const off = !ev.target.checked; const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
+165
View File
@@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const fab = document.createElement('button');
fab.type = 'button';
fab.className = 'ai-fab';
fab.hidden = true;
fab.title = 'ИИ-ассистент';
fab.setAttribute('aria-label', 'ИИ-ассистент');
fab.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/><path d="M8.5 11h.01M12 11h.01M15.5 11h.01"/></svg>';
const box = document.createElement('div');
box.className = 'ai-box';
box.hidden = true;
box.innerHTML =
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' +
'<button type="button" class="btn ghost small icon ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить».</p></div>' +
'<form class="ai-form-in"><textarea rows="2" placeholder="Сообщение… (Enter - отправить, Shift+Enter - строка)"></textarea>' +
'<button type="submit" class="ai-send">Отправить</button></form>';
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 += '<div class="ai-text">' + esc(part).replace(/\n/g, '<br>') + '</div>'; 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 += '<div class="ai-code"><pre>' + esc(code) + '</pre>' +
(lang === 'run' && closed && code.trim() ? '<button type="button" class="btn small ai-run" data-cmd="' + esc(code) + '">Выполнить</button>' : '') + '</div>';
});
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(); }
});
})();
+1
View File
@@ -210,6 +210,7 @@
<script src="{{asset "users.js"}}"></script> <script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script> <script src="{{asset "account.js"}}"></script>
<script src="{{asset "machine.js"}}"></script> <script src="{{asset "machine.js"}}"></script>
<script src="{{asset "ai.js"}}"></script>
<script>App.start();</script> <script>App.start();</script>
</body> </body>
</html>{{end}} </html>{{end}}
@@ -116,6 +116,26 @@
{{end}} {{end}}
</div> </div>
<div class="card" id="ai">
<h2>ИИ-ассистент</h2>
<p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере
и в браузер не уходит.</p>
<form method="post" action="/settings/ai" class="ai-form">
<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>
</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>Ключ<input type="password" name="ai_key" placeholder="{{if .AI.Key}}задан - пусто, чтобы оставить{{else}}не задан{{end}}" autocomplete="off"></label>
{{if .AI.Key}}<label class="row" style="gap:8px"><input type="checkbox" name="ai_key_clear" value="1" style="width:auto"> убрать ключ</label>{{end}}
<button type="submit">Сохранить</button>
</form>
</div>
<div class="card"> <div class="card">
<h2>Enroll-секрет</h2> <h2>Enroll-секрет</h2>
<p class="muted">Общий секрет для регистрации новых агентов.</p> <p class="muted">Общий секрет для регистрации новых агентов.</p>