From 9420b276e9688ec154ac6699c83bd88b66fffec3 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Fri, 21 Aug 2026 10:33:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20chat=20?= =?UTF-8?q?=E2=80=94=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=D1=8F=20LLM-=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (фабрики/хелперы) --- internal/agents/agents.go | 4 +- internal/agents/chat.md | 17 +++ internal/app/app.go | 56 ++++++++ internal/ui/desktop/window.go | 245 +++++++++++++++++++++++++--------- internal/ui/session.go | 43 ++++++ internal/ui/session_test.go | 55 ++++++++ 6 files changed, 356 insertions(+), 64 deletions(-) create mode 100644 internal/agents/chat.md create mode 100644 internal/ui/session.go create mode 100644 internal/ui/session_test.go diff --git a/internal/agents/agents.go b/internal/agents/agents.go index fc1a961..7e7d952 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -21,7 +21,7 @@ var FS embed.FS // // Держим в курсе: если добавить файл в каталог, он автоматически попадёт // в FS, но для детерминированной распаковки список лучше дополнять здесь. -var Names = []string{"analyst", "dev", "reviewer"} +var Names = []string{"analyst", "dev", "reviewer", "chat"} // WriteTo распаковывает всех встроенных агентов в каталог dir/agents // (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна. @@ -37,4 +37,4 @@ func WriteTo(dir string) error { } } return nil -} \ No newline at end of file +} diff --git a/internal/agents/chat.md b/internal/agents/chat.md new file mode 100644 index 0000000..731b189 --- /dev/null +++ b/internal/agents/chat.md @@ -0,0 +1,17 @@ +--- +name: chat +description: Свободный чат с Ratatoskr — прямой диалог с LLM без задачи и JSON-вердиктов +mode: primary +--- + +Ты — полезный ассистент в диалоговом режиме. Пользователь пишет тебе произвольные +сообщения; отвечай на них прямо и по делу, на русском языке, естественно. + +Особенности: +1. Отвечай на вопросы пользователя, помогай с задачами разработки, поясняй код. +2. Не оборачивай ответ в JSON и не возвращай структурированные вердикты — это + свободный диалог, не конвейер задач Ratatoskr. +3. Если тебе не хватает контекста — задай уточняющий вопрос в обычном тексте. +4. Пиши кратко и по существу, если пользователь не просит развёрнуто. + +Просто продолжай диалог. \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index e81bfbe..7efae7e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -73,6 +73,35 @@ type App struct { // nil — headless-режим (без окна). UI ui.Window Controller *ui.Controller + // FreeChat — свободные диалоги с LLM вне машины задач (in-memory). + FreeChat *FreeChat +} + +// FreeChat — свободный LLM-чат (без задачи). Каждый UserID (вкладка UI) +// держит свою opencode-сессию, чтобы контекст вкладки не смешивался. +// Транскрипт живёт в памяти окна; здесь только связка uid → opencode sessionID. +type FreeChat struct { + Runner analyst.OpenCodeRunner + Worktree string + sessions map[chat.UserID]string // uid → opencode sessionID +} + +// NewFreeChat создаёт сервис свободного чата. +func NewFreeChat(runner analyst.OpenCodeRunner, worktree string) *FreeChat { + return &FreeChat{Runner: runner, Worktree: worktree, sessions: map[chat.UserID]string{}} +} + +// Chat отправляет текст пользователя свободной сессии в opencode (agent="chat") +// и возвращает ответ модели. Сессия продолжается (resume по sessionID), поэтому +// каждая вкладка ведёт свой независимый диалог. +func (f *FreeChat) Chat(ctx context.Context, uid chat.UserID, text string) (string, error) { + sid := f.sessions[uid] + res, err := f.Runner.Run(ctx, text, f.Worktree, "chat", sid) + if err != nil { + return "", err + } + f.sessions[uid] = res.SessionID + return res.Stdout, nil } // New читает конфиг и собирает все зависимости. @@ -212,6 +241,9 @@ func New(configPath, version, updateToken string, noUI bool) (*App, error) { a.Router = router a.Worker = w + // Свободный LLM-чат (вкладки UI без задачи): общий Runner, свой sessionID на uid. + a.FreeChat = NewFreeChat(ocRunner, cfg.Paths.Worktree) + // /status N читает живое состояние сессии через liveProbe-адаптер. coreCtx.Live = &liveProbe{reg: live} @@ -363,6 +395,10 @@ func (a *App) handleIncoming(inc chat.Incoming) { } a.send(ctx, uid, result.Reply) + case isUISession(uid): + // Свободная UI-вкладка (нет активной задачи): свободный LLM-чат. + a.freeChatReply(ctx, uid, text) + default: a.send(ctx, uid, "Нет активной задачи. /start — создать новую.") } @@ -427,6 +463,26 @@ func hasArg(text string) bool { return i > 0 && strings.TrimSpace(s[i:]) != "" } +// isUISession — true, если UserID принадлежит UI-вкладке-сессии (ui://sess-*). +func isUISession(uid chat.UserID) bool { + return strings.HasPrefix(string(uid), ui.SessionID) +} + +// freeChatReply — ответ свободной LLM-сессии (вкладка UI без активной задачи). +func (a *App) freeChatReply(ctx context.Context, uid chat.UserID, text string) { + if a.FreeChat == nil { + a.send(ctx, uid, "Нет активной задачи. /start — создать новую.") + return + } + reply, err := a.FreeChat.Chat(ctx, uid, text) + if err != nil { + log.Printf("app: free chat %s: %v", uid, err) + a.send(ctx, uid, "Ошибка LLM-чата.") + return + } + a.send(ctx, uid, reply) +} + // ownerUID — куда слать авто-уведомления об обновлении. func (a *App) ownerUID() chat.UserID { if a.Config.Telegram.ChatID != "" { diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index 5ea5361..aa2f726 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -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- 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) +) diff --git a/internal/ui/session.go b/internal/ui/session.go new file mode 100644 index 0000000..2b9d72c --- /dev/null +++ b/internal/ui/session.go @@ -0,0 +1,43 @@ +package ui + +import ( + "github.com/kamelion/ratatoskr-go/internal/chat" + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// Session — одна вкладка-сессия UI: независимый диалог с LLM. +// +// Каждая вкладка имеет собственный UserID и Address, поэтому core видит её как +// отдельный чат со своим активным task (политика «одна задача на чат» работает +// из коробки). Свободная сессия (BoundTask==0) — in-memory диалог с моделью +// вне машины задач; task-сессия (BoundTask>0) — диалог конкретной задачи, у +// которой UserID == task.ChatID (чат задачи уникален). +type Session struct { + UserID chat.UserID + Address chat.Address + Title string + BoundTask int64 // 0 = свободный чат; >0 — задача, к которой привязана вкладка +} + +// NewFreeSession создаёт свободную сессию (без задачи). userID/address должны +// быть уникальными (например "ui://sess-"). Транскрипт живёт только в памяти. +func NewFreeSession(userID chat.UserID, address chat.Address) *Session { + return &Session{UserID: userID, Address: address, Title: "Новый чат", BoundTask: 0} +} + +// NewTaskSession создаёт сессию, привязанную к задаче. UserID == task.ChatID, +// чтобы Router/core находили активную задачу этой вкладки. address — уникальный +// адрес доставки вкладки (не должен совпадать с UserID других вкладок). +func NewTaskSession(address chat.Address, t storage.Task) *Session { + title := t.Title + if title == "" { + title = "Задача #" + Itoa(t.ID) + } + return &Session{UserID: chat.UserID(t.ChatID), Address: address, Title: title, BoundTask: t.ID} +} + +// SessionID — префикс идентификаторов UI-сессий (и UserID, и Address). +const SessionID = "ui://sess-" + +// IsFreeSession — true, если вкладка не привязана к задаче. +func IsFreeSession(s *Session) bool { return s != nil && s.BoundTask == 0 } diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go new file mode 100644 index 0000000..8399932 --- /dev/null +++ b/internal/ui/session_test.go @@ -0,0 +1,55 @@ +package ui + +import ( + "testing" + + "github.com/kamelion/ratatoskr-go/internal/chat" + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +func TestNewFreeSession(t *testing.T) { + s := NewFreeSession(chat.UserID("ui://sess-1"), chat.Address("ui://sess-1")) + if s.UserID != chat.UserID("ui://sess-1") { + t.Errorf("UserID = %q, want ui://sess-1", s.UserID) + } + if s.Address != chat.Address("ui://sess-1") { + t.Errorf("Address = %q, want ui://sess-1", s.Address) + } + if s.BoundTask != 0 { + t.Errorf("BoundTask = %d, want 0", s.BoundTask) + } + if !IsFreeSession(s) { + t.Error("free session must be free") + } +} + +func TestNewTaskSessionUsesTaskChatID(t *testing.T) { + task := &storage.Task{ID: 7, ChatID: "ui://sess-3", Title: "Фича"} + s := NewTaskSession(chat.Address("ui://sess-3"), *task) + if s.UserID != chat.UserID("ui://sess-3") { + t.Errorf("UserID = %q, want task.ChatID", s.UserID) + } + if s.BoundTask != 7 { + t.Errorf("BoundTask = %d, want 7", s.BoundTask) + } + if s.Title != "Фича" { + t.Errorf("Title = %q, want Фича", s.Title) + } + if IsFreeSession(s) { + t.Error("task session must not be free") + } +} + +func TestNewTaskSessionTitleFallback(t *testing.T) { + task := &storage.Task{ID: 3, ChatID: "ui://sess-3"} + s := NewTaskSession(chat.Address("ui://sess-3"), *task) + if s.Title != "Задача #3" { + t.Errorf("Title = %q, want Задача #3", s.Title) + } +} + +func TestIsFreeSessionNil(t *testing.T) { + if IsFreeSession(nil) { + t.Error("nil is not free") + } +} -- 2.49.1