feat(ui): команды, snapshots, Fyne-окно и интеграция (--noui)
Команды (спец 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:
46
internal/ui/commands.go
Normal file
46
internal/ui/commands.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package ui
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Commands — отображение действий UI в текстовые команды канала.
|
||||
//
|
||||
// UI — это chat.Channel, поэтому все действия пользователя (кнопки, ввод)
|
||||
// превращаются в текстовые сообщения, которые уходят в Router → Core. Это
|
||||
// сохраняет единый путь команд (спец 12.2): никаких прямых вызовов Core из
|
||||
// виджетов — только текст через канал.
|
||||
type Commands struct {
|
||||
// Submit отправляет текст пользователя в канал (обычно — window.Submit).
|
||||
Submit func(text string)
|
||||
}
|
||||
|
||||
// NewCommands создаёт Commands с отправкой через fn.
|
||||
func NewCommands(fn func(text string)) *Commands {
|
||||
if fn == nil {
|
||||
fn = func(string) {}
|
||||
}
|
||||
return &Commands{Submit: fn}
|
||||
}
|
||||
|
||||
// Start — новая задача (/start).
|
||||
func (c *Commands) Start() { c.Submit("/start") }
|
||||
|
||||
// Cancel — отмена текущей задачи (/cancel).
|
||||
func (c *Commands) Cancel() { c.Submit("/cancel") }
|
||||
|
||||
// Skip — пропустить сбор, сформировать черновик (/skip).
|
||||
func (c *Commands) Skip() { c.Submit("/skip") }
|
||||
|
||||
// Retry — перезапустить задачу N (/retry N).
|
||||
func (c *Commands) Retry(id int64) { c.Submit(fmt.Sprintf("/retry %d", id)) }
|
||||
|
||||
// Status — запросить статус задачи N (/status N).
|
||||
func (c *Commands) Status(id int64) { c.Submit(fmt.Sprintf("/status %d", id)) }
|
||||
|
||||
// Continue — продолжить задачу N (/continue N).
|
||||
func (c *Commands) Continue(id int64) { c.Submit(fmt.Sprintf("/continue %d", id)) }
|
||||
|
||||
// Approve — одобрить черновик («создавай»).
|
||||
func (c *Commands) Approve() { c.Submit("создавай") }
|
||||
|
||||
// SendText — обычное сообщение пользователя (ввод в поле).
|
||||
func (c *Commands) SendText(text string) { c.Submit(text) }
|
||||
@@ -49,4 +49,4 @@ func (c *Controller) Start() {
|
||||
func (c *Controller) Close() {
|
||||
c.domain.Close()
|
||||
c.logs.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,4 @@ func TestControllerIdempotentClose(t *testing.T) {
|
||||
c.Start()
|
||||
c.Close()
|
||||
c.Close() // не должно падать
|
||||
}
|
||||
}
|
||||
|
||||
497
internal/ui/desktop/window.go
Normal file
497
internal/ui/desktop/window.go
Normal 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:])
|
||||
}
|
||||
73
internal/ui/store.go
Normal file
73
internal/ui/store.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
)
|
||||
|
||||
// Store — абстракция чтения-модели для UI (спец 12.5).
|
||||
//
|
||||
// Возвращает только копии (value-типы), никогда — живые указатели на
|
||||
// разделяемые контейнеры core/БД. UI читает снимки и реагирует на события;
|
||||
// мутирует состояние только Core.
|
||||
type Store interface {
|
||||
// ListTasks возвращает все задачи (копии), сортировка по updated_at desc.
|
||||
ListTasks(ctx context.Context) ([]storage.Task, error)
|
||||
// GetTask возвращает задачу по ID (копию).
|
||||
GetTask(ctx context.Context, id int64) (storage.Task, error)
|
||||
// GetHistory возвращает историю диалога задачи (копии).
|
||||
GetHistory(ctx context.Context, id int64) ([]storage.HistoryMsg, error)
|
||||
// GetTraces возвращает трассы задачи (копии).
|
||||
GetTraces(ctx context.Context, id int64) ([]storage.Trace, error)
|
||||
}
|
||||
|
||||
// DBStore — реализация Store поверх *storage.Storage (БД).
|
||||
type DBStore struct {
|
||||
s *storage.Storage
|
||||
}
|
||||
|
||||
// NewDBStore создаёт DBStore на основе БД.
|
||||
func NewDBStore(s *storage.Storage) *DBStore {
|
||||
return &DBStore{s: s}
|
||||
}
|
||||
|
||||
// ListTasks реализует Store: deref-копии записей, без указателей.
|
||||
func (d *DBStore) ListTasks(ctx context.Context) ([]storage.Task, error) {
|
||||
rows, err := d.s.ListTasks(ctx, storage.TaskFilter{Limit: 200})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]storage.Task, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTask реализует Store.
|
||||
func (d *DBStore) GetTask(ctx context.Context, id int64) (storage.Task, error) {
|
||||
t, err := d.s.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return storage.Task{}, err
|
||||
}
|
||||
return *t, nil
|
||||
}
|
||||
|
||||
// GetHistory реализует Store.
|
||||
func (d *DBStore) GetHistory(ctx context.Context, id int64) ([]storage.HistoryMsg, error) {
|
||||
return d.s.GetHistory(ctx, id)
|
||||
}
|
||||
|
||||
// GetTraces реализует Store.
|
||||
func (d *DBStore) GetTraces(ctx context.Context, id int64) ([]storage.Trace, error) {
|
||||
rows, err := d.s.GetTraces(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]storage.Trace, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
107
internal/ui/store_test.go
Normal file
107
internal/ui/store_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *storage.Storage {
|
||||
t.Helper()
|
||||
s, err := storage.Open(context.Background(), ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open storage: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestDBStoreListTasksReturnsCopies(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id1, err := s.CreateTask(ctx, &storage.Task{ChatID: "ui://local", Title: "A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := s.CreateTask(ctx, &storage.Task{ChatID: "tg://1", Title: "B"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
st := NewDBStore(s)
|
||||
tasks, err := st.ListTasks(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("got %d tasks, want 2", len(tasks))
|
||||
}
|
||||
|
||||
// мутация полученной копии не влияет на БД
|
||||
tasks[0].Title = "mutated"
|
||||
fetched, err := s.GetTask(ctx, id1)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if fetched.Title == "mutated" {
|
||||
t.Fatal("mutating snapshot leaked into DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBStoreGetTaskAndHistory(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.CreateTask(ctx, &storage.Task{ChatID: "ui://local", Title: "A"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := s.AppendHistory(ctx, id, "user", "hello"); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
st := NewDBStore(s)
|
||||
task, err := st.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
if task.ID != id {
|
||||
t.Fatalf("task.ID = %d, want %d", task.ID, id)
|
||||
}
|
||||
|
||||
hist, err := st.GetHistory(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(hist) != 1 || hist[0].Content != "hello" {
|
||||
t.Fatalf("bad history: %+v", hist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBStoreGetTraces(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := s.CreateTask(ctx, &storage.Task{ChatID: "ui://local"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
tid, err := s.AppendTrace(ctx, &storage.Trace{TaskID: id, Agent: "dev", Prompt: "p"})
|
||||
if err != nil {
|
||||
t.Fatalf("append trace: %v", err)
|
||||
}
|
||||
_ = s.UpdateTraceStatus(ctx, tid, storage.TraceSuccess)
|
||||
|
||||
st := NewDBStore(s)
|
||||
traces, err := st.GetTraces(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTraces: %v", err)
|
||||
}
|
||||
if len(traces) != 1 {
|
||||
t.Fatalf("got %d traces, want 1", len(traces))
|
||||
}
|
||||
if traces[0].Status != storage.TraceSuccess {
|
||||
t.Fatalf("trace status = %s, want success", traces[0].Status)
|
||||
}
|
||||
}
|
||||
@@ -37,4 +37,4 @@ func (NilView) OnTaskStatusChanged(events.TaskStatusChanged) {}
|
||||
func (NilView) OnHistoryAppended(events.HistoryAppended) {}
|
||||
func (NilView) OnTraceAppended(events.TraceAppended) {}
|
||||
func (NilView) OnAgentActivity(events.AgentActivity) {}
|
||||
func (NilView) OnLog(events.LogLine) {}
|
||||
func (NilView) OnLog(events.LogLine) {}
|
||||
|
||||
68
internal/ui/window.go
Normal file
68
internal/ui/window.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||
"github.com/kamelion/ratatoskr-go/internal/events"
|
||||
)
|
||||
|
||||
// UID — фиксированный пользовательский идентификатор UI-канала (один пользователь).
|
||||
const UID = chat.UserID("ui://local")
|
||||
|
||||
// Address — адрес доставки для UI-канала.
|
||||
const Address = chat.Address("ui://local")
|
||||
|
||||
// Window — окно десктопного UI.
|
||||
//
|
||||
// Объединяет две роли:
|
||||
// - chat.Channel — ещё одна реализация канала (спец 12.1): входящие от
|
||||
// пользователя (ввод) уходят в Router → Core; исходящие (Send/Ask) — из
|
||||
// Core/Router попадают в окно (диалог).
|
||||
// - View — событийный мост из Controller (статусы, история, трейсы, логи).
|
||||
//
|
||||
// Реализация (Fyne) живёт в internal/ui/desktop под build-tag `cgo`.
|
||||
type Window interface {
|
||||
chat.Channel
|
||||
View
|
||||
// SetOnQuit задаёт колбэк полного выхода (кнопка «Завершить»).
|
||||
SetOnQuit(func())
|
||||
}
|
||||
|
||||
// NilWindow — no-op окно для headless-режима (--noui) и тестов.
|
||||
// Реализует Window: канал без доставки и View без действий.
|
||||
type NilWindow struct {
|
||||
handler chat.Handler
|
||||
}
|
||||
|
||||
// NewNilWindow создаёт NilWindow.
|
||||
func NewNilWindow() *NilWindow { return &NilWindow{} }
|
||||
|
||||
func (n *NilWindow) OnMessage(h chat.Handler) { n.handler = h }
|
||||
|
||||
func (n *NilWindow) SetOnQuit(func()) {}
|
||||
|
||||
func (n *NilWindow) Run(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (n *NilWindow) Send(context.Context, chat.Address, chat.Message) error { return nil }
|
||||
func (n *NilWindow) Ask(context.Context, chat.Address, chat.Message) error { return nil }
|
||||
func (n *NilWindow) Close() error { return nil }
|
||||
|
||||
// Submit отправляет текст пользователя в Router (ввод в окне).
|
||||
func (n *NilWindow) Submit(text string) {
|
||||
if n.handler != nil {
|
||||
n.handler(chat.Incoming{UserID: UID, Address: Address, Channel: n, Msg: chat.Message{Text: text}})
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NilWindow) OnTaskCreated(events.TaskCreated) {}
|
||||
func (n *NilWindow) OnTaskUpdated(events.TaskUpdated) {}
|
||||
func (n *NilWindow) OnTaskDeleted(events.TaskDeleted) {}
|
||||
func (n *NilWindow) OnTaskStatusChanged(events.TaskStatusChanged) {}
|
||||
func (n *NilWindow) OnHistoryAppended(events.HistoryAppended) {}
|
||||
func (n *NilWindow) OnTraceAppended(events.TraceAppended) {}
|
||||
func (n *NilWindow) OnAgentActivity(events.AgentActivity) {}
|
||||
func (n *NilWindow) OnLog(events.LogLine) {}
|
||||
80
internal/ui/window_test.go
Normal file
80
internal/ui/window_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||
)
|
||||
|
||||
func TestCommandsMapToText(t *testing.T) {
|
||||
var got []string
|
||||
c := NewCommands(func(text string) { got = append(got, text) })
|
||||
|
||||
c.Start()
|
||||
c.Cancel()
|
||||
c.Skip()
|
||||
c.Retry(7)
|
||||
c.Status(7)
|
||||
c.Continue(3)
|
||||
c.Approve()
|
||||
c.SendText("просто текст")
|
||||
|
||||
want := []string{"/start", "/cancel", "/skip", "/retry 7", "/status 7", "/continue 3", "создавай", "просто текст"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d commands, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("command %d = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilWindowSubmitReachesHandler(t *testing.T) {
|
||||
w := NewNilWindow()
|
||||
|
||||
incoming := make(chan chat.Incoming, 1)
|
||||
w.OnMessage(func(inc chat.Incoming) {
|
||||
incoming <- inc
|
||||
})
|
||||
|
||||
w.Submit("/start")
|
||||
|
||||
select {
|
||||
case inc := <-incoming:
|
||||
if inc.UserID != UID {
|
||||
t.Errorf("UserID = %q, want %q", inc.UserID, UID)
|
||||
}
|
||||
if inc.Address != Address {
|
||||
t.Errorf("Address = %q, want %q", inc.Address, Address)
|
||||
}
|
||||
if inc.Msg.Text != "/start" {
|
||||
t.Errorf("Msg.Text = %q, want /start", inc.Msg.Text)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("handler not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilWindowRunStopsOnCancel(t *testing.T) {
|
||||
w := NewNilWindow()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.Run(ctx) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not stop on cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilWindowSendNoop(t *testing.T) {
|
||||
w := NewNilWindow()
|
||||
if err := w.Send(context.Background(), Address, chat.Message{Text: "hi"}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user