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 (фабрики/хелперы)
This commit is contained in:
@@ -8,6 +8,7 @@ package desktop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
@@ -25,15 +26,17 @@ import (
|
||||
const (
|
||||
selectedPref = "task.selected"
|
||||
splitHPref = "split.h"
|
||||
splitVPref = "split.v"
|
||||
)
|
||||
|
||||
// Window — Fyne-окно: chat.Channel + ui.View.
|
||||
//
|
||||
// Внутри — вкладки-сессии (ui.Session): каждая вкладка — независимый диалог
|
||||
// с LLM (свой UserID/Address, свой активный task). Свободные вкладки (без
|
||||
// задачи) создаются кнопкой «Новый чат»; вкладки задач открываются из списка.
|
||||
type Window struct {
|
||||
fyneApp fyne.App
|
||||
win fyne.Window
|
||||
store ui.Store
|
||||
commands *ui.Commands
|
||||
|
||||
handler chat.Handler
|
||||
|
||||
@@ -41,14 +44,19 @@ type Window struct {
|
||||
selected int64
|
||||
|
||||
// виджеты
|
||||
taskList *TaskListPanel
|
||||
detail *TaskDetailPanel
|
||||
chat *ChatPanel
|
||||
state *StatePanel
|
||||
logs *LogPanel
|
||||
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
|
||||
vsplit *container.Split
|
||||
|
||||
onQuit func()
|
||||
}
|
||||
@@ -57,12 +65,14 @@ type Window struct {
|
||||
func New(appID string, store ui.Store) *Window {
|
||||
fa := app.NewWithID(appID)
|
||||
w := &Window{
|
||||
fyneApp: fa,
|
||||
store: store,
|
||||
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.commands = ui.NewCommands(w.submitText)
|
||||
w.build()
|
||||
w.win.SetCloseIntercept(func() {
|
||||
// Закрытие окна = сворачивание (спец 12.6): Core продолжает работать.
|
||||
@@ -79,17 +89,12 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
|
||||
// build собирает виджеты и раскладку.
|
||||
func (w *Window) build() {
|
||||
w.detail = NewTaskDetailPanel()
|
||||
|
||||
w.chat = NewChatPanel()
|
||||
w.chat.SetCommands(w.commands)
|
||||
|
||||
w.state = NewStatePanel()
|
||||
|
||||
w.logs = NewLogPanel()
|
||||
|
||||
// Список задач (слева)
|
||||
w.taskList = NewTaskListPanel()
|
||||
w.taskList.OnSelect(func(id int64) { w.selectTask(id) })
|
||||
w.taskList.OnSelect(func(id int64) { w.openTaskTab(id) })
|
||||
|
||||
quitBtn := widget.NewButtonWithIcon("Завершить", theme.LogoutIcon(), func() {
|
||||
w.saveLayout()
|
||||
@@ -99,60 +104,119 @@ func (w *Window) build() {
|
||||
})
|
||||
left := container.NewBorder(nil, quitBtn, nil, nil, w.taskList.Widget())
|
||||
|
||||
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
|
||||
details := w.detail.Widget()
|
||||
right := container.NewVSplit(
|
||||
container.NewScroll(details),
|
||||
w.chat.Transcript(),
|
||||
)
|
||||
w.vsplit = right
|
||||
// Центр: вкладки-сессии + кнопка «Новый чат».
|
||||
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)
|
||||
|
||||
bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs)
|
||||
|
||||
w.hsplit = container.NewHSplit(left, right)
|
||||
|
||||
root := container.NewVSplit(w.hsplit, bottom)
|
||||
w.win.SetContent(root)
|
||||
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.vsplit.SetOffset(pref.FloatWithFallback(splitVPref, 0.45))
|
||||
|
||||
// Начальный снимок списка.
|
||||
w.refreshList()
|
||||
|
||||
// Открываем свободную вкладку-сессию по умолчанию.
|
||||
w.addFreeTab()
|
||||
}
|
||||
|
||||
// saveLayout сохраняет текущие позиции сплитов в Preferences.
|
||||
// Вызывается при сворачивании окна и перед полным выходом.
|
||||
func (w *Window) saveLayout() {
|
||||
pref := w.fyneApp.Preferences()
|
||||
pref.SetFloat(splitHPref, w.hsplit.Offset)
|
||||
pref.SetFloat(splitVPref, w.vsplit.Offset)
|
||||
// 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)))
|
||||
}
|
||||
|
||||
// selectTask загружает детали выбранной задачи (снимки из Store).
|
||||
func (w *Window) selectTask(id int64) {
|
||||
w.selected = id
|
||||
w.taskList.Select(id)
|
||||
w.fyneApp.Preferences().SetInt(selectedPref, int(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 {
|
||||
w.detail.ShowEmpty()
|
||||
w.chat.Clear()
|
||||
return
|
||||
}
|
||||
w.detail.ShowTask(t)
|
||||
|
||||
// Диалог: история + краткие трассы.
|
||||
// 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
|
||||
@@ -172,9 +236,30 @@ func (w *Window) selectTask(id int64) {
|
||||
if b.Len() == 0 {
|
||||
b.WriteString("Нет сообщений. /start — начать задачу.")
|
||||
}
|
||||
w.chat.SetTranscript(b.String())
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -204,18 +289,34 @@ func (w *Window) appendLog(text string) {
|
||||
w.logs.Append(text)
|
||||
}
|
||||
|
||||
// submitText отправляет ввод пользователя в Router.
|
||||
func (w *Window) submitText(text string) {
|
||||
// submitText отправляет ввод пользователя из вкладки в Router.
|
||||
func (w *Window) submitText(uid chat.UserID, addr chat.Address, text string) {
|
||||
if w.handler != nil {
|
||||
w.handler(chat.Incoming{
|
||||
UserID: ui.UID,
|
||||
Address: ui.Address,
|
||||
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).
|
||||
@@ -232,30 +333,37 @@ func (w *Window) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send доставляет сообщение Core в диалог окна.
|
||||
func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error {
|
||||
// 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 i, opt := range m.Options {
|
||||
for _, opt := range m.Options {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(opt.Label)
|
||||
_ = i
|
||||
}
|
||||
text = sb.String()
|
||||
}
|
||||
fyne.Do(func() { w.chat.Append("🤖 " + text) })
|
||||
fyne.Do(func() { panel.Append("🤖 " + text) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ask — вопрос с вариантами (отображается в диалоге).
|
||||
func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
|
||||
// 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() { w.chat.Append("❓ " + text) })
|
||||
fyne.Do(func() { panel.Append("❓ " + text) })
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -272,6 +380,8 @@ func (w *Window) Close() error {
|
||||
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)
|
||||
}
|
||||
@@ -308,8 +418,13 @@ func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) {
|
||||
|
||||
func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
|
||||
fyne.Do(func() {
|
||||
if w.selected == e.TaskID {
|
||||
w.chat.Append(ui.FormatRole(e.Role) + e.Content)
|
||||
// история приходит в диалог вкладки задачи
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -329,3 +444,9 @@ func (w *Window) OnAgentActivity(e events.AgentActivity) {
|
||||
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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user