Files
lpra/internal/server/ai.go
T

1327 lines
43 KiB
Go

package server
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"path"
"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 (
// cfgAIModels: the models to choose from, as JSON - the first is the
// default. The four keys after it are the one model of before.
cfgAIModels = "ai_models"
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"
)
// aiNativeKey is the config key of what was learned of a model: "1" it
// thinks by itself, "0" it does not, missing - not known yet.
func aiNativeKey(id string) string { return "ai_native_" + id }
// aiUserKey is the config key of a user's own switch. Missing means on by
// default; an explicit "0" means the user turned it off.
func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10) }
// aiOn: the user has the assistant - on unless turned off.
func (s *Server) aiOn(id int64) bool {
v, _ := s.db.GetConfig(aiUserKey(id))
return v != "0"
}
// aiConfig is one model the assistant can ask: its id and the names the chat
// shows (Short, up to 12 characters, on the button of the model in use), the
// kind of API, its address, model and key.
type aiConfig struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Short string `json:"short,omitempty"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Key string `json:"key,omitempty"`
Model string `json:"model,omitempty"`
// think, for one request: "on" asks for the model's own thinking, "off"
// asks it not to think, "" leaves it to the model.
think string
}
// label is the model's name in the chat: its own, else its model or kind.
func (c aiConfig) label() string {
switch {
case c.Name != "":
return c.Name
case c.Model != "":
return c.Model
case c.Type == "openai":
return "OpenAI"
case c.Type == "anthropic":
return "Anthropic"
}
return "Qwen Omni"
}
// tidy keeps a known kind and gives gradio its default address.
func (c aiConfig) tidy() aiConfig {
if c.Type != "openai" && c.Type != "anthropic" {
c.Type = "gradio"
}
c.Name, c.Model, c.Key = strings.TrimSpace(c.Name), strings.TrimSpace(c.Model), strings.TrimSpace(c.Key)
if r := []rune(strings.TrimSpace(c.Short)); len(r) > 12 {
c.Short = strings.TrimSpace(string(r[:12]))
} else {
c.Short = string(r)
}
c.URL = strings.TrimRight(strings.TrimSpace(c.URL), "/")
if c.URL == "" && c.Type == "gradio" {
c.URL = DefaultAIURL
}
return c
}
type aiAttachment struct {
Name string `json:"name"`
MIME string `json:"mime"`
Data string `json:"data,omitempty"`
Text string `json:"text,omitempty"`
}
type aiMsg struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments []aiAttachment `json:"-"`
// ThinkMS: how long the model thought before this answer (kept to show it).
ThinkMS int64 `json:"think_ms,omitempty"`
}
// aiModels are the models set in Настройки, never none: before any is
// saved, the one of the older settings (or the default).
func (s *Server) aiModels() []aiConfig {
var ms []aiConfig
if v, _ := s.db.GetConfig(cfgAIModels); v != "" {
_ = json.Unmarshal([]byte(v), &ms)
}
if len(ms) == 0 {
get := func(k string) string { v, _ := s.db.GetConfig(k); return v }
ms = []aiConfig{{ID: "1", Type: get(cfgAIType), URL: get(cfgAIURL), Key: get(cfgAIKey), Model: get(cfgAIModel)}}
}
for i := range ms {
ms[i] = ms[i].tidy()
}
return ms
}
// aiModel is the model of that id, or the first.
func (s *Server) aiModel(id string) aiConfig {
ms := s.aiModels()
for _, m := range ms {
if m.ID == id {
return m
}
}
return ms[0]
}
// aiConfig is the default model, the first.
func (s *Server) aiConfig() aiConfig { return s.aiModel("") }
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()
}
// aiHTTPError is a model's answer other than 2xx.
type aiHTTPError struct {
Code int
Msg string
}
func (e aiHTTPError) Error() string {
return fmt.Sprintf("модель ответила %d: %s", e.Code, e.Msg)
}
// badRequest: the model did not take the request as it was (an option it
// does not know, say).
func badRequest(err error) bool {
var e aiHTTPError
return errors.As(err, &e) && (e.Code == http.StatusBadRequest || e.Code == http.StatusUnprocessableEntity)
}
// thinkOpen and thinkClose frame a model's own thinking in the answer, as
// the chat shows thinking (and as a model asked to think in words writes it).
const (
thinkOpen = "::думаю::\n"
thinkClose = "\n::ответ::\n"
)
// thinkWrap passes on a model's thinking and answer as they come, the
// thinking framed by thinkOpen and thinkClose.
type thinkWrap struct {
emit func(string)
state int // 0: nothing yet, 1: thinking, 2: answering
}
func (w *thinkWrap) think(t string) {
if t == "" || w.state == 2 {
return
}
if w.state == 0 {
w.emit(thinkOpen)
w.state = 1
}
w.emit(t)
}
func (w *thinkWrap) text(t string) {
if t == "" {
return
}
if w.state == 1 {
w.emit(thinkClose)
}
w.state = 2
w.emit(t)
}
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, aiHTTPError{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...)
apiMessages := make([]map[string]any, 0, len(all))
for i, m := range all {
var content any = m.Content
if i == len(all)-1 && len(m.Attachments) > 0 {
blocks := []any{}
for _, a := range m.Attachments {
if a.Data == "" {
continue
}
mediaURL := "data:" + a.MIME + ";base64," + a.Data
switch {
case strings.HasPrefix(a.MIME, "image/"):
blocks = append(blocks, map[string]any{"type": "image_url", "image_url": map[string]string{"url": mediaURL}})
case strings.HasPrefix(a.MIME, "audio/"):
format := strings.TrimPrefix(a.MIME, "audio/")
if format == "mpeg" || format == "mp3" {
format = "mp3"
} else if format == "x-wav" || format == "wave" {
format = "wav"
}
blocks = append(blocks, map[string]any{"type": "input_audio", "input_audio": map[string]string{"data": mediaURL, "format": format}})
case strings.HasPrefix(a.MIME, "video/"):
blocks = append(blocks, map[string]any{"type": "video_url", "video_url": map[string]string{"url": mediaURL}, "fps": 1})
}
}
blocks = append(blocks, map[string]any{"type": "text", "text": m.Content})
content = blocks
}
apiMessages = append(apiMessages, map[string]any{"role": m.Role, "content": content})
}
hdr := map[string]string{}
if cfg.Key != "" {
hdr["Authorization"] = "Bearer " + cfg.Key
}
// Thinking on or off by the chat template's switch (llama.cpp, vLLM,
// Ollama…); a server that does not know it is asked again without.
body := map[string]any{"model": cfg.Model, "stream": true, "messages": apiMessages}
if cfg.think != "" {
body["chat_template_kwargs"] = map[string]any{"enable_thinking": cfg.think == "on"}
}
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", body, hdr)
if err != nil && cfg.think != "" && badRequest(err) {
delete(body, "chat_template_kwargs")
resp, err = aiPost(ctx, cfg.URL+"/chat/completions", body, hdr)
}
if err != nil {
return err
}
tw := &thinkWrap{emit: emit}
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"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `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 {
tw.think(c.Delta.ReasoningContent + c.Delta.Reasoning)
tw.text(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"
}
apiMessages := make([]map[string]any, 0, len(msgs))
for i, m := range msgs {
var content any = m.Content
if i == len(msgs)-1 && len(m.Attachments) > 0 {
blocks := []any{map[string]any{"type": "text", "text": m.Content}}
for _, a := range m.Attachments {
if a.Data == "" || !strings.HasPrefix(a.MIME, "image/") {
continue
}
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]string{"type": "base64", "media_type": a.MIME, "data": a.Data}})
}
content = blocks
}
apiMessages = append(apiMessages, map[string]any{"role": m.Role, "content": content})
}
body := map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": apiMessages, "stream": true}
hdr := map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"}
// Extended thinking, for «Думать»; a model without it is asked again
// as it was.
if cfg.think == "on" {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 4096}
body["max_tokens"] = 12288
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", body, hdr)
if err != nil && cfg.think == "on" && badRequest(err) {
delete(body, "thinking")
body["max_tokens"] = 4096
resp, err = aiPost(ctx, cfg.URL+"/v1/messages", body, hdr)
}
if err != nil {
return err
}
tw := &thinkWrap{emit: emit}
defer resp.Body.Close()
return sseData(resp.Body, func(d string) (bool, error) {
var m struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
} `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":
if m.Delta.Type == "thinking_delta" {
tw.think(m.Delta.Thinking)
} else {
tw.text(m.Delta.Text)
}
case "message_stop":
return true, nil
case "error":
return true, errors.New(m.Error.Message)
}
return false, nil
})
}
func aiGradioUpload(ctx context.Context, cfg aiConfig, hdr map[string]string, a aiAttachment) (map[string]any, error) {
data, err := base64.StdEncoding.DecodeString(a.Data)
if err != nil {
return nil, errors.New("вложение повреждено")
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
part, err := mw.CreateFormFile("files", path.Base(a.Name))
if err != nil {
return nil, err
}
if _, err = part.Write(data); err != nil {
return nil, err
}
if err = mw.Close(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL+"/gradio_api/upload", &body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := aiClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 400))
return nil, fmt.Errorf("Gradio не принял файл (%d): %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
var paths []string
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&paths); err != nil || len(paths) == 0 || paths[0] == "" {
return nil, errors.New("Gradio не вернул путь загруженного файла")
}
return map[string]any{"path": paths[0], "url": paths[0], "orig_name": path.Base(a.Name), "mime_type": a.MIME, "size": len(data), "meta": map[string]string{"_type": "gradio.FileData"}}, 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 {
Components []struct {
ID int `json:"id"`
Type string `json:"type"`
} `json:"components"`
Dependencies []struct {
ID int `json:"id"`
APIName string `json:"api_name"`
Targets [][]any `json:"targets"`
Inputs []int `json:"inputs"`
} `json:"dependencies"`
}
err = json.NewDecoder(resp.Body).Decode(&conf)
resp.Body.Close()
if err != nil {
return errors.New("модель: не прочиталась её конфигурация")
}
fn, trigger := -1, any(nil)
var inputs []int
for _, d := range conf.Dependencies {
if d.APIName == "chat_predict" {
fn = d.ID
inputs = d.Inputs
if len(d.Targets) > 0 && len(d.Targets[0]) > 0 {
trigger = d.Targets[0][0]
}
}
}
// Its inputs in order, by kind - the files go where the app has them.
// An app that does not say: Qwen Omni's order.
kinds := []string{"textbox", "audio", "image", "video", "chatbot", "textbox", "slider", "slider", "slider"}
if len(inputs) > 0 {
types := map[int]string{}
for _, c := range conf.Components {
types[c.ID] = c.Type
}
kinds = kinds[:0:0]
for _, id := range inputs {
kinds = append(kinds, types[id])
}
}
if fn < 0 {
return errors.New("модель: нет функции chat_predict")
}
last := msgs[len(msgs)-1]
media := map[string]any{}
for _, a := range last.Attachments {
kind := strings.SplitN(a.MIME, "/", 2)[0]
if a.Data == "" || (kind != "image" && kind != "audio" && kind != "video") {
continue
}
fileData, err := aiGradioUpload(ctx, cfg, hdr, a)
if err != nil {
return err
}
if kind == "video" {
// Gradio 5 takes a video with its subtitles.
media[kind] = map[string]any{"video": fileData, "subtitles": nil}
} else {
media[kind] = fileData
}
}
// The values in the inputs' order: the question in the first text box,
// the system prompt in the second, the talk in the chatbot, the files,
// and temperature, top p and top k in the sliders.
data := make([]any, len(kinds))
texts, sliders := 0, []any{0.7, 0.8, 20}
for i, k := range kinds {
switch k {
case "textbox":
if texts == 0 {
data[i] = last.Content
} else {
data[i] = system
}
texts++
case "image", "audio", "video":
data[i] = media[k]
case "chatbot":
data[i] = msgs[:len(msgs)-1]
case "slider":
if len(sliders) > 0 {
data[i], sliders = sliders[0], sliders[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": data,
"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```" + ` - команда в терминале этой машины (машину можно не указывать - тогда активная);
` + "```read машина:/путь\n```" + ` - прочитать файл;
` + "```ls машина:/путь\n```" + ` - список папки;
` + "```write машина:/путь\nполное новое содержимое файла\n```" + ` - записать файл целиком.
Имя машины - как в списке машин. Одна команда на блок, без пояснений внутри блока. Результат действия придёт следующим сообщением - не выдумывай его.
Опасные команды (удаление, перезапуск, изменение системы) - только с предупреждением. Перед правкой файла сначала прочитай его.`
const aiAgent = `
Режим агента: ты сам ведёшь задачу шаг за шагом. В каждом ответе - коротко, что делаешь, и ровно одно действие. Получив результат, решай следующий шаг.
Когда задача решена (или решить нельзя) - ответь итогом без блоков действий.`
const aiThink = `
Режим размышления: начни ответ строкой ::думаю:: и подумай - разбери задачу, варианты и риски; затем строка ::ответ:: и сам ответ.`
// The talks are kept in the database: each user's chats, newest first, the
// last aiKeep messages of each and at most aiChatsKeep chats.
const (
aiKeep = 200
aiChatsKeep = 50
)
type aiChat struct {
ID int64 `json:"id"`
Title string `json:"title"`
Updated int64 `json:"updated"`
}
// aiNewChat starts a talk; the oldest go past aiChatsKeep.
func (s *Server) aiNewChat(owner int64) int64 {
now := time.Now().Unix()
id, err := s.db.sql.insert(`INSERT INTO ai_chats (admin_id, title, created, updated) VALUES (?, '', ?, ?)`, owner, now, now)
if err != nil {
return 0
}
rows, err := s.db.sql.Query(`SELECT id FROM ai_chats WHERE admin_id = ? ORDER BY updated DESC, id DESC LIMIT -1 OFFSET ?`, owner, aiChatsKeep)
if s.db.sql.pg {
rows, err = s.db.sql.Query(`SELECT id FROM ai_chats WHERE admin_id = ? ORDER BY updated DESC, id DESC OFFSET ?`, owner, aiChatsKeep)
}
if err == nil {
var old []int64
for rows.Next() {
var c int64
if rows.Scan(&c) == nil {
old = append(old, c)
}
}
rows.Close()
for _, c := range old {
s.aiDeleteChat(owner, c)
}
}
return id
}
// aiOwns: chat is one of owner's.
func (s *Server) aiOwns(owner, chat int64) bool {
var n int
_ = s.db.sql.QueryRow(`SELECT COUNT(*) FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner).Scan(&n)
return n == 1
}
func (s *Server) aiChats(owner int64) []aiChat {
out := []aiChat{}
rows, err := s.db.sql.Query(`SELECT id, title, updated FROM ai_chats WHERE admin_id = ? ORDER BY updated DESC, id DESC`, owner)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var c aiChat
if rows.Scan(&c.ID, &c.Title, &c.Updated) == nil {
if c.Title == "" {
c.Title = "Новый чат"
}
out = append(out, c)
}
}
return out
}
func (s *Server) aiDeleteChat(owner, chat int64) {
if !s.aiOwns(owner, chat) {
return
}
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND admin_id = ?`, chat, owner)
_, _ = s.db.sql.Exec(`DELETE FROM ai_chats WHERE id = ? AND admin_id = ?`, chat, owner)
s.db.wipeDeleted()
}
// aiAppendTo keeps a message of owner's chat; the first question names it.
func (s *Server) aiAppendTo(owner, chat int64, role, content string) {
s.aiKeepMsg(owner, chat, aiMsg{Role: role, Content: content})
}
func (s *Server) aiKeepMsg(owner, chat int64, m aiMsg) {
role, content := m.Role, m.Content
if content == "" || !s.aiOwns(owner, chat) {
return
}
if len(content) > 64<<10 {
content = utf8Safe(content[:64<<10])
}
now := time.Now().Unix()
_, _ = s.db.sql.Exec(`INSERT INTO ai_messages (admin_id, chat_id, role, content, at, think_ms) VALUES (?, ?, ?, ?, ?, ?)`, owner, chat, role, content, now, m.ThinkMS)
_, _ = s.db.sql.Exec(`DELETE FROM ai_messages WHERE chat_id = ? AND id <= (SELECT id FROM ai_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?)`, chat, chat, aiKeep)
s.db.wipeDeleted()
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET updated = ? WHERE id = ?`, now, chat)
if role == "user" {
t := strings.Join(strings.Fields(content), " ")
if r := []rune(t); len(r) > 40 {
t = string(r[:40]) + "…"
}
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET title = ? WHERE id = ? AND title = ''`, t, chat)
}
}
// aiHistoryOf is a chat's talk, oldest first, the last limit messages.
func (s *Server) aiHistoryOf(owner, chat int64, limit int) []aiMsg {
rows, err := s.db.sql.Query(`SELECT role, content, think_ms FROM ai_messages WHERE admin_id = ? AND chat_id = ? ORDER BY id DESC LIMIT ?`, owner, chat, limit)
out := []aiMsg{}
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var m aiMsg
if rows.Scan(&m.Role, &m.Content, &m.ThinkMS) == 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
}
// aiAdopt gives the talk kept before there were several (chat 0) a chat of
// its own, once.
func (s *Server) aiAdopt(owner int64) {
var n int
_ = s.db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE admin_id = ? AND chat_id = 0`, owner).Scan(&n)
if n == 0 {
return
}
id := s.aiNewChat(owner)
_, _ = s.db.sql.Exec(`UPDATE ai_messages SET chat_id = ? WHERE admin_id = ? AND chat_id = 0`, id, owner)
var first string
_ = s.db.sql.QueryRow(`SELECT content FROM ai_messages WHERE chat_id = ? AND role = 'user' ORDER BY id LIMIT 1`, id).Scan(&first)
if r := []rune(strings.Join(strings.Fields(first), " ")); len(r) > 0 {
t := string(r)
if len(r) > 40 {
t = string(r[:40]) + "…"
}
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET title = ? WHERE id = ?`, t, id)
}
}
// 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) {
a := s.currentAdmin(r)
if !s.aiOn(a.ID) {
http.Error(w, "ассистент выключен в профиле", http.StatusForbidden)
return
}
var in struct {
Message string `json:"message"`
Result string `json:"result"`
Context string `json:"context"`
Agent bool `json:"agent"`
Think bool `json:"think"`
Search bool `json:"search"`
Chat int64 `json:"chat"`
Attachments []aiAttachment `json:"attachments"`
Model string `json:"model"` // the model's id; "" or unknown: the first
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 24<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "" && len(in.Attachments) == 0) {
http.Error(w, "плохой запрос", http.StatusBadRequest)
return
}
if len(in.Attachments) > 6 {
http.Error(w, "можно прикрепить не больше 6 файлов", http.StatusBadRequest)
return
}
cfg := s.aiModel(in.Model)
total := 0
mediaCounts := map[string]int{}
for i := range in.Attachments {
f := &in.Attachments[i]
if len(f.Name) == 0 || len(f.Name) > 512 || len(f.Text) > 120000 {
http.Error(w, "неверное вложение", http.StatusBadRequest)
return
}
if f.Data != "" {
decoded, err := base64.StdEncoding.DecodeString(f.Data)
if err != nil || len(decoded) > 8<<20 {
http.Error(w, "вложение повреждено или больше 8 МиБ", http.StatusBadRequest)
return
}
total += len(decoded)
if total > 16<<20 {
http.Error(w, "общий размер вложений больше 16 МиБ", http.StatusRequestEntityTooLarge)
return
}
kind := ""
switch {
case strings.HasPrefix(f.MIME, "image/"):
kind = "image"
case strings.HasPrefix(f.MIME, "audio/"):
kind = "audio"
case strings.HasPrefix(f.MIME, "video/"):
kind = "video"
default:
http.Error(w, "этот формат вложения не поддерживается", http.StatusBadRequest)
return
}
mediaCounts[kind]++
}
if f.Text == "" && f.Data == "" {
http.Error(w, "пустое вложение", http.StatusBadRequest)
return
}
}
if cfg.Type == "anthropic" && (mediaCounts["audio"] > 0 || mediaCounts["video"] > 0) {
http.Error(w, "Anthropic API в этой панели принимает только изображения; для аудио и видео используйте OpenAI-compatible API", http.StatusBadRequest)
return
}
if cfg.Type != "openai" && cfg.Type != "anthropic" && (mediaCounts["image"] > 1 || mediaCounts["audio"] > 1 || mediaCounts["video"] > 1) {
http.Error(w, "Gradio принимает не больше одного файла каждого медиа-типа за сообщение", http.StatusBadRequest)
return
}
stored := strings.TrimSpace(in.Message)
for _, f := range in.Attachments {
stored += "\n\n[Вложение: " + f.Name + "]"
if f.Text != "" {
stored += "\nСодержимое файла:\n" + f.Text
}
}
stored = strings.TrimSpace(stored)
// No chat (or not one of the user's): a new one; its id goes back in
// X-Chat-Id.
if in.Chat == 0 || !s.aiOwns(a.ID, in.Chat) {
in.Chat = s.aiNewChat(a.ID)
}
w.Header().Set("X-Chat-Id", strconv.FormatInt(in.Chat, 10))
fresh := stored != "" && len(s.aiHistoryOf(a.ID, in.Chat, 1)) == 0
s.aiAppendTo(a.ID, in.Chat, "result", in.Result)
s.aiAppendTo(a.ID, in.Chat, "user", stored)
if fresh {
// A new chat gets a short name from the model, aside.
go s.aiNameChat(cfg, a.ID, in.Chat, stored)
}
msgs := aiWindow(s.aiHistoryOf(a.ID, in.Chat, 60), 24000)
if len(msgs) > 0 && msgs[len(msgs)-1].Role == "user" {
msgs[len(msgs)-1].Attachments = in.Attachments
}
if len(in.Context) > 30000 {
in.Context = utf8Safe(in.Context[len(in.Context)-30000:])
}
system := aiSystem
// Pictures, sound and video go to the model itself: it is told so, or
// it takes them for something a panel assistant does not do.
var media []string
for _, f := range in.Attachments {
if f.Data != "" {
media = append(media, f.Name)
}
}
if len(media) > 0 {
system += "\n\nК последнему сообщению пользователя приложены файлы (" + strings.Join(media, ", ") +
"): ты получаешь их сам - видишь изображения и видео, слышишь аудио. Отвечай на вопросы о них по существу."
}
system += s.imagePrompt()
if in.Agent {
system += aiAgent
}
if in.Search {
system += searchPrompt(s.searchConf())
}
// «Думать»: a model with thinking of its own (OpenAI-compatible or
// Anthropic) thinks by itself, once; the others are asked to think in
// words. Which it is, is learned from its answers and kept. Without
// «Думать» a model that would think anyway is asked not to.
native := ""
if cfg.Type == "openai" || cfg.Type == "anthropic" {
native, _ = s.db.GetConfig(aiNativeKey(cfg.ID))
}
ownThink := in.Think && (cfg.Type == "openai" || cfg.Type == "anthropic") && native != "0"
promptThink := func() {
system += aiThink
// Models heed the last words most: the reminder goes there too (not
// kept with the talk).
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" {
msgs[n-1].Content += "\n\n(Сначала подумай: первая строка ответа - ::думаю::, дальше рассуждение, потом строка ::ответ:: и сам ответ.)"
}
if cfg.Type == "openai" {
cfg.think = "off"
} else {
cfg.think = ""
}
}
switch {
case ownThink:
cfg.think = "on"
case in.Think:
promptThink()
case cfg.Type == "openai":
cfg.think = "off"
}
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
var thinkFrom, thinkTo time.Time // when the thinking began and ended
emit := func(t string) {
if t == "" {
return
}
answer.WriteString(t)
if a := answer.String(); thinkFrom.IsZero() && (strings.Contains(a, "::думаю::") || strings.Contains(a, "<think>")) {
thinkFrom = time.Now()
} else if !thinkFrom.IsZero() && thinkTo.IsZero() && (strings.Contains(a, "::ответ::") || strings.Contains(a, "</think>")) {
thinkTo = time.Now()
}
_, _ = io.WriteString(w, t)
if fl != nil {
fl.Flush()
}
}
run := func(ctx context.Context, emit func(string)) error {
return aiStream(ctx, cfg, system+"\n\n--- Панель сейчас ---\n"+in.Context, msgs, emit)
}
var err error
if ownThink && native == "" {
// Not known yet: its first words tell. Thinking - it has its own;
// an answer straight away - it has not, and that answer is dropped
// unseen for one asked to think in words.
ctx, cancel := context.WithCancel(r.Context())
first, dropped := true, false
err = run(ctx, func(t string) {
if dropped || t == "" {
return
}
if first {
first = false
if t != thinkOpen {
dropped = true
cancel()
return
}
_ = s.db.SetConfig(aiNativeKey(cfg.ID), "1")
}
emit(t)
})
cancel()
if dropped {
_ = s.db.SetConfig(aiNativeKey(cfg.ID), "0")
promptThink()
err = run(r.Context(), emit)
}
} else {
err = run(r.Context(), emit)
}
// The answer is kept whole, thinking too (to show it again); the model
// gets it without (aiWindow).
ans := aiMsg{Role: "assistant", Content: answer.String()}
if !thinkFrom.IsZero() {
if thinkTo.IsZero() {
thinkTo = time.Now()
}
ans.ThinkMS = thinkTo.Sub(thinkFrom).Milliseconds()
}
s.aiKeepMsg(a.ID, in.Chat, ans)
if err != nil && r.Context().Err() == nil {
if answer.Len() == 0 {
w.WriteHeader(http.StatusBadGateway)
}
_, _ = io.WriteString(w, "\n[ошибка: "+err.Error()+"]")
}
}
// aiNameChat asks the model for a two-or-three-word name of the chat its
// first question opened, and gives the chat that name.
func (s *Server) aiNameChat(cfg aiConfig, owner, chat int64, question string) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if r := []rune(question); len(r) > 600 {
question = string(r[:600])
}
var b strings.Builder
if cfg.Type == "openai" {
cfg.think = "off" // a name needs no thinking
}
err := aiStream(ctx, cfg, "Придумай короткое название чата по первому вопросу пользователя: 2-3 слова, по-русски, "+
"без кавычек и точки в конце. Ответь только названием.", []aiMsg{{Role: "user", Content: question}}, func(t string) { b.WriteString(t) })
if err != nil {
log.Printf("ai: naming chat %d: %v", chat, err)
return
}
if t := chatTitle(b.String()); t != "" {
_, _ = s.db.sql.Exec(`UPDATE ai_chats SET title = ? WHERE id = ? AND admin_id = ?`, t, chat, owner)
}
}
// chatTitle tidies what the model gave for a name: the first line, no
// thinking, quotes or end mark, four words at most.
func chatTitle(s string) string {
s = stripThink(s)
if i := strings.IndexByte(strings.TrimSpace(s), '\n'); i >= 0 {
s = strings.TrimSpace(s)[:i]
}
s = strings.Trim(strings.TrimSpace(s), "\"'«»„“”`*#.!:;")
w := strings.Fields(s)
if len(w) > 4 {
w = w[:4]
}
s = strings.Join(w, " ")
if r := []rune(s); len(r) > 40 {
s = string(r[:40])
}
return s
}
// GET /api/panel/ai/chats - the user's chats, newest first.
func (s *Server) handleAIChats(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
s.aiAdopt(a.ID)
writeJSON(w, s.aiChats(a.ID))
}
// POST /api/panel/ai/chats - a new chat.
func (s *Server) handleAIChatNew(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "id": s.aiNewChat(s.currentAdmin(r).ID)})
}
// POST /api/panel/ai/chats/{cid}/delete
func (s *Server) handleAIChatDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("cid"), 10, 64)
s.aiDeleteChat(s.currentAdmin(r).ID, id)
writeJSON(w, map[string]any{"ok": true})
}
// GET /api/panel/ai/chats/{cid} - a chat's talk to show, oldest first.
func (s *Server) handleAIHistory(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("cid"), 10, 64)
writeJSON(w, s.aiHistoryOf(s.currentAdmin(r).ID, id, 60))
}
// POST /api/panel/ai/note - {chat, content}: an action's result the user got
// by hand, kept with the chat for the next question.
func (s *Server) handleAINote(w http.ResponseWriter, r *http.Request) {
var in struct {
Chat int64 `json:"chat"`
Content string `json:"content"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in) == nil {
s.aiAppendTo(s.currentAdmin(r).ID, in.Chat, "result", in.Content)
}
writeJSON(w, map[string]any{"ok": true})
}
// 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))
// The models to choose from: ids and names, never a key.
type pick struct {
ID string `json:"id"`
Name string `json:"name"`
Short string `json:"short,omitempty"`
}
var ms []pick
for _, m := range s.aiModels() {
ms = append(ms, pick{m.ID, m.label(), m.Short})
}
_, imgKey := s.imageConf()
writeJSON(w, map[string]any{"on": on != "0", "models": ms, "image": imgKey != ""})
}
// 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 := "0"
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
}
// The models, a card each, in order. The key field shows the key there;
// what it holds is the key (empty: none). A card left empty is dropped;
// a new one gets an id.
f := r.PostForm
ids, names, shorts, types, urls, models, keys := f["m_id"], f["m_name"], f["m_short"], f["m_type"], f["m_url"], f["m_model"], f["m_key"]
at := func(v []string, i int) string {
if i < len(v) {
return v[i]
}
return ""
}
var ms []aiConfig
seen := map[string]bool{}
for i := range types {
m := aiConfig{ID: at(ids, i), Name: at(names, i), Short: at(shorts, i), Type: at(types, i), URL: at(urls, i), Model: at(models, i), Key: at(keys, i)}
if strings.TrimSpace(m.Name+m.Short+m.Type+m.URL+m.Model+m.Key) == "" {
continue
}
if m.ID == "" || seen[m.ID] || len(m.ID) > 32 {
m.ID = newAIModelID()
}
seen[m.ID] = true
ms = append(ms, m.tidy())
}
if len(ms) > 0 {
b, _ := json.Marshal(ms)
_ = s.db.SetConfig(cfgAIModels, string(b))
// A model saved may be another one now: whether it thinks by itself
// is learned again.
for _, m := range ms {
_ = s.db.SetConfig(aiNativeKey(m.ID), "")
}
}
// The search engine for «Поиск».
eng := r.FormValue("ai_search")
if eng != "searxng" && eng != "brave" && eng != "tavily" {
eng = ""
}
_ = s.db.SetConfig(cfgSearchEngine, eng)
_ = s.db.SetConfig(cfgSearchURL, strings.TrimSpace(r.FormValue("ai_search_url")))
_ = s.db.SetConfig(cfgSearchKey, strings.TrimSpace(r.FormValue("ai_search_key")))
// The key of the image service («Генерация изображений»).
_ = s.db.SetConfig(cfgImageKey, strings.TrimSpace(r.FormValue("ai_image_key")))
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(stripThink(m.Content), 3000)
case m.Role == "assistant":
m.Content = stripThink(m.Content)
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
}
// newAIModelID is a short random id for a model saved for the first time.
func newAIModelID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}