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, "")) { thinkFrom = time.Now() } else if !thinkFrom.IsZero() && thinkTo.IsZero() && (strings.Contains(a, "::ответ::") || strings.Contains(a, "")) { 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 ) 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{{"::думаю::", "::ответ::"}, {"", ""}} { 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) }