Files
ratatoskr-go/internal/ui/desktop/window.go
ki.sagidullin 9420b276e9
Some checks failed
CI / test (pull_request) Failing after 1m20s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m2s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s
feat(ui): каждая вкладка chat — независимая LLM-сессия
- ui.Session (UserID/Address/Title/BoundTask) + фабрики NewFreeSession/NewTaskSession и префикс ui://sess-*
- свободная вкладка без задачи: свой UserID, диалог с LLM в памяти (agent=chat, resume по sessionID)
- /start в свободной вкладке создаёт задачу с ChatID == UserID вкладки -> вкладка становится task-сессией
- desktop окно: container.AppTabs с вкладками, кнопка «Новый чат», маршрутизация Send/Ask/истории по Address вкладки
- app.FreeChat: in-memory связка uid -> opencode sessionID; роутинг свободного чата в handleIncoming
- агент chat.md + регистрация 'chat' в agents.Names
- тесты: session_test.go (фабрики/хелперы)
2026-08-21 10:33:05 +05:00

453 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build cgo
// Package desktop — Fyne-реализация окна UI (см. internal/ui).
//
// Сборка требует cgo (GLFW). На машинах без C-компилятора пакет не входит
// в сборку, и app работает в headless-режиме (newUIWindow → nil).
package desktop
import (
"context"
"fmt"
"log"
"strings"
"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/ui"
)
const (
selectedPref = "task.selected"
splitHPref = "split.h"
)
// Window — Fyne-окно: chat.Channel + ui.View.
//
// Внутри — вкладки-сессии (ui.Session): каждая вкладка — независимый диалог
// с LLM (свой UserID/Address, свой активный task). Свободные вкладки (без
// задачи) создаются кнопкой «Новый чат»; вкладки задач открываются из списка.
type Window struct {
fyneApp fyne.App
win fyne.Window
store ui.Store
handler chat.Handler
// состояние выбора (живёт в окне, спец 12.10)
selected int64
// виджеты
taskList *TaskListPanel
detail *TaskDetailPanel
state *StatePanel
logs *LogPanel
// вкладки-сессии
tabs *container.AppTabs
sessions map[chat.Address]*ui.Session // Address вкладки → сессия
panels map[chat.Address]*ChatPanel // Address вкладки → панель
tabSession map[*container.TabItem]*ui.Session
nextSess int // счётчик уникальных ui://sess-<n>
hsplit *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,
sessions: map[chat.Address]*ui.Session{},
panels: map[chat.Address]*ChatPanel{},
tabSession: map[*container.TabItem]*ui.Session{},
}
w.win = fa.NewWindow("Ratatoskr")
w.win.SetFullScreen(true)
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.detail = NewTaskDetailPanel()
w.state = NewStatePanel()
w.logs = NewLogPanel()
// Список задач (слева)
w.taskList = NewTaskListPanel()
w.taskList.OnSelect(func(id int64) { w.openTaskTab(id) })
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
w.saveLayout()
if w.onQuit != nil {
w.onQuit()
}
})
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
// Центр: вкладки-сессии + кнопка «Новый чат».
w.tabs = container.NewAppTabs()
w.tabs.OnSelected = func(tab *container.TabItem) {
// фокус вкладки → показываем детали/состояние её задачи
if s := w.tabSession[tab]; s != nil && s.BoundTask > 0 {
w.selectTask(s.BoundTask)
}
}
newBtn := widget.NewButtonWithIcon("Новый чат", theme.ContentAddIcon(), func() {
w.addFreeTab()
})
center := container.NewBorder(newBtn, nil, nil, nil, w.tabs)
// Нижняя панель: вкладки «Логи» + «Состояние».
bottomTabs := container.NewAppTabs(
container.NewTabItem("Логи", w.logs.Widget()),
container.NewTabItem("Состояние", w.state.Widget()),
)
bottom := container.NewBorder(nil, nil, nil, nil, bottomTabs)
w.hsplit = container.NewHSplit(left, container.NewVSplit(center, bottom))
w.win.SetContent(w.hsplit)
// Восстановление layout из Preferences.
pref := w.fyneApp.Preferences()
w.hsplit.SetOffset(pref.FloatWithFallback(splitHPref, 0.30))
// Начальный снимок списка.
w.refreshList()
// Открываем свободную вкладку-сессию по умолчанию.
w.addFreeTab()
}
// addFreeTab создаёт свободную вкладку-сессию (in-memory диалог с LLM).
func (w *Window) addFreeTab() {
w.nextSess++
id := fmt.Sprintf("%s%d", ui.SessionID, w.nextSess)
w.addTab(ui.NewFreeSession(chat.UserID(id), chat.Address(id)))
}
// addTab создаёт вкладку для сессии и фокусирует её.
func (w *Window) addTab(sess *ui.Session) {
panel := NewChatPanel()
panel.SetCommands(ui.NewCommands(func(text string) {
w.submitText(sess.UserID, sess.Address, text)
}))
content := container.NewBorder(nil, panel.Composer(), nil, nil, panel.Transcript())
tab := container.NewTabItem(sess.Title, content)
w.tabs.Append(tab)
w.sessions[sess.Address] = sess
w.panels[sess.Address] = panel
w.tabSession[tab] = sess
w.tabs.Select(tab)
}
// tabForAddress возвращает вкладку по адресу сессии.
func (w *Window) tabForAddress(addr chat.Address) *container.TabItem {
for tab, s := range w.tabSession {
if s.Address == addr {
return tab
}
}
return nil
}
// openTaskTab открывает вкладку задачи (или фокусирует уже открытую).
//
// Вкладку можно открыть только для задачи, созданной в UI-сессии (ChatID с
// префиксом ui://sess-*): её ChatID уникален и не пересекается с Telegram-каналом.
// Задачи Telegram не открываются в отдельной вкладке — их показывает список и
// детали (selectTask), чтобы не плодить сессию с общим UserID=чат-id.
func (w *Window) openTaskTab(id int64) {
ctx := context.Background()
t, err := w.store.GetTask(ctx, id)
if err != nil {
return
}
// Telegram-задача (ChatID без ui://sess-*) — только детали, без вкладки.
if !strings.HasPrefix(t.ChatID, ui.SessionID) {
w.selectTask(id)
return
}
// Если вкладка для задачи уже есть — фокусируем.
for _, s := range w.sessions {
if s.BoundTask == id {
if tab := w.tabForAddress(s.Address); tab != nil {
w.tabs.Select(tab)
return
}
}
}
// Создаём новую вкладку: UserID == task.ChatID (чат задачи уникален).
w.nextSess++
addr := chat.Address(fmt.Sprintf("%s%d", ui.SessionID, w.nextSess))
sess := ui.NewTaskSession(addr, t)
w.addTab(sess)
// Транскрипт из истории задачи.
if panel := w.panels[sess.Address]; panel != nil {
panel.SetTranscript(w.renderHistory(ctx, id))
}
w.selectTask(id)
}
// renderHistory строит транскрипт диалога задачи (история + краткие трассы).
func (w *Window) renderHistory(ctx context.Context, id int64) string {
hist, err := w.store.GetHistory(ctx, id)
if err != nil {
hist = nil
}
var b strings.Builder
for _, h := range hist {
b.WriteString(ui.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 — начать задачу.")
}
return b.String()
}
// saveLayout сохраняет текущие позиции сплитов в Preferences.
// Вызывается при сворачивании окна и перед полным выходом.
func (w *Window) saveLayout() {
pref := w.fyneApp.Preferences()
pref.SetFloat(splitHPref, w.hsplit.Offset)
}
// selectTask загружает детали выбранной задачи (снимки из Store).
func (w *Window) selectTask(id int64) {
w.selected = id
w.taskList.Select(id)
w.fyneApp.Preferences().SetInt(selectedPref, int(id))
ctx := context.Background()
t, err := w.store.GetTask(ctx, id)
if err != nil {
w.detail.ShowEmpty()
w.state.SetText(ui.StateText(nil))
return
}
w.detail.ShowTask(t)
w.refreshState(ctx, id)
}
// refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы).
func (w *Window) refreshState(ctx context.Context, id int64) {
traces, err := w.store.GetTraces(ctx, id)
if err != nil {
return
}
w.state.SetText(ui.StateText(traces))
}
// 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.taskList.SetTasks(tasks)
w.taskList.Select(w.selected)
}
// appendLog добавляет строку в панель «Логи» (делегирует в LogPanel).
func (w *Window) appendLog(text string) {
w.logs.Append(text)
}
// submitText отправляет ввод пользователя из вкладки в Router.
func (w *Window) submitText(uid chat.UserID, addr chat.Address, text string) {
if w.handler != nil {
w.handler(chat.Incoming{
UserID: uid,
Address: addr,
Channel: w,
Msg: chat.Message{Text: text},
})
}
}
// bindTaskSession связывает свободную вкладку с созданной задачей: когда
// /start в свободном табе создал задачу с ChatID == UserID этой вкладки,
// вкладка становится task-сессией (спец: каждая задача — своя вкладка).
func (w *Window) bindTaskSession(chatID string, taskID int64) {
for addr, s := range w.sessions {
if string(s.UserID) != chatID || s.BoundTask != 0 {
continue
}
s.BoundTask = taskID
if tab := w.tabForAddress(addr); tab != nil {
tab.Text = "Задача #" + ui.Itoa(taskID)
w.tabs.Refresh()
}
}
}
// ---- 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 в диалог вкладки по её Address.
func (w *Window) Send(_ context.Context, to chat.Address, m chat.Message) error {
panel, ok := w.panels[to]
if !ok {
return nil
}
text := m.Text
if len(m.Options) > 0 {
var sb strings.Builder
sb.WriteString(text)
for _, opt := range m.Options {
sb.WriteString("\n")
sb.WriteString(opt.Label)
}
text = sb.String()
}
fyne.Do(func() { panel.Append("🤖 " + text) })
return nil
}
// Ask — вопрос с вариантами (отображается в диалоге вкладки).
func (w *Window) Ask(_ context.Context, to chat.Address, m chat.Message) error {
panel, ok := w.panels[to]
if !ok {
return nil
}
text := m.Text
for _, opt := range m.Options {
text += "\n" + opt.Label
}
fyne.Do(func() { panel.Append("❓ " + 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()
// /start в свободной вкладке: вкладка привязывается к задаче.
w.bindTaskSession(e.ChatID, e.ID)
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.state.Append(ui.TaskStatusText(e))
})
}
func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
fyne.Do(func() {
// история приходит в диалог вкладки задачи
for addr, s := range w.sessions {
if s.BoundTask == e.TaskID {
if panel, ok := w.panels[addr]; ok {
panel.Append(ui.FormatRole(e.Role) + e.Content)
}
}
}
})
}
func (w *Window) OnTraceAppended(e events.TraceAppended) {
fyne.Do(func() {
w.state.Append(ui.TraceLine(e.Agent, e.Status))
})
}
func (w *Window) OnAgentActivity(e events.AgentActivity) {
fyne.Do(func() {
w.state.Append(ui.ActivityLine(e.Agent, e.Stage))
})
}
func (w *Window) OnLog(e events.LogLine) {
w.appendLog(e.Text)
}
// compile-time проверка: окно реализует chat.Channel и ui.View.
var (
_ chat.Channel = (*Window)(nil)
_ ui.View = (*Window)(nil)
)