Add multimodal AI chat attachments

This commit is contained in:
redtea committed 2026-10-16 15:46:23 +03:00
1 parent 8aba30a093
commit 84b42c5369
6 files changed
+434 -39

No files matched your search

BIN
View File
Binary file not shown.
+198 -16
View File
@@ -5,13 +5,16 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"path"
"strconv"
"strings"
"time"
@@ -39,9 +42,17 @@ func aiUserKey(id int64) string { return "ai_user_" + strconv.FormatInt(id, 10)
type aiConfig struct{ Type, URL, Key, Model string }
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"`
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"`
}
@@ -113,11 +124,41 @@ func aiPost(ctx context.Context, url string, body any, hdr map[string]string) (*
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
}
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", map[string]any{"model": cfg.Model, "stream": true, "messages": all}, hdr)
resp, err := aiPost(ctx, cfg.URL+"/chat/completions", map[string]any{"model": cfg.Model, "stream": true, "messages": apiMessages}, hdr)
if err != nil {
return err
}
@@ -156,7 +197,22 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
if model == "" {
model = "claude-sonnet-5-5"
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": msgs, "stream": true},
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})
}
resp, err := aiPost(ctx, cfg.URL+"/v1/messages", map[string]any{"model": model, "max_tokens": 4096, "system": system, "messages": apiMessages, "stream": true},
map[string]string{"x-api-key": cfg.Key, "anthropic-version": "2023-06-01"})
if err != nil {
return err
@@ -187,6 +243,47 @@ func aiAnthropic(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg,
})
}
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
@@ -241,11 +338,33 @@ func aiGradio(ctx context.Context, cfg aiConfig, system string, msgs []aiMsg, em
return errors.New("модель: нет функции chat_predict")
}
last := msgs[len(msgs)-1]
media := []any{nil, nil, nil}
for _, a := range last.Attachments {
if a.Data == "" {
continue
}
slot := -1
switch {
case strings.HasPrefix(a.MIME, "image/"):
slot = 0
case strings.HasPrefix(a.MIME, "audio/"):
slot = 1
case strings.HasPrefix(a.MIME, "video/"):
slot = 2
}
if slot >= 0 {
fileData, err := aiGradioUpload(ctx, cfg, hdr, a)
if err != nil {
return err
}
media[slot] = fileData
}
}
b := make([]byte, 8)
_, _ = rand.Read(b)
session := hex.EncodeToString(b)
jr, err := aiPost(ctx, cfg.URL+"/gradio_api/queue/join", map[string]any{
"data": []any{last.Content, nil, nil, nil, msgs[:len(msgs)-1], system, 0.7, 0.8, 20},
"data": []any{last.Content, media[0], media[1], media[2], msgs[:len(msgs)-1], system, 0.7, 0.8, 20},
"fn_index": fn, "trigger_id": trigger, "event_data": nil, "session_hash": session,
}, hdr)
if err != nil {
@@ -520,32 +639,95 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) {
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"`
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"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil || (in.Message == "" && in.Result == "") {
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.aiConfig()
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 := in.Message != "" && len(s.aiHistoryOf(a.ID, in.Chat, 1)) == 0
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", in.Message)
s.aiAppendTo(a.ID, in.Chat, "user", stored)
if fresh {
// A new chat gets a short name from the model, aside.
go s.aiNameChat(a.ID, in.Chat, in.Message)
go s.aiNameChat(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:])
}
+69
View File
@@ -99,6 +99,38 @@ func TestAIOpenAI(t *testing.T) {
}
}
func TestAIOpenAIAttachments(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
msg := aiMsg{Role: "user", Content: "посмотри", Attachments: []aiAttachment{
{Name: "image.png", MIME: "image/png", Data: "AQID"},
{Name: "voice.wav", MIME: "audio/wav", Data: "BAUG"},
{Name: "clip.mp4", MIME: "video/mp4", Data: "BwgJ"},
}}
if err := aiOpenAI(context.Background(), aiConfig{Type: "openai", URL: srv.URL, Model: "m"}, "sys", []aiMsg{msg}, func(string) {}); err != nil {
t.Fatal(err)
}
messages := body["messages"].([]any)
content := messages[1].(map[string]any)["content"].([]any)
if len(content) != 4 || content[3].(map[string]any)["text"] != "посмотри" {
t.Fatalf("content: %#v", content)
}
if got := content[0].(map[string]any)["image_url"].(map[string]any)["url"].(string); !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("image: %q", got)
}
if got := content[1].(map[string]any)["input_audio"].(map[string]any)["format"]; got != "wav" {
t.Fatalf("audio: %#v", content[1])
}
video := content[2].(map[string]any)
if video["type"] != "video_url" || video["fps"] != float64(1) || !strings.HasPrefix(video["video_url"].(map[string]any)["url"].(string), "data:video/mp4;base64,") {
t.Fatalf("video: %#v", video)
}
}
func TestAIAnthropic(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -159,6 +191,43 @@ func TestAIChatNeedsUserOn(t *testing.T) {
}
}
func TestAIChatKeepsAttachmentTextOnly(t *testing.T) {
s, c := newTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in struct {
Messages []map[string]any `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
content := in.Messages[len(in.Messages)-1]["content"].([]any)
if content[0].(map[string]any)["type"] != "image_url" {
http.Error(w, "image missing", http.StatusBadRequest)
return
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, srv.URL)
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
chatID := s.aiNewChat(a.ID)
s.aiAppendTo(a.ID, chatID, "user", "предыдущий вопрос")
s.aiAppendTo(a.ID, chatID, "assistant", "ответ")
body := fmt.Sprintf(`{"message":"посмотри","chat":%d,"attachments":[{"name":"x.png","mime":"image/png","data":"AQID"}]}`, chatID)
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("response: %d %s", w.Code, w.Body.String())
}
chat, _ := strconv.ParseInt(w.Header().Get("X-Chat-Id"), 10, 64)
history := s.aiHistoryOf(a.ID, chat, 10)
if chat != chatID || len(history) != 3 || !strings.Contains(history[2].Content, "x.png") || strings.Contains(history[2].Content, "AQID") {
t.Fatalf("history contains wrong attachment data: %+v", history)
}
}
func TestVersionAtLeast(t *testing.T) {
for _, c := range []struct {
v string
+137 -15
View File
@@ -24,7 +24,8 @@
'<span class="muted ai-where"></span><span class="spacer"></span>' +
'<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-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></div>' +
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
'<div class="ai-chat-body"><div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить», умеет читать и писать файлы на любой вашей машине. В режиме «Агент» он делает шаги сам, спрашивая перед каждой командой.</p></div>' +
'<button type="button" class="ai-jump" hidden title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button></div>' +
'<div class="ai-pills">' +
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
@@ -33,11 +34,23 @@
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
'</div>' +
'<form class="ai-form-in"><textarea rows="1" placeholder="Сообщение…"></textarea>' +
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></form>';
'<div class="ai-file-picker" hidden></div>' +
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
'<button type="button" class="ai-attach" title="Прикрепить файл" aria-label="Прикрепить файл"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-8.9 8.9a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg></button>' +
'<button type="button" class="ai-from-pane" title="Выбрать файл из файлового окна" aria-label="Выбрать файл из файлового окна"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v1M3 7v11a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2z"/></svg></button>' +
'<textarea rows="1" placeholder="Сообщение…"></textarea>' +
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></div></div></form>';
document.body.append(fab, box);
const q = s => box.querySelector(s);
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send');
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'), jump = q('.ai-jump');
const attachInput = q('.ai-file-input'), attachList = q('.ai-attachments'), filePicker = q('.ai-file-picker');
let attachments = [];
const nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80;
function updateJump() { jump.hidden = nearBottom() || !isOpen(); }
list.addEventListener('scroll', updateJump, {passive: true});
jump.onclick = () => { list.scrollTo({top: list.scrollHeight, behavior: 'smooth'}); };
function scrollIfFollowing(follow) { if (follow) list.scrollTop = list.scrollHeight; updateJump(); }
// The talk lives on the server (the database); here only what is shown.
let loaded = false;
let ac = null;
@@ -51,6 +64,7 @@
placeBox();
box.classList.add('on');
fab.classList.add('away');
requestAnimationFrame(updateJump);
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
where();
if (!phone()) input.focus();
@@ -202,8 +216,11 @@
setChat(id);
drawChats();
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
list.scrollTop = 0;
jump.hidden = true;
filePicker.hidden = true;
q('.ai-hello').hidden = !!id;
if (!id) return;
if (!id) { updateJump(); return; }
try {
const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
if (chatId !== id) return;
@@ -212,6 +229,8 @@
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
});
if (!h.length) q('.ai-hello').hidden = false;
list.scrollTop = list.scrollHeight;
updateJump();
} catch (e) { /* stays empty */ }
}
// The chat last open, when the window first opens.
@@ -427,13 +446,74 @@
body.innerHTML = html;
el.dataset.text = text;
}
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
const MAX_FILE = 8 * 1024 * 1024, MAX_ATTACHMENTS = 6, MAX_TOTAL = 16 * 1024 * 1024;
function bytesToBase64(buf) {
const bytes = new Uint8Array(buf); let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
function previewText(html) {
return new DOMParser().parseFromString(html, 'text/html').body.textContent.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function renderAttachments() {
attachList.innerHTML = '';
attachList.hidden = !attachments.length;
attachments.forEach((a, i) => {
const chip = document.createElement('span'); chip.className = 'ai-file-chip';
const name = document.createElement('span'); name.textContent = a.name; name.title = a.name;
const remove = document.createElement('button'); remove.type = 'button'; remove.title = 'Убрать вложение'; remove.setAttribute('aria-label', 'Убрать ' + a.name); remove.textContent = '×';
remove.onclick = () => { attachments.splice(i, 1); renderAttachments(); };
chip.append(name, remove); attachList.appendChild(chip);
});
}
function setFileError(message) {
filePicker.hidden = false; filePicker.replaceChildren();
const text = document.createElement('span'); text.textContent = message; filePicker.appendChild(text);
setTimeout(() => { if (filePicker.firstChild === text) filePicker.hidden = true; }, 5000);
}
async function makeAttachments(file, name = file.name) {
const ext = fileExt(name), type = (file.type || '').toLowerCase();
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
if (officeExt.test(ext) || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
const buf = await file.arrayBuffer(); let text;
if (ext === 'docx') text = previewText(await DocView.docx(buf));
else if (ext === 'xlsx') text = previewText(await DocView.xlsx(buf));
else if (ext === 'ods') text = previewText(await DocView.ods(buf));
else if (ext === 'odt') text = previewText(await DocView.odt(buf));
else if (ext === 'doc') text = previewText(DocView.doc(buf));
else if (ext === 'csv' || ext === 'tsv') text = previewText(DocView.csv(new TextDecoder().decode(buf), ext === 'tsv'));
else text = new TextDecoder().decode(buf);
if (!text) throw new Error(name + ': текст не найден');
return [{name, mime: 'text/plain', text: text.slice(0, 120000), size: file.size}];
}
const fallback = {png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',gif:'image/gif',webp:'image/webp',bmp:'image/bmp',tif:'image/tiff',tiff:'image/tiff',mp3:'audio/mpeg',wav:'audio/wav',m4a:'audio/mp4',aac:'audio/aac',ogg:'audio/ogg',oga:'audio/ogg',flac:'audio/flac',opus:'audio/ogg',mp4:'video/mp4',m4v:'video/mp4',webm:'video/webm',mov:'video/quicktime',ogv:'video/ogg',mkv:'video/x-matroska',avi:'video/x-msvideo','3gp':'video/3gpp'}[ext] || '';
const mime = !type || type === 'application/octet-stream' ? fallback : type;
if (!/^image\//.test(mime) && !/^audio\//.test(mime) && !/^video\//.test(mime)) throw new Error(name + ': поддерживаются изображения, аудио, видео, текст и документы Word/Excel');
return [{name, mime, data: bytesToBase64(await file.arrayBuffer()), size: file.size}];
}
async function addFiles(files) {
for (const file of files) {
if (attachments.length >= MAX_ATTACHMENTS) { setFileError('Можно прикрепить не больше ' + MAX_ATTACHMENTS + ' файлов или кадров.'); break; }
try {
const ready = await makeAttachments(file);
const total = attachments.reduce((n, a) => n + (a.size || 0), 0) + ready.reduce((n, a) => n + (a.size || 0), 0);
if (attachments.length + ready.length > MAX_ATTACHMENTS) throw new Error('Слишком много вложений (максимум ' + MAX_ATTACHMENTS + ').');
if (total > MAX_TOTAL) throw new Error('Общий размер вложений не должен превышать 16 МиБ.');
attachments.push(...ready); renderAttachments();
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл'); }
}
}
function add(role, text, thinkMs) {
q('.ai-hello').hidden = true;
const el = document.createElement('div');
el.className = 'ai-msg ' + role;
if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); } else el.textContent = text;
const follow = nearBottom();
list.appendChild(el);
list.scrollTop = list.scrollHeight;
scrollIfFollowing(follow);
return el;
}
// An action's result shows folded; it reaches the talk on the server with
@@ -465,8 +545,11 @@
return detail || 'Ошибка сервиса ИИ (HTTP ' + status + ').';
}
// ask sends the new words (or an action's result) and shows the answer.
async function ask(text, res) {
if (text) add('user', text);
async function ask(text, res, files) {
if (text || (files && files.length)) {
const labels = files && files.length ? '\n\n📎 ' + files.map(f => f.name).join(', ') : '';
add('user', (text || '') + labels);
}
if (res) showResult(res);
const el = add('assistant', '');
el.classList.add('wait');
@@ -479,7 +562,7 @@
await loadMachines();
const r = await fetch('/api/panel/ai/chat', {
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId})
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || []})
});
// A new chat gets its id (and its name) from the answer.
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
@@ -499,14 +582,17 @@
if (done) break;
answer += dec.decode(value, {stream: true});
el.classList.remove('wait');
const follow = nearBottom();
render(el, answer, false);
list.scrollTop = list.scrollHeight;
scrollIfFollowing(follow);
}
} catch (e) {
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
} finally {
el.classList.remove('wait');
const follow = nearBottom();
render(el, answer || '[нет ответа]', true);
scrollIfFollowing(follow);
}
return {el, answer: stripThink(answer)};
}
@@ -526,8 +612,8 @@
// talkTurn: one question, and in agent mode the steps after it - the
// first action of each answer done (asking first for commands and writes)
// and its result sent back - until an answer asks for nothing.
async function talkTurn(text) {
let turn = await ask(text);
async function talkTurn(text, files) {
let turn = await ask(text, null, files);
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break;
@@ -548,16 +634,52 @@
ac = null;
send.innerHTML = PLANE; send.title = 'Отправить';
}
q('.ai-form-in').onsubmit = ev => {
const form = q('.ai-form-in'), attachBtn = q('.ai-attach'), paneBtn = q('.ai-from-pane');
attachBtn.onclick = () => attachInput.click();
attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; };
paneBtn.onclick = () => showPaneFiles();
form.onsubmit = ev => {
ev.preventDefault();
if (ac) { ac.abort(); return; }
const text = input.value.trim();
if (!text) return;
if (!text && !attachments.length) return;
const files = attachments.slice(); attachments = []; renderAttachments();
input.value = '';
grow();
where();
talkTurn(text);
filePicker.hidden = true;
talkTurn(text, files);
};
box.addEventListener('dragover', ev => { if (ev.dataTransfer && [...ev.dataTransfer.types].includes('Files')) { ev.preventDefault(); form.classList.add('drag'); } });
box.addEventListener('dragleave', ev => { if (!box.contains(ev.relatedTarget)) form.classList.remove('drag'); });
box.addEventListener('drop', ev => {
if (![...ev.dataTransfer.types].includes('Files')) return;
ev.preventDefault(); form.classList.remove('drag'); addFiles([...ev.dataTransfer.files]);
});
async function showPaneFiles() {
filePicker.replaceChildren();
const pane = window.Workspace && Workspace.activePane();
if (!pane || !/files$/.test(pane.kind)) { setFileError('Сначала откройте файловое окно подключённой машины.'); return; }
const files = (pane.entries || []).filter(e => !e.is_dir);
if (!files.length) { setFileError('В текущей папке нет файлов.'); return; }
const title = document.createElement('strong'); title.textContent = 'Файлы · ' + pane.cwd; filePicker.appendChild(title);
files.forEach(entry => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'ai-file-option'; b.textContent = entry.name;
b.onclick = async () => {
b.disabled = true; b.textContent += ' · загрузка…';
try {
const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + entry.name;
const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'});
if (!response.ok) throw new Error('Не удалось скачать файл (' + response.status + ')');
const blob = await response.blob();
await addFiles([new File([blob], entry.name, {type: blob.type})]);
filePicker.hidden = true;
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл машины'); }
};
filePicker.appendChild(b);
});
filePicker.hidden = false;
}
// The field grows with the text, up to a few lines.
function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; }
input.addEventListener('input', grow);
+26 -6
View File
@@ -857,7 +857,11 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-head .ai-chatsel { min-width:0; }
.ai-head .ai-chatname { min-width:3ch; }
.ai-head .ai-chatsel { flex:0 1 auto; }
.ai-list { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
.ai-chat-body { position:relative; flex:1; min-height:0; }
.ai-list { height:100%; box-sizing:border-box; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
.ai-jump { position:absolute; right:14px; bottom:14px; z-index:2; width:38px; height:38px; padding:0; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:var(--text); background:var(--panel); border:1px solid var(--border); box-shadow:0 4px 14px rgba(0,0,0,.35); }
.ai-jump:hover { background:#202833; }
.ai-jump[hidden] { display:none; }
.ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; }
.ai-msg.user { align-self:flex-end; background:#16324f; white-space:pre-wrap; }
.ai-msg.assistant { align-self:flex-start; background:#151a21; border:1px solid var(--border); }
@@ -869,10 +873,26 @@ body.touch.term-active .ai-fab { bottom:104px; }
.ai-code { margin:6px 0; }
.ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; }
.ai-code .ai-run { margin-top:6px; }
.ai-form-in { display:flex; align-items:flex-end; gap:8px; padding:8px 10px 10px; }
.ai-form-in textarea { flex:1; resize:none; font:inherit; font-size:14px; line-height:20px; padding:9px 10px; height:40px; max-height:140px; overflow-y:auto; color:var(--text);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; }
.ai-form-in textarea:focus { outline:none; border-color:var(--accent); }
.ai-form-in { display:flex; padding:8px 10px 10px; }
.ai-inputbox { min-width:0; flex:1; padding:5px; background:#0c0f13; border:1px solid var(--border); border-radius:13px; transition:border-color .15s, box-shadow .15s; }
.ai-inputbox:focus-within { border-color:var(--accent); box-shadow:0 0 0 2px rgba(91,140,255,.12); }
.ai-entry { display:flex; align-items:flex-end; gap:5px; min-width:0; }
.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:14px; line-height:20px; padding:9px 6px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; }
.ai-form-in textarea:focus { outline:none; border-color:transparent; }
.ai-attach, .ai-from-pane { flex:none; width:36px; height:36px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; }
.ai-attach:hover, .ai-from-pane:hover { color:var(--text); background:#1b222c; }
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
.ai-attachments[hidden] { display:none; }
.ai-file-chip { display:inline-flex; align-items:center; gap:4px; max-width:100%; padding:3px 7px 3px 9px; border:1px solid var(--border); border-radius:999px; background:#151a21; color:var(--muted); font-size:11.5px; }
.ai-file-chip span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-file-chip button { flex:none; width:18px; height:18px; padding:0; border:0; border-radius:50%; background:transparent; color:var(--muted); line-height:1; }
.ai-file-chip button:hover { color:var(--text); background:#303846; }
.ai-file-picker { position:absolute; right:10px; bottom:78px; z-index:5; display:flex; flex-direction:column; gap:3px; max-width:min(320px, calc(100% - 20px)); max-height:45%; overflow:auto; padding:9px; background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.45); }
.ai-file-picker[hidden] { display:none; }
.ai-file-picker strong { padding:3px 6px 6px; color:var(--muted); font-size:12px; }
.ai-file-option { width:100%; overflow:hidden; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:transparent; border-color:transparent; color:var(--text); font-size:12px; }
.ai-file-option:hover { background:#202833; }
.ai-form-in.drag .ai-inputbox { border-color:var(--accent); box-shadow:0 0 0 3px rgba(91,140,255,.18); }
@media (max-width: 640px) {
/* A phone: the chat is a screen of its own, sliding up. */
.ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); }
@@ -908,7 +928,7 @@ body:not(.touch) .ai-head { cursor:grab; user-select:none; }
body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; }
body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
/* Send: the field's height, square. */
.ai-form-in textarea, .ai-send { box-sizing:border-box; }
.ai-form-in textarea, .ai-send, .ai-attach, .ai-from-pane { box-sizing:border-box; }
.ai-send { width:40px; height:40px; align-self:flex-end; }
@media (max-width: 640px) {
.ai-fab { width:46px; height:46px; right:22px; bottom:24px; }
+4 -2
View File
@@ -84,7 +84,7 @@
}
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
}
window.addEventListener('resize', () => fitTabs());
window.addEventListener('resize', () => { fitTabs(); placeFloat(); });
function renderTab(t) {
t.el.querySelector('.tab-title').textContent = tabTitle(t);
@@ -148,7 +148,9 @@
}
// With one tab and a file pane in the top right corner, the "+" joins
// that pane's top bar: right of "Обновить", above "Удалить выбранное",
// a button of the same size. Otherwise it is back in the corner.
// a button of the same size. It is positioned in .area, not the scrolling
// .fpane-body, so it stays beside the path field while the file list scrolls.
// Otherwise it is back in the corner.
function placeFloat() {
const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null;
const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : [];