feat(ui): команды, snapshots, Fyne-окно и интеграция (--noui)
Some checks failed
CI / test (pull_request) Failing after 1m19s
CI / build-and-package (amd64, linux) (pull_request) Failing after 58s
CI / build-and-package (amd64, windows) (pull_request) Successful in 53s

Команды (спец 12.2):
- ui.Commands: действия UI → текстовые команды канала (start/cancel/skip/
  retry/continue/approve/send), единый путь через chat.Channel.
- ui.Window = chat.Channel + View; NilWindow для headless/тестов.

Snapshots (спец 12.5):
- ui.Store: чтение-модель, возвращает только копии (ListTasks/GetTask/
  GetHistory/GetTraces); ui.DBStore поверх storage.

Fyne-окно (internal/ui/desktop, build-tag cgo):
- список задач слева, сплиты рабочей области и панели «Логи»/«Состояние»,
  ввод+кнопки команд, тёмная тема, fullscreen, сохранение layout в
  Preferences, сворачивание при закрытии крестиком.
- Колбэки View через fyne.Do (спец 12.4).

Интеграция:
- app.New(..., noUI); флаг --noui; UI собирается только с cgo (ui_cgo/
  ui_noui фабрики), приложение headless без него.
- Run: окно блокирует главную горутину; «Завершить» → cancel → graceful
  shutdown (спец 12.6).
- go.mod: fyne.io/fyne/v2 v2.6.0 (direct).
This commit is contained in:
ki.sagidullin
2026-08-20 08:54:55 +05:00
parent baf2ad7147
commit c2272137b3
17 changed files with 1056 additions and 18 deletions

View File

@@ -0,0 +1,497 @@
//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 (
maxLogLen = 200_000 // обрезка буферов панелей, чтобы не расти бесконечно
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
logsLbl *widget.Label
stateLbl *widget.Label
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.logsLbl = widget.NewLabel("")
w.logsLbl.Wrapping = fyne.TextWrapWord
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("Логи", container.NewScroll(w.logsLbl)),
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) > maxLogLen {
s = s[len(s)-maxLogLen:]
}
w.convLbl.SetText(s)
}
// appendLog добавляет строку в панель «Логи».
func (w *Window) appendLog(text string) {
s := w.logsLbl.Text + text + "\n"
if len(s) > maxLogLen {
s = s[len(s)-maxLogLen:]
}
w.logsLbl.SetText(s)
}
// appendState добавляет строку в панель «Состояние».
func (w *Window) appendState(text string) {
s := w.stateLbl.Text + text + "\n"
if len(s) > maxLogLen {
s = s[len(s)-maxLogLen:]
}
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) {
fyne.Do(func() { 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:])
}