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

@@ -23,6 +23,7 @@ import (
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
"github.com/kamelion/ratatoskr-go/internal/ui"
"github.com/kamelion/ratatoskr-go/internal/update"
"github.com/kamelion/ratatoskr-go/internal/worker"
)
@@ -67,6 +68,11 @@ type App struct {
// Events — доменная шина UI; LogEvents — шина логов (панель «Логи»).
Events *events.Bus
LogEvents *events.LogBus
// UI — десктопное окно (если собрано с cgo и не задан --noui).
// nil — headless-режим (без окна).
UI ui.Window
Controller *ui.Controller
}
// New читает конфиг и собирает все зависимости.
@@ -74,7 +80,7 @@ type App struct {
// version — вшитая версия бинаря (ldflag -X main.version).
// updateToken — вшитый токен read:package для авто-обновления
// (ldflag -X main.updateToken); имеет приоритет над update.token из конфига.
func New(configPath, version, updateToken string) (*App, error) {
func New(configPath, version, updateToken string, noUI bool) (*App, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConfig, err)
@@ -165,6 +171,22 @@ func New(configPath, version, updateToken string) (*App, error) {
router := chat.NewRouter(a.handleIncoming)
// UI-канал: ещё одна реализация chat.Channel (спец 12.1).
// Собирается только при наличии cgo и отсутствии --noui.
if !noUI {
win := newUIWindow(ui.NewDBStore(store))
if win != nil {
if err := router.Attach(win); err != nil {
store.Close()
return nil, fmt.Errorf("attach ui: %w", err)
}
a.UI = win
// Контроллер подписывается на шины и мостит события в окно (fyne.Do).
a.Controller = ui.New(a.Events, a.LogEvents, win)
a.Controller.Start()
}
}
// Telegram-канал
tg := telegram.New(cfg.Telegram.Token, cfg.Chat.PollInterval.Duration())
a.tg = tg
@@ -246,6 +268,14 @@ func (a *App) Run(ctx context.Context) error {
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
a.startAutoCheck(ctx)
// UI: окно блокирует главную горутину (спец 12.6). Кнопка «Завершить»
// отменяет ctx → fyneApp.Quit() → Run возвращается → graceful shutdown.
// Закрытие крестиком = сворачивание: Core продолжает работать.
if a.UI != nil {
a.UI.SetOnQuit(cancel)
return a.UI.Run(ctx)
}
// Ожидание сигнала или фатальной ошибки Telegram
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

View File

@@ -21,7 +21,7 @@ func TestNew(t *testing.T) {
t.Setenv("RATATOSKR_DB", dbPath)
// Загружаем без config-файла (дефолты + env)
a, err := New("", "dev", "")
a, err := New("", "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
@@ -52,7 +52,7 @@ func TestNew_MissingToken(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "")
_, err := New("", "dev", "", true)
if err == nil {
t.Fatal("expected error for missing token")
}
@@ -67,7 +67,7 @@ func TestNew_BadDB(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("", "dev", "")
_, err := New("", "dev", "", true)
if err == nil {
t.Fatal("expected error for invalid db path")
}
@@ -84,7 +84,7 @@ func TestNew_RunCtxCancel(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath)
a, err := New("", "dev", "")
a, err := New("", "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
@@ -130,7 +130,7 @@ func TestNew_WorktreeCreated(t *testing.T) {
t.Fatalf("write config: %v", err)
}
a, err := New(configPath, "dev", "")
a, err := New(configPath, "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
@@ -175,7 +175,7 @@ func TestNew_UpdateWiring(t *testing.T) {
}
// 1) base_url из update-блока, а НЕ git
a, err := New(configPath, "dev", "")
a, err := New(configPath, "dev", "", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
@@ -196,7 +196,7 @@ func TestNew_UpdateWiring(t *testing.T) {
}
// 2) вшитый updateToken перекрывает конфиг
a2, err := New(configPath, "dev", "embedded-update-token")
a2, err := New(configPath, "dev", "embedded-update-token", true)
if err != nil {
t.Fatalf("New() err = %v", err)
}
@@ -248,4 +248,4 @@ func TestVersion_Semver(t *testing.T) {
if !re.MatchString(Version) {
t.Errorf("Version = %q, ожидался формат major.minor.patch", Version)
}
}
}

15
internal/app/ui_cgo.go Normal file
View File

@@ -0,0 +1,15 @@
//go:build cgo
package app
import (
"github.com/kamelion/ratatoskr-go/internal/ui"
"github.com/kamelion/ratatoskr-go/internal/ui/desktop"
)
// newUIWindow создаёт десктопное окно (Fyne) на основе чтения-модели.
// Сборка с cgo: GLFW доступен, окно реально.
func newUIWindow(store ui.Store) ui.Window {
w := desktop.New("ratatoskr-go", store)
return w
}

11
internal/app/ui_noui.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build !cgo
package app
import (
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// newUIWindow возвращает nil: без cgo (нет GLFW) окно недоступно,
// приложение работает в headless-режиме.
func newUIWindow(ui.Store) ui.Window { return nil }

46
internal/ui/commands.go Normal file
View 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) }

View File

@@ -49,4 +49,4 @@ func (c *Controller) Start() {
func (c *Controller) Close() {
c.domain.Close()
c.logs.Close()
}
}

View File

@@ -143,4 +143,4 @@ func TestControllerIdempotentClose(t *testing.T) {
c.Start()
c.Close()
c.Close() // не должно падать
}
}

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:])
}

73
internal/ui/store.go Normal file
View 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
View 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)
}
}

View File

@@ -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
View 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) {}

View 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)
}
}