refactor(ui): вынести панель «Диалог» (ChatPanel) из монолита окна

Спеца 12.12: интерфейс + Nil + FormatRole в internal/ui/chat_panel.go,
Fyne-реализация в internal/ui/desktop/chat_panel.go (транскрипт + композитор).
Окно как chat.Channel рендерит Send/Ask/историю через Append; state_panel
не трогаем.
This commit is contained in:
ki.sagidullin
2026-08-20 23:48:54 +05:00
parent 4c44956d3e
commit 5c93642cf8
5 changed files with 180 additions and 50 deletions

39
internal/ui/chat_panel.go Normal file
View File

@@ -0,0 +1,39 @@
package ui
// ChatPanel — панель «Диалог» (спец 12.12): транскрипт диалога выбранной
// задачи + композитор (поле ввода и кнопки команд).
//
// Транскрипт — буфер ограниченного размера; при превышении лимита старые
// строки отбрасываются. Композитор подключается к ui.Commands через
// SetCommands. Обновление — на потоке Fyne; вызывающий уже внутри fyne.Do.
type ChatPanel interface {
// SetTranscript заменяет содержимое диалога полным рендером.
SetTranscript(text string)
// Append добавляет строку в диалог (буфер ограничен).
Append(text string)
// Clear очищает диалог (задача недоступна/не выбрана).
Clear()
// SetCommands подключает команды к композитору (ввод + кнопки).
SetCommands(c *Commands)
}
// NilChatPanel — no-op реализация ChatPanel для headless-режима и тестов.
type NilChatPanel struct{}
// NewNilChatPanel создаёт NilChatPanel.
func NewNilChatPanel() *NilChatPanel { return &NilChatPanel{} }
func (NilChatPanel) SetTranscript(string) {}
func (NilChatPanel) Append(string) {}
func (NilChatPanel) Clear() {}
func (NilChatPanel) SetCommands(*Commands) {}
// FormatRole — строка роли в диалоге (pure-функция, спец 12.12).
func FormatRole(role string) string {
switch role {
case "user":
return "👤 "
default:
return "🤖 "
}
}

View File

@@ -0,0 +1,26 @@
package ui
import "testing"
// FormatRole — строка роли в диалоге (спец 12.12), pure-функция без Fyne.
func TestFormatRole(t *testing.T) {
if FormatRole("user") != "👤 " {
t.Errorf("user: got %q, want «👤 »", FormatRole("user"))
}
if FormatRole("assistant") != "🤖 " {
t.Errorf("assistant: got %q, want «🤖 »", FormatRole("assistant"))
}
if FormatRole("") != "🤖 " {
t.Errorf("default: got %q, want «🤖 »", FormatRole(""))
}
}
// NilChatPanel — no-op контракт (спец 12.8): безопасно для headless/тестов.
func TestNilChatPanelNoop(t *testing.T) {
p := NewNilChatPanel()
c := NewCommands(nil)
p.SetTranscript("история")
p.Append("строка")
p.Clear()
p.SetCommands(c)
}

View File

@@ -0,0 +1,87 @@
//go:build cgo
package desktop
import (
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/kamelion/ratatoskr-go/internal/ui"
)
// chatLimit — обрезка буфера транскрипта диалога (спец 12.12).
const chatLimit = 200_000
// ChatPanel — Fyne-реализация ui.ChatPanel (спец 12.8, 12.12): транскрипт
// диалога + композитор (поле ввода и кнопки команд).
type ChatPanel struct {
convLbl *widget.Label
input *widget.Entry
commands *ui.Commands
}
// NewChatPanel создаёт ChatPanel.
func NewChatPanel() *ChatPanel {
p := &ChatPanel{}
p.convLbl = widget.NewLabel("Выберите задачу.")
p.convLbl.Wrapping = fyne.TextWrapWord
p.input = widget.NewEntry()
p.input.SetPlaceHolder("Сообщение… (Enter — отправить)")
return p
}
// Transcript возвращает виджет транскрипта (Scroll) для встраивания в окно.
func (p *ChatPanel) Transcript() fyne.CanvasObject {
return container.NewScroll(p.convLbl)
}
// Composer возвращает виджет композитора (ввод + кнопки команд).
func (p *ChatPanel) Composer() fyne.CanvasObject {
cmdBar := container.NewHBox(
widget.NewButton("Новая", func() { if p.commands != nil { p.commands.Start() } }),
widget.NewButton("Создавай", func() { if p.commands != nil { p.commands.Approve() } }),
widget.NewButton("Пропустить", func() { if p.commands != nil { p.commands.Skip() } }),
widget.NewButton("Отмена", func() { if p.commands != nil { p.commands.Cancel() } }),
)
return container.NewBorder(nil, nil, cmdBar, p.input, nil)
}
// SetTranscript заменяет содержимое диалога (спец 12.12).
func (p *ChatPanel) SetTranscript(text string) {
p.convLbl.SetText(text)
}
// Append добавляет строку в диалог (буфер ограничен).
func (p *ChatPanel) Append(text string) {
s := p.convLbl.Text + text + "\n\n"
if len(s) > chatLimit {
s = s[len(s)-chatLimit:]
}
p.convLbl.SetText(s)
}
// Clear очищает диалог.
func (p *ChatPanel) Clear() {
p.convLbl.SetText("")
}
// SetCommands подключает команды к композитору.
func (p *ChatPanel) SetCommands(c *ui.Commands) {
p.commands = c
p.input.OnSubmitted = func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
p.input.SetText("")
if c != nil {
c.SendText(s)
}
}
}
// compile-time проверка реализации контракта.
var _ ui.ChatPanel = (*ChatPanel)(nil)

