Спеца 12.8-12.9: интерфейс + Nil в internal/ui/log_panel.go, Fyne-реализация в internal/ui/desktop/log_panel.go, окно использует контракт вместо logsLbl.
493 lines
12 KiB
Go
493 lines
12 KiB
Go
//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:])
|
||
}
|