diff --git a/cmd/ratatoskr/main.go b/cmd/ratatoskr/main.go index ea1d33c..7e549f9 100644 --- a/cmd/ratatoskr/main.go +++ b/cmd/ratatoskr/main.go @@ -27,6 +27,7 @@ var updateToken = "" func main() { cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)") versionFlag := flag.Bool("version", false, "показать версию и выйти") + noUI := flag.Bool("noui", false, "работать без графического окна (headless)") flag.Parse() if *versionFlag { @@ -34,7 +35,7 @@ func main() { return } - a, err := app.New(*cfg, version, updateToken) + a, err := app.New(*cfg, version, updateToken, *noUI) if err != nil { log.Fatalf("app init: %v", err) } @@ -60,4 +61,4 @@ func main() { } log.Print("ratatoskr: остановлен") os.Exit(0) -} \ No newline at end of file +} diff --git a/docs/ui-spec.md b/docs/ui-spec.md index f69dc1a..363fb09 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -146,9 +146,16 @@ ### 12.7. Скоуп реализации (поэтапно) -- **Фаза 1**: `internal/ui` со сплитами + `TaskStore`-абстракция + **односторонний** поток: команды → Core, периодические snapshots из БД (без шины), всё через `fyne.Do`. -- **Фаза 2**: добавить `event.Bus` — Core публикует события, UI подписывается (статусы, история, live-шаги). -- **Фаза 3**: консистентность TG↔UI через общий маршрутизатор/шину (`UserID`, `chat.Router`). +- [x] **Фаза 1**: `internal/ui` — Store-абстракция (копии), окно как `chat.Channel`, + команды через канал, snapshots из БД. Fyne-реализация — `internal/ui/desktop` + (build-tag `cgo`, требует компилятор C для GLFW). +- [x] **Фаза 2**: `internal/events` — Bus/Hub, Core публикует события, UI подписывается + через `Controller` (статусы, история, live-шаги, логи). +- [ ] **Фаза 3**: консистентность TG↔UI через общий маршрутизатор/шину (`UserID`, `chat.Router`). + +Примечание: окно (Фаза 1) собирается только с cgo; на машинах без C-компилятора +приложение работает headless (UI=nil). Реальное окно полноценно проверяется +на машине с MinGW-w64/gcc (`go build -ldflags ...`), здесь — только typecheck. --- diff --git a/go.mod b/go.mod index 6f02588..4bb5bbe 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,48 @@ module github.com/kamelion/ratatoskr-go go 1.25.0 require ( + fyne.io/fyne/v2 v2.6.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.56.0 ) require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.22.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index a6bc08b..babcfb7 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,99 @@ +fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ= +fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= diff --git a/internal/app/app.go b/internal/app/app.go index de7ffdb..e81bfbe 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e2f4d06..832caf7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) } -} \ No newline at end of file +} diff --git a/internal/app/ui_cgo.go b/internal/app/ui_cgo.go new file mode 100644 index 0000000..a7b8535 --- /dev/null +++ b/internal/app/ui_cgo.go @@ -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 +} diff --git a/internal/app/ui_noui.go b/internal/app/ui_noui.go new file mode 100644 index 0000000..cd835e9 --- /dev/null +++ b/internal/app/ui_noui.go @@ -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 } diff --git a/internal/ui/commands.go b/internal/ui/commands.go new file mode 100644 index 0000000..bcf202e --- /dev/null +++ b/internal/ui/commands.go @@ -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) } diff --git a/internal/ui/controller.go b/internal/ui/controller.go index b53f606..3765746 100644 --- a/internal/ui/controller.go +++ b/internal/ui/controller.go @@ -49,4 +49,4 @@ func (c *Controller) Start() { func (c *Controller) Close() { c.domain.Close() c.logs.Close() -} \ No newline at end of file +} diff --git a/internal/ui/controller_test.go b/internal/ui/controller_test.go index 67df3f9..9280f81 100644 --- a/internal/ui/controller_test.go +++ b/internal/ui/controller_test.go @@ -143,4 +143,4 @@ func TestControllerIdempotentClose(t *testing.T) { c.Start() c.Close() c.Close() // не должно падать -} \ No newline at end of file +} diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go new file mode 100644 index 0000000..cd21481 --- /dev/null +++ b/internal/ui/desktop/window.go @@ -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:]) +} diff --git a/internal/ui/store.go b/internal/ui/store.go new file mode 100644 index 0000000..183c310 --- /dev/null +++ b/internal/ui/store.go @@ -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 +} diff --git a/internal/ui/store_test.go b/internal/ui/store_test.go new file mode 100644 index 0000000..e668e9c --- /dev/null +++ b/internal/ui/store_test.go @@ -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) + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go index 38df43c..da4e261 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -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) {} \ No newline at end of file +func (NilView) OnLog(events.LogLine) {} diff --git a/internal/ui/window.go b/internal/ui/window.go new file mode 100644 index 0000000..9850fc2 --- /dev/null +++ b/internal/ui/window.go @@ -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) {} diff --git a/internal/ui/window_test.go b/internal/ui/window_test.go new file mode 100644 index 0000000..75ba9d4 --- /dev/null +++ b/internal/ui/window_test.go @@ -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) + } +}