View File

@@ -24,7 +24,7 @@ import (
)
const (
bufLimit = 200_000 // обрезка буферов панелей окна (conv/state), чтобы не расти бесконечно
bufLimit = 200_000 // обрезка буфера панели «Состояние», чтобы не расти бесконечно
selectedPref = "task.selected"
splitHPref = "split.h"
splitVPref = "split.v"
@@ -45,10 +45,9 @@ type Window struct {
// виджеты
taskList *TaskListPanel
detail *TaskDetailPanel
convLbl *widget.Label
chat *ChatPanel
stateLbl *widget.Label
logs *LogPanel
input *widget.Entry
hsplit *container.Split
vsplit *container.Split
@@ -83,8 +82,8 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn }
func (w *Window) build() {
w.detail = NewTaskDetailPanel()
w.convLbl = widget.NewLabel("Выберите задачу.")
w.convLbl.Wrapping = fyne.TextWrapWord
w.chat = NewChatPanel()
w.chat.SetCommands(w.commands)
w.logs = NewLogPanel()
@@ -105,10 +104,9 @@ func (w *Window) build() {
// Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане).
details := w.detail.Widget()
convScroll := container.NewScroll(w.convLbl)
right := container.NewVSplit(
container.NewScroll(details),
convScroll,
w.chat.Transcript(),
)
w.vsplit = right
@@ -118,25 +116,7 @@ func (w *Window) build() {
container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)),
)
// Ввод + кнопки команд
w.input = widget.NewEntry()
w.input.SetPlaceHolder("Сообщение… (Enter — отправить)")
w.input.OnSubmitted = func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
w.input.SetText("")
w.commands.SendText(s)
}
cmdBar := container.NewHBox(
widget.NewButton("Новая", func() { w.commands.Start() }),
widget.NewButton("Создавай", func() { w.commands.Approve() }),
widget.NewButton("Пропустить", func() { w.commands.Skip() }),
widget.NewButton("Отмена", func() { w.commands.Cancel() }),
)
inputRow := container.NewBorder(nil, nil, cmdBar, w.input, nil)
bottom := container.NewBorder(nil, inputRow, nil, nil, bottomTabs)
bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs)
w.hsplit = container.NewHSplit(left, right)
@@ -170,7 +150,7 @@ func (w *Window) selectTask(id int64) {
t, err := w.store.GetTask(ctx, id)
if err != nil {
w.detail.ShowEmpty()
w.convLbl.SetText("")
w.chat.Clear()
return
}
w.detail.ShowTask(t)
@@ -182,7 +162,7 @@ func (w *Window) selectTask(id int64) {
}
var b strings.Builder
for _, h := range hist {
b.WriteString(formatRole(h.Role))
b.WriteString(ui.FormatRole(h.Role))
b.WriteString(h.Content)
b.WriteString("\n\n")
}
@@ -195,21 +175,12 @@ func (w *Window) selectTask(id int64) {
if b.Len() == 0 {
b.WriteString("Нет сообщений. /start — начать задачу.")
}
w.convLbl.SetText(b.String())
w.chat.SetTranscript(b.String())
// Состояние: сброс к снимку задач.
w.refreshState(ctx, id)
}
func formatRole(role string) string {
switch role {
case "user":
return "👤 "
default:
return "🤖 "
}
}
// refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы).
func (w *Window) refreshState(ctx context.Context, id int64) {
traces, err := w.store.GetTraces(ctx, id)
@@ -242,15 +213,6 @@ func (w *Window) refreshList() {
w.taskList.Select(w.selected)
}
// appendConv добавляет строку в диалог (буфер ограничен).
func (w *Window) appendConv(text string) {
s := w.convLbl.Text + text + "\n\n"
if len(s) > bufLimit {
s = s[len(s)-bufLimit:]
}
w.convLbl.SetText(s)
}
// appendLog добавляет строку в панель «Логи» (делегирует в LogPanel).
func (w *Window) appendLog(text string) {
w.logs.Append(text)
@@ -306,7 +268,7 @@ func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error {
}
text = sb.String()
}
fyne.Do(func() { w.appendConv("🤖 " + text) })
fyne.Do(func() { w.chat.Append("🤖 " + text) })
return nil
}
@@ -316,7 +278,7 @@ func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
for _, opt := range m.Options {
text += "\n" + opt.Label
}
fyne.Do(func() { w.appendConv("❓ " + text) })
fyne.Do(func() { w.chat.Append("❓ " + text) })
return nil
}
@@ -370,7 +332,7 @@ func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) {
func (w *Window) OnHistoryAppended(e events.HistoryAppended) {
fyne.Do(func() {
if w.selected == e.TaskID {
w.appendConv(formatRole(e.Role) + e.Content)
w.chat.Append(ui.FormatRole(e.Role) + e.Content)
}
})
}