feat(ui): каждая вкладка chat — независимая LLM-сессия
Some checks failed
CI / test (pull_request) Failing after 1m20s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m2s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s

- 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:
ki.sagidullin
2026-08-21 10:33:05 +05:00
parent de21ec9f31
commit 9420b276e9
6 changed files with 356 additions and 64 deletions

View File

@@ -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 != "" {