feat: the assistant's talk is kept in the database per user and the model sees a trimmed window of it; clear is a broom; the ball steps aside for the chat and comes back where the chat was
This commit is contained in:
1 parent
92a90646a5
commit
00617451aa
6 files changed
+283
-60
No files matched your search
+175
-35
@@ -364,8 +364,45 @@ const aiAgent = `
|
|||||||
const aiThink = `
|
const aiThink = `
|
||||||
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
|
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
|
||||||
|
|
||||||
// POST /api/panel/ai/chat - {messages, context}: the answer, streamed as
|
// The talk is kept in the database, the last aiKeep messages of each user.
|
||||||
// plain text. A failure after the start ends the text with "[ошибка: …]".
|
const aiKeep = 200
|
||||||
|
|
||||||
|
func (s *Server) aiAppend(owner int64, role, content string) {
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(content) > 64<<10 {
|
||||||
|
content = utf8Safe(content[:64<<10])
|
||||||
|
}
|
||||||
|
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, role, content, at) VALUES (?, ?, ?, ?)`, owner, role, content, time.Now().Unix())
|
||||||
|
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE admin_id = ? AND id <= (SELECT id FROM ai_messages WHERE admin_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, owner, owner, aiKeep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aiHistory is the user's talk, oldest first, the last limit messages.
|
||||||
|
func (s *Server) aiHistory(owner int64, limit int) []aiMsg {
|
||||||
|
rows, err := s.db.sql.Query(`SELECT role, content FROM ai_messages WHERE admin_id = ? ORDER BY id DESC LIMIT ?`, owner, limit)
|
||||||
|
out := []aiMsg{}
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var m aiMsg
|
||||||
|
if rows.Scan(&m.Role, &m.Content) == nil {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for l, r := 0, len(out)-1; l < r; l, r = l+1, r-1 {
|
||||||
|
out[l], out[r] = out[r], out[l]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/ai/chat - {message | result, context, agent, think}: the
|
||||||
|
// new words (or an action's result) are kept with the talk, and the answer,
|
||||||
|
// streamed as plain text, is kept too. The model sees a window of the talk
|
||||||
|
// (aiWindow) and the panel as it is now. A failure after the start ends the
|
||||||
|
// text with "[ошибка: …]".
|
||||||
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
||||||
a := s.currentAdmin(r)
|
a := s.currentAdmin(r)
|
||||||
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
|
||||||
@@ -373,44 +410,21 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var in struct {
|
var in struct {
|
||||||
Messages []aiMsg `json:"messages"`
|
Message string `json:"message"`
|
||||||
|
Result string `json:"result"`
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Agent bool `json:"agent"`
|
Agent bool `json:"agent"`
|
||||||
Think bool `json:"think"`
|
Think bool `json:"think"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || len(in.Messages) == 0 {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "") {
|
||||||
http.Error(w, "плохой запрос", http.StatusBadRequest)
|
http.Error(w, "плохой запрос", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The last 30 messages are enough to go on.
|
s.aiAppend(a.ID, "result", in.Result)
|
||||||
if n := len(in.Messages); n > 30 {
|
s.aiAppend(a.ID, "user", in.Message)
|
||||||
in.Messages = in.Messages[n-30:]
|
msgs := aiWindow(s.aiHistory(a.ID, 60), 24000)
|
||||||
}
|
if len(in.Context) > 30000 {
|
||||||
for i := range in.Messages {
|
in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
system := aiSystem
|
system := aiSystem
|
||||||
if in.Agent {
|
if in.Agent {
|
||||||
@@ -419,15 +433,54 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.Think {
|
if in.Think {
|
||||||
system += aiThink
|
system += aiThink
|
||||||
}
|
}
|
||||||
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, in.Messages, emit)
|
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)
|
||||||
|
var answer strings.Builder
|
||||||
|
emit := func(t string) {
|
||||||
|
if t == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
answer.WriteString(t)
|
||||||
|
_, _ = io.WriteString(w, t)
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := aiStream(r.Context(), s.aiConfig(), system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
|
||||||
|
s.aiAppend(a.ID, "assistant", stripThink(answer.String()))
|
||||||
if err != nil && r.Context().Err() == nil {
|
if err != nil && r.Context().Err() == nil {
|
||||||
if !wrote {
|
if answer.Len() == 0 {
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
}
|
}
|
||||||
_, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]")
|
_, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/ai/history - the talk to show, oldest first.
|
||||||
|
func (s *Server) handleAIHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, s.aiHistory(s.currentAdmin(r).ID, 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/ai/clear - a new talk.
|
||||||
|
func (s *Server) handleAIClear(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE admin_id = ?`, s.currentAdmin(r).ID)
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/ai/note - {content}: an action's result the user got by
|
||||||
|
// hand, kept with the talk for the next question.
|
||||||
|
func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in) == nil {
|
||||||
|
s.aiAppend(s.currentAdmin(r).ID, "result", in.Content)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/panel/ai - whether the assistant is on for this user.
|
// GET /api/panel/ai - whether the assistant is on for this user.
|
||||||
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAIState(w http.ResponseWriter, r *http.Request) {
|
||||||
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
|
on, _ := s.db.GetConfig(aiUserKey(s.currentAdmin(r).ID))
|
||||||
@@ -473,3 +526,90 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
|
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aiWindow is what of the stored talk goes to the model: the newest
|
||||||
|
// messages within budget characters - the last one always - with older
|
||||||
|
// action results and answers cut to their head and tail (the newest result
|
||||||
|
// whole), results given as the user's words, and messages of one role in a
|
||||||
|
// row merged (some models want them alternating). It starts with the user.
|
||||||
|
func aiWindow(rows []aiMsg, budget int) []aiMsg {
|
||||||
|
cut := func(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
head, tail := max/3, max-max/3
|
||||||
|
return utf8Safe(s[:head]) + "\n…[сокращено]…\n" + utf8Safe(s[len(s)-tail:])
|
||||||
|
}
|
||||||
|
lastResult := -1
|
||||||
|
for i := len(rows) - 1; i >= 0; i-- {
|
||||||
|
if rows[i].Role == "result" {
|
||||||
|
lastResult = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var picked []aiMsg
|
||||||
|
total := 0
|
||||||
|
for i := len(rows) - 1; i >= 0; i-- {
|
||||||
|
m := rows[i]
|
||||||
|
switch {
|
||||||
|
case m.Role == "result":
|
||||||
|
max := 1500
|
||||||
|
if i == lastResult {
|
||||||
|
max = 12000
|
||||||
|
}
|
||||||
|
m = aiMsg{Role: "user", Content: "Результат действия:\n" + cut(m.Content, max)}
|
||||||
|
case m.Role == "assistant" && i < len(rows)-2:
|
||||||
|
m.Content = cut(m.Content, 3000)
|
||||||
|
case m.Role != "assistant":
|
||||||
|
m.Role = "user"
|
||||||
|
}
|
||||||
|
if len(picked) > 0 && total+len(m.Content) > budget {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
total += len(m.Content)
|
||||||
|
picked = append(picked, m)
|
||||||
|
}
|
||||||
|
// Oldest first, from the user's first word.
|
||||||
|
for l, r := 0, len(picked)-1; l < r; l, r = l+1, r-1 {
|
||||||
|
picked[l], picked[r] = picked[r], picked[l]
|
||||||
|
}
|
||||||
|
for len(picked) > 0 && picked[0].Role != "user" {
|
||||||
|
picked = picked[1:]
|
||||||
|
}
|
||||||
|
var out []aiMsg
|
||||||
|
for _, m := range picked {
|
||||||
|
if n := len(out); n > 0 && out[n-1].Role == m.Role {
|
||||||
|
out[n-1].Content += "\n\n" + m.Content
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// utf8Safe trims broken runes off the ends of a cut string.
|
||||||
|
func utf8Safe(s string) string {
|
||||||
|
for len(s) > 0 && !utf8.ValidString(s) {
|
||||||
|
if r, _ := utf8.DecodeRuneInString(s); r == utf8.RuneError {
|
||||||
|
s = s[1:]
|
||||||
|
} else {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripThink drops the thinking (::думаю:: … ::ответ::, or <think>) before
|
||||||
|
// an answer is kept: it is not worth its room in the talk.
|
||||||
|
func stripThink(s string) string {
|
||||||
|
t := strings.TrimLeft(s, " \n")
|
||||||
|
for _, p := range [][2]string{{"::думаю::", "::ответ::"}, {"<think>", "</think>"}} {
|
||||||
|
if strings.HasPrefix(t, p[0]) {
|
||||||
|
if i := strings.Index(t, p[1]); i >= 0 {
|
||||||
|
return strings.TrimLeft(t[i+len(p[1]):], " \n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -130,7 +130,7 @@ func TestAIChatNeedsUserOn(t *testing.T) {
|
|||||||
_ = s.db.SetConfig(cfgAIType, "openai")
|
_ = s.db.SetConfig(cfgAIType, "openai")
|
||||||
_ = s.db.SetConfig(cfgAIURL, srv.URL)
|
_ = s.db.SetConfig(cfgAIURL, srv.URL)
|
||||||
post := func() *httptest.ResponseRecorder {
|
post := func() *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"context":"x"}`))
|
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"hi","context":"x"}`))
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
s.Handler().ServeHTTP(w, req)
|
s.Handler().ServeHTTP(w, req)
|
||||||
@@ -146,6 +146,10 @@ func TestAIChatNeedsUserOn(t *testing.T) {
|
|||||||
if w.Code != 200 || string(b) != "ok" {
|
if w.Code != 200 || string(b) != "ok" {
|
||||||
t.Fatalf("on: %d %q", w.Code, b)
|
t.Fatalf("on: %d %q", w.Code, b)
|
||||||
}
|
}
|
||||||
|
// The talk is kept: the question and the answer.
|
||||||
|
if h := s.aiHistory(a.ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" {
|
||||||
|
t.Fatalf("history: %+v", h)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVersionAtLeast(t *testing.T) {
|
func TestVersionAtLeast(t *testing.T) {
|
||||||
@@ -158,3 +162,41 @@ func TestVersionAtLeast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The window the model sees: newest first within the budget, old results
|
||||||
|
// and answers cut down, results as the user's words, same roles merged.
|
||||||
|
func TestAIWindow(t *testing.T) {
|
||||||
|
big := strings.Repeat("x", 5000)
|
||||||
|
rows := []aiMsg{
|
||||||
|
{Role: "user", Content: "старое"},
|
||||||
|
{Role: "assistant", Content: big},
|
||||||
|
{Role: "result", Content: big},
|
||||||
|
{Role: "user", Content: "дальше"},
|
||||||
|
{Role: "assistant", Content: "ок"},
|
||||||
|
{Role: "result", Content: "вывод"},
|
||||||
|
}
|
||||||
|
w := aiWindow(rows, 100000)
|
||||||
|
if len(w) != 5 || w[0].Role != "user" || w[len(w)-1].Role != "user" {
|
||||||
|
t.Fatalf("roles: %+v", w)
|
||||||
|
}
|
||||||
|
if len(w[1].Content) > 3200 || len(w[2].Content) > 1700+len("дальше")+40 {
|
||||||
|
t.Fatalf("old not cut: %d %d", len(w[1].Content), len(w[2].Content))
|
||||||
|
}
|
||||||
|
if !strings.Contains(w[4].Content, "вывод") || !strings.HasPrefix(w[4].Content, "Результат") {
|
||||||
|
t.Fatalf("last result: %q", w[4].Content)
|
||||||
|
}
|
||||||
|
// A small budget keeps the newest, starting with the user.
|
||||||
|
w = aiWindow(rows, 60)
|
||||||
|
if len(w) == 0 || w[0].Role != "user" || !strings.Contains(w[len(w)-1].Content, "вывод") {
|
||||||
|
t.Fatalf("small: %+v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripThink(t *testing.T) {
|
||||||
|
if got := stripThink("::думаю::\nхм\n::ответ::\n391"); got != "391" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
if got := stripThink("просто"); got != "просто" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,11 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
|||||||
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
|
||||||
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS ai_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS ai_messages_admin ON ai_messages(admin_id, id)`,
|
||||||
`CREATE TABLE IF NOT EXISTS ssh_hosts (
|
`CREATE TABLE IF NOT EXISTS ssh_hosts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete))
|
mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete))
|
||||||
mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle))
|
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 /api/panel/ai/chat", s.requireAdmin(s.handleAIChat))
|
||||||
|
mux.Handle("GET /api/panel/ai/history", s.requireAdmin(s.handleAIHistory))
|
||||||
|
mux.Handle("POST /api/panel/ai/clear", s.requireAdmin(s.handleAIClear))
|
||||||
|
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
|
||||||
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
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))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
box.innerHTML =
|
box.innerHTML =
|
||||||
'<div class="ai-head"><b>Ассистент</b><span class="muted ai-where"></span><span class="spacer"></span>' +
|
'<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 sq ai-clear" title="Очистить разговор" aria-label="Очистить разговор"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 4 5 5"/><path d="M17.5 6.5 11 13"/><path d="M11 13c-2-2-5-1.5-6.5 0L3 14.5 9.5 21l1.5-1.5c1.5-1.5 2-4.5 0-6.5z"/><path d="m6 17 2-2M8.5 19.5 10 18"/></svg></button>' +
|
||||||
'<button type="button" class="btn ghost small icon close x 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>' +
|
'<button type="button" class="btn ghost small icon close x 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>' +
|
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
|
||||||
'<div class="ai-pills">' +
|
'<div class="ai-pills">' +
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
document.body.append(fab, box);
|
document.body.append(fab, box);
|
||||||
const q = s => box.querySelector(s);
|
const q = s => box.querySelector(s);
|
||||||
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
|
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
|
||||||
const talk = []; // {role, content}
|
// The talk lives on the server (the database); here only what is shown.
|
||||||
|
let loaded = false;
|
||||||
let ac = null;
|
let ac = null;
|
||||||
|
|
||||||
// Open and shut with a short slide. On a phone the chat takes the screen
|
// Open and shut with a short slide. On a phone the chat takes the screen
|
||||||
@@ -43,8 +44,10 @@
|
|||||||
const phone = () => matchMedia('(max-width: 640px)').matches;
|
const phone = () => matchMedia('(max-width: 640px)').matches;
|
||||||
const isOpen = () => box.classList.contains('on');
|
const isOpen = () => box.classList.contains('on');
|
||||||
function show() {
|
function show() {
|
||||||
|
loadTalk();
|
||||||
placeBox();
|
placeBox();
|
||||||
box.classList.add('on');
|
box.classList.add('on');
|
||||||
|
fab.classList.add('away');
|
||||||
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
|
||||||
where();
|
where();
|
||||||
if (!phone()) input.focus();
|
if (!phone()) input.focus();
|
||||||
@@ -52,10 +55,24 @@
|
|||||||
function hide() {
|
function hide() {
|
||||||
if (!isOpen()) return;
|
if (!isOpen()) return;
|
||||||
if (history.state && history.state.aiChat) { history.back(); return; }
|
if (history.state && history.state.aiChat) { history.back(); return; }
|
||||||
box.classList.remove('on');
|
shut();
|
||||||
}
|
}
|
||||||
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) box.classList.remove('on'); });
|
// shut: the chat goes, and the ball comes back in its corner - where the
|
||||||
|
// chat was last moved to.
|
||||||
|
function shut() {
|
||||||
|
if (desk && !phone()) {
|
||||||
|
const b = box.getBoundingClientRect(), o = box.style.transformOrigin || 'bottom right';
|
||||||
|
const right = /right/.test(o) ? innerWidth - b.right : innerWidth - b.left - fab.offsetWidth;
|
||||||
|
const bottom = /bottom/.test(o) ? innerHeight - b.bottom : innerHeight - b.top - fab.offsetHeight;
|
||||||
|
const pos = placeFab(right, bottom);
|
||||||
|
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
|
||||||
|
}
|
||||||
|
box.classList.remove('on');
|
||||||
|
fab.classList.remove('away');
|
||||||
|
}
|
||||||
|
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
|
||||||
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
|
||||||
|
const desk = !matchMedia('(pointer: coarse)').matches;
|
||||||
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
|
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));
|
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
|
||||||
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
|
||||||
@@ -64,7 +81,6 @@
|
|||||||
// The ball goes where it is dragged and stays there (kept in this
|
// The ball goes where it is dragged and stays there (kept in this
|
||||||
// browser, as distances from the right and bottom edges); the chat opens
|
// browser, as distances from the right and bottom edges); the chat opens
|
||||||
// beside it, toward the room, and moves by its title.
|
// beside it, toward the room, and moves by its title.
|
||||||
const desk = !matchMedia('(pointer: coarse)').matches;
|
|
||||||
let moved = false;
|
let moved = false;
|
||||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
function placeFab(right, bottom) {
|
function placeFab(right, bottom) {
|
||||||
@@ -77,10 +93,9 @@
|
|||||||
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
|
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
|
||||||
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
|
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
|
||||||
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
|
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
|
||||||
let x = toLeft ? f.right - w : f.left;
|
// The chat takes the ball's place, grown from its corner.
|
||||||
let y = up ? f.top - gap - h : f.bottom + gap;
|
const x = toLeft ? f.right - w : f.left;
|
||||||
// No room above or below: beside the ball.
|
const y = up ? f.bottom - h : f.top;
|
||||||
if (y < 8 || y + h > innerHeight - 8) { y = clamp(f.top + f.height - h, 8, innerHeight - h - 8); x = toLeft ? f.left - gap - w : f.right + gap; }
|
|
||||||
box.style.right = box.style.bottom = 'auto';
|
box.style.right = box.style.bottom = 'auto';
|
||||||
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
|
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
|
||||||
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
|
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
|
||||||
@@ -126,12 +141,25 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
q('.ai-close').onclick = hide;
|
q('.ai-close').onclick = hide;
|
||||||
q('.ai-clear').onclick = () => {
|
q('.ai-clear').onclick = async () => {
|
||||||
|
if (!list.querySelector('.ai-msg') || !await UI.confirm('Очистить разговор?', {ok: 'Очистить', danger: true})) return;
|
||||||
if (ac) ac.abort();
|
if (ac) ac.abort();
|
||||||
talk.length = 0;
|
fetch('/api/panel/ai/clear', {method: 'POST', credentials: 'same-origin'});
|
||||||
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
|
||||||
q('.ai-hello').hidden = false;
|
q('.ai-hello').hidden = false;
|
||||||
};
|
};
|
||||||
|
// The talk so far, once, when the chat first opens.
|
||||||
|
async function loadTalk() {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
const h = await fetch('/api/panel/ai/history', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
|
||||||
|
h.forEach(m => {
|
||||||
|
if (m.role === 'result') showResult(m.content);
|
||||||
|
else { const el = add(m.role, m.content); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
|
||||||
|
});
|
||||||
|
} catch (e) { loaded = false; }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- what the panel shows ----------
|
// ---------- what the panel shows ----------
|
||||||
const W = () => window.Workspace;
|
const W = () => window.Workspace;
|
||||||
@@ -304,12 +332,12 @@
|
|||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
// A result goes into the talk as the user's message and shows folded.
|
// An action's result shows folded; it reaches the talk on the server with
|
||||||
function result(text) {
|
// the next question (or by note, when done by hand).
|
||||||
|
function showResult(text) {
|
||||||
const el = add('result', '');
|
const el = add('result', '');
|
||||||
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
|
||||||
el.querySelector('pre').textContent = text;
|
el.querySelector('pre').textContent = text;
|
||||||
talk.push({role: 'user', content: text});
|
|
||||||
}
|
}
|
||||||
list.addEventListener('click', async ev => {
|
list.addEventListener('click', async ev => {
|
||||||
const b = ev.target.closest('.ai-act');
|
const b = ev.target.closest('.ai-act');
|
||||||
@@ -320,11 +348,14 @@
|
|||||||
b.textContent = '…';
|
b.textContent = '…';
|
||||||
const res = await perform(a);
|
const res = await perform(a);
|
||||||
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||||
result(res);
|
showResult(res);
|
||||||
|
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({content: res})});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function ask(text) {
|
// ask sends the new words (or an action's result) and shows the answer.
|
||||||
if (text != null) { talk.push({role: 'user', content: text}); add('user', text); }
|
async function ask(text, res) {
|
||||||
|
if (text) add('user', text);
|
||||||
|
if (res) showResult(res);
|
||||||
const el = add('assistant', '');
|
const el = add('assistant', '');
|
||||||
el.classList.add('wait');
|
el.classList.add('wait');
|
||||||
ac = new AbortController();
|
ac = new AbortController();
|
||||||
@@ -334,7 +365,7 @@
|
|||||||
await loadMachines();
|
await loadMachines();
|
||||||
const r = await fetch('/api/panel/ai/chat', {
|
const r = await fetch('/api/panel/ai/chat', {
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
|
||||||
body: JSON.stringify({messages: talk, context: context(), agent: agentOn(), think: thinkOn()})
|
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn()})
|
||||||
});
|
});
|
||||||
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
|
||||||
if (!r.body) throw new Error('ошибка ' + r.status);
|
if (!r.body) throw new Error('ошибка ' + r.status);
|
||||||
@@ -352,7 +383,6 @@
|
|||||||
} finally {
|
} finally {
|
||||||
el.classList.remove('wait');
|
el.classList.remove('wait');
|
||||||
render(el, answer || '[нет ответа]', true);
|
render(el, answer || '[нет ответа]', true);
|
||||||
talk.push({role: 'assistant', content: stripThink(answer)});
|
|
||||||
}
|
}
|
||||||
return {el, answer: stripThink(answer)};
|
return {el, answer: stripThink(answer)};
|
||||||
}
|
}
|
||||||
@@ -379,16 +409,14 @@
|
|||||||
const b = turn.el.querySelector('.ai-act');
|
const b = turn.el.querySelector('.ai-act');
|
||||||
if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) {
|
if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) {
|
||||||
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
|
||||||
result('Пользователь отклонил это действие. Предложи другое или закончи.');
|
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
|
||||||
turn = await ask(null);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (b) { b.disabled = true; b.textContent = '…'; }
|
if (b) { b.disabled = true; b.textContent = '…'; }
|
||||||
const res = await perform(a, ac.signal);
|
const res = await perform(a, ac.signal);
|
||||||
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
|
||||||
result(res);
|
if (ac.signal.aborted) { showResult(res); break; }
|
||||||
if (ac.signal.aborted) break;
|
turn = await ask(null, res);
|
||||||
turn = await ask(null);
|
|
||||||
}
|
}
|
||||||
ac = null;
|
ac = null;
|
||||||
send.innerHTML = PLANE; send.title = 'Отправить';
|
send.innerHTML = PLANE; send.title = 'Отправить';
|
||||||
|
|||||||
@@ -888,3 +888,8 @@ body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
|
|||||||
body.touch.term-active .ai-fab { bottom:110px; }
|
body.touch.term-active .ai-fab { bottom:110px; }
|
||||||
.ai-form-in textarea { font-size:16px; }
|
.ai-form-in textarea { font-size:16px; }
|
||||||
}
|
}
|
||||||
|
.ai-clear { width:30px; height:30px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
|
||||||
|
/* The ball steps aside while the chat is open, and back when it shuts. */
|
||||||
|
.ai-fab { transition:transform .22s ease, opacity .22s ease, box-shadow .2s ease, right .25s ease, bottom .25s ease; }
|
||||||
|
.ai-fab.away { opacity:0; transform:scale(.5); pointer-events:none; }
|
||||||
|
body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
|
||||||
Reference in new issue
Block a user