453 lines
13 KiB
Go
453 lines
13 KiB
Go
//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.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()
|
||
maximizeWindow()
|
||
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)
|
||
)
|