//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- 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) )