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:
1 parent
9572a35ad5
commit
46948f5ddc
9 files changed
+811
No files matched your search
@@ -40,6 +40,11 @@
|
||||
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
|
||||
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
|
||||
существующего: сообщить / заменить / оставить как есть.
|
||||
- **ИИ-ассистент** (MVP) - круглая кнопка чата справа внизу; каждый включает себе сам в профиле.
|
||||
Видит вкладки и активный терминал или папку, отвечает потоком, команды предлагает кнопкой
|
||||
«Выполнить» - она отправляет команду в активный терминал с правами пользователя. Модель задаёт
|
||||
администратор в «Настройки › ИИ-ассистент»: Gradio (по умолчанию - прокси к Qwen Omni),
|
||||
OpenAI-совместимый API или Anthropic; ключ хранится на сервере.
|
||||
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
||||
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
' <p class="acct-msg"></p>' +
|
||||
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
|
||||
' <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>' +
|
||||
' <form class="acct-pwform" autocomplete="on">' +
|
||||
' <input type="text" name="username" autocomplete="username" hidden>' +
|
||||
@@ -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('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
|
||||
|
||||
@@ -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 = '<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(); }
|
||||
});
|
||||
})();
|
||||
@@ -210,6 +210,7 @@
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
<script src="{{asset "machine.js"}}"></script>
|
||||
<script src="{{asset "ai.js"}}"></script>
|
||||
<script>App.start();</script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -116,6 +116,26 @@
|
||||
{{end}}
|
||||
</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">
|
||||
<h2>Enroll-секрет</h2>
|
||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
||||
|
||||
Reference in new issue
Block a user