//go:build cgo // Package desktop — Fyne-реализация окна UI (см. internal/ui). // // Сборка требует cgo (GLFW). На машинах без C-компилятора пакет не входит // в сборку, и app работает в headless-режиме (newUIWindow → nil). package desktop import ( "context" "log" "strings" "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/kamelion/ratatoskr-go/internal/chat" "github.com/kamelion/ratatoskr-go/internal/events" "github.com/kamelion/ratatoskr-go/internal/model" "github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/ui" ) const ( bufLimit = 200_000 // обрезка буферов панелей окна (conv/state), чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" ) // Window — Fyne-окно: chat.Channel + ui.View. type Window struct { fyneApp fyne.App win fyne.Window store ui.Store commands *ui.Commands handler chat.Handler // состояние списка mu sync.Mutex // защищает tasks tasks []storage.Task selected int64 // виджеты list *widget.List titleLbl *widget.Label statusLbl *widget.Label detailLbl *widget.Label convLbl *widget.Label stateLbl *widget.Label logs *LogPanel input *widget.Entry hsplit *container.Split vsplit *container.Split onQuit func() } // New создаёт окно. appID — идентификатор Fyne-приложения (Preferences). func New(appID string, store ui.Store) *Window { fa := app.NewWithID(appID) w := &Window{ fyneApp: fa, store: store, } w.win = fa.NewWindow("Ratatoskr") w.win.SetFullScreen(true) w.commands = ui.NewCommands(w.submitText) w.build() w.win.SetCloseIntercept(func() { // Закрытие окна = сворачивание (спец 12.6): Core продолжает работать. w.saveLayout() w.win.Hide() }) fa.Settings().SetTheme(theme.DarkTheme()) return w } // SetOnQuit задаёт колбэк полного выхода (кнопка «Завершить»). func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn } // build собирает виджеты и раскладку. func (w *Window) build() { w.titleLbl = widget.NewLabel("—") w.titleLbl.TextStyle = fyne.TextStyle{Bold: true} w.statusLbl = widget.NewLabel("") w.statusLbl.TextStyle = fyne.TextStyle{Italic: true} w.detailLbl = widget.NewLabel("") w.detailLbl.Wrapping = fyne.TextWrapWord w.convLbl = widget.NewLabel("Выберите задачу.") w.convLbl.Wrapping = fyne.TextWrapWord w.logs = NewLogPanel() w.stateLbl = widget.NewLabel("") w.stateLbl.Wrapping = fyne.TextWrapWord // Список задач (слева) w.list = widget.NewList( func() int { w.mu.Lock(); defer w.mu.Unlock(); return len(w.tasks) }, func() fyne.CanvasObject { return widget.NewLabel("loading…") }, func(id widget.ListItemID, o fyne.CanvasObject) { w.mu.Lock() defer w.mu.Unlock() if id < 0 || id >= len(w.tasks) { return } t := w.tasks[id] o.(*widget.Label).SetText(taskTitle(t)) }, ) w.list.OnSelected = func(id widget.ListItemID) { w.mu.Lock() if id < 0 || id >= len(w.tasks) { w.mu.Unlock() return } t := w.tasks[id] w.mu.Unlock() w.selectTask(t.ID) } quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() { w.saveLayout() if w.onQuit != nil { w.onQuit() } }) left := container.NewBorder(nil, quitBtn, nil, nil, w.list) // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). details := container.NewVBox(w.titleLbl, w.statusLbl, w.detailLbl) convScroll := container.NewScroll(w.convLbl) right := container.NewVSplit( container.NewScroll(details), convScroll, ) w.vsplit = right // Нижняя панель: вкладки «Логи» + «Состояние». bottomTabs := container.NewAppTabs( container.NewTabItem("Логи", w.logs.Widget()), container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), ) // Ввод + кнопки команд w.input = widget.NewEntry() w.input.SetPlaceHolder("Сообщение… (Enter — отправить)") w.input.OnSubmitted = func(s string) { s = strings.TrimSpace(s) if s == "" { return } w.input.SetText("") w.commands.SendText(s) } cmdBar := container.NewHBox( widget.NewButton("Новая", func() { w.commands.Start() }), widget.NewButton("Создавай", func() { w.commands.Approve() }), widget.NewButton("Пропустить", func() { w.commands.Skip() }), widget.NewButton("Отмена", func() { w.commands.Cancel() }), ) inputRow := container.NewBorder(nil, nil, cmdBar, w.input, nil) bottom := container.NewBorder(nil, inputRow, nil, nil, bottomTabs) w.hsplit = container.NewHSplit(left, right) root := container.NewVSplit(w.hsplit, bottom) w.win.SetContent(root) // Восстановление layout из Preferences. pref := w.fyneApp.Preferences() w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30)) w.vsplit.SetOffset(pref.FloatWithFallback(splitVPref, 0.45)) // Начальный снимок списка. w.refreshList() } // saveLayout сохраняет текущие позиции сплитов в Preferences. // Вызывается при сворачивании окна и перед полным выходом. func (w *Window) saveLayout() { pref := w.fyneApp.Preferences() pref.SetFloat(splitHPref, w.hsplit.Offset) pref.SetFloat(splitVPref, w.vsplit.Offset) } // taskTitle — строка в списке задач. func taskTitle(t storage.Task) string { title := t.Title if title == "" { title = "(без названия)" } return title + "\n" + string(t.Status) } // selectTask загружает детали выбранной задачи (снимки из Store). func (w *Window) selectTask(id int64) { w.selected = id w.fyneApp.Preferences().SetInt(selectedPref, int(id)) ctx := context.Background() t, err := w.store.GetTask(ctx, id) if err != nil { w.titleLbl.SetText("—") w.statusLbl.SetText("") w.detailLbl.SetText("") w.convLbl.SetText("") return } w.titleLbl.SetText(t.Title) w.statusLbl.SetText(string(t.Status)) detail := "Цель: " + t.Goal if len(t.Repos) > 0 { detail += "\nРепозитории: " + strings.Join(t.Repos, ", ") } w.detailLbl.SetText(detail) // Диалог: история + краткие трассы. hist, err := w.store.GetHistory(ctx, id) if err != nil { hist = nil } var b strings.Builder for _, h := range hist { b.WriteString(formatRole(h.Role)) b.WriteString(h.Content) b.WriteString("\n\n") } traces, err := w.store.GetTraces(ctx, id) if err == nil { for _, tr := range traces { b.WriteString("— " + tr.Agent + " (" + string(tr.Status) + ")\n") } } if b.Len() == 0 { b.WriteString("Нет сообщений. /start — начать задачу.") } w.convLbl.SetText(b.String()) // Состояние: сброс к снимку задач. w.refreshState(ctx, id) } func formatRole(role string) string { switch role { case "user": return "👤 " default: return "🤖 " } } // refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы). func (w *Window) refreshState(ctx context.Context, id int64) { traces, err := w.store.GetTraces(ctx, id) if err != nil { return } var b strings.Builder for _, tr := range traces { b.WriteString(strings.ToUpper(tr.Agent) + ": " + string(tr.Status)) if tr.SessionID != "" { b.WriteString(" (session " + tr.SessionID + ")") } b.WriteString("\n") } if b.Len() == 0 { b.WriteString("Нет активных агентов.") } w.stateLbl.SetText(b.String()) } // refreshList перечитывает список задач из Store (снимок). func (w *Window) refreshList() { ctx := context.Background() tasks, err := w.store.ListTasks(ctx) if err != nil { log.Printf("ui: list tasks: %v", err) return } w.mu.Lock() w.tasks = tasks selected := w.selected w.mu.Unlock() if w.list != nil { w.list.Refresh() } _ = selected } // appendConv добавляет строку в диалог (буфер ограничен). func (w *Window) appendConv(text string) { s := w.convLbl.Text + text + "\n\n" if len(s) > bufLimit { s = s[len(s)-bufLimit:] } w.convLbl.SetText(s) } // appendLog добавляет строку в панель «Логи» (делегирует в LogPanel). func (w *Window) appendLog(text string) { w.logs.Append(text) } // appendState добавляет строку в панель «Состояние». func (w *Window) appendState(text string) { s := w.stateLbl.Text + text + "\n" if len(s) > bufLimit { s = s[len(s)-bufLimit:] } w.stateLbl.SetText(s) } // submitText отправляет ввод пользователя в Router. func (w *Window) submitText(text string) { if w.handler != nil { w.handler(chat.Incoming{ UserID: ui.UID, Address: ui.Address, Channel: w, Msg: chat.Message{Text: text}, }) } } // ---- chat.Channel ---- // OnMessage регистрирует обработчик входящих (Router). func (w *Window) OnMessage(h chat.Handler) { w.handler = h } // Run показывает окно и запускает Fyne-цикл. Блокирует до Quit. func (w *Window) Run(ctx context.Context) error { w.win.Show() go func() { <-ctx.Done() w.fyneApp.Quit() }() w.fyneApp.Run() return nil } // Send доставляет сообщение Core в диалог окна. func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error { text := m.Text if len(m.Options) > 0 { var sb strings.Builder sb.WriteString(text) for i, opt := range m.Options { sb.WriteString("\n") sb.WriteString(opt.Label) _ = i } text = sb.String() } fyne.Do(func() { w.appendConv("🤖 " + text) }) return nil } // Ask — вопрос с вариантами (отображается в диалоге). func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error { text := m.Text for _, opt := range m.Options { text += "\n" + opt.Label } fyne.Do(func() { w.appendConv("❓ " + text) }) return nil } // Close завершает Fyne-цикл. func (w *Window) Close() error { w.fyneApp.Quit() return nil } // ---- ui.View ---- // Все колбэки выполняются в горутинах events.Hub → обновляем UI через fyne.Do. func (w *Window) OnTaskCreated(e events.TaskCreated) { fyne.Do(func() { w.refreshList() if w.selected == 0 { w.selectTask(e.ID) } }) } func (w *Window) OnTaskUpdated(e events.TaskUpdated) { fyne.Do(func() { w.refreshList() if w.selected == e.ID { w.selectTask(e.ID) } }) } func (w *Window) OnTaskDeleted(e events.TaskDeleted) { fyne.Do(func() { if w.selected == e.ID { w.selected = 0 } w.refreshList() }) } func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) { fyne.Do(func() { w.refreshList() if w.selected == e.ID { w.selectTask(e.ID) } w.appendState(taskStatusLine(e)) }) } func (w *Window) OnHistoryAppended(e events.HistoryAppended) { fyne.Do(func() { if w.selected == e.TaskID { w.appendConv(formatRole(e.Role) + e.Content) } }) } func (w *Window) OnTraceAppended(e events.TraceAppended) { fyne.Do(func() { w.appendState(strings.ToUpper(e.Agent) + ": " + string(e.Status)) }) } func (w *Window) OnAgentActivity(e events.AgentActivity) { fyne.Do(func() { w.appendState(strings.ToUpper(e.Agent) + " → " + e.Stage) }) } func (w *Window) OnLog(e events.LogLine) { w.appendLog(e.Text) } func taskStatusLine(e events.TaskStatusChanged) string { return statusBadge(e.To) + " #" + itoa(e.ID) + ": " + string(e.From) + " → " + string(e.To) } func statusBadge(s model.Status) string { switch s { case model.StatusSuccess: return "✅" case model.StatusFailed, model.StatusAborted: return "❌" case model.StatusRunning, model.StatusCollecting: return "⏳" case model.StatusReady, model.StatusApproved: return "🟡" case model.StatusCancelled: return "🚫" default: return "•" } } func itoa(v int64) string { if v == 0 { return "0" } neg := v < 0 if neg { v = -v } var b [24]byte i := len(b) for v > 0 { i-- b[i] = byte('0' + v%10) v /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) }