mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Video calls (PairUX→ASCII), agent@ chat, and finger routes
ssh video-<code>@ joins a PairUX/LiveKit call rendered as truecolor ASCII (▀ half-blocks, 2 pixels per cell); video@ prompts for a code. Codes are minted by PairUX only. Pipeline: VP8 RTP → PLI keyframe requests → ivfwriter remux → ffmpeg decode/scale → RGB24 → ANSI → bubbletea over the SSH PTY. Subscriber-only, no audio in v1. ssh agent@ opens a persisted chat with the operator's agent — AGENTBBS_AGENT_CMD runs per message (stdin→stdout), e.g. `claude -p`. ssh <member>@ with someone else's name prints a classic finger card (.plan, member since, last seen) and disconnects; your own name still enters the hub. cmd/lkpublish: dev publisher for testing (explicit -fps pacing; lksdk IVF replay mispaces from file timebase alone, measured 1fps from a 15fps file; dimensions required or dynacast pauses the track). Verified end-to-end against livekit-server --dev: 128k truecolor cells / 20k distinct colors streamed over a real SSH session; chat round-trip and finger card verified over SSH. Go toolchain pinned to 1.26 via mise.toml (lksdk requirement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f3b085a08f
commit
9b0f465946
14 changed files with 1397 additions and 46 deletions
52
internal/ascii/ascii.go
Normal file
52
internal/ascii/ascii.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Package ascii converts raw video frames into truecolor terminal art.
|
||||
//
|
||||
// Each character cell renders two vertical pixels using the upper-half block
|
||||
// (▀): foreground colors the top pixel, background the bottom — the same
|
||||
// technique doom-ascii uses. A WxH terminal therefore displays a Wx(2H)
|
||||
// pixel image.
|
||||
package ascii
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FrameRGB renders a packed RGB24 frame (w*h*3 bytes, as ffmpeg's rawvideo
|
||||
// rgb24 emits) sized exactly for the target cell grid: w columns, h*2 rows
|
||||
// of pixels. Rows are joined with \r\n so the output is PTY-safe.
|
||||
func FrameRGB(buf []byte, w, h int) string {
|
||||
if len(buf) < w*h*3 || w <= 0 || h <= 0 {
|
||||
return ""
|
||||
}
|
||||
rows := h / 2
|
||||
var b strings.Builder
|
||||
b.Grow(rows * w * 40)
|
||||
for row := 0; row < rows; row++ {
|
||||
top := row * 2
|
||||
bot := top + 1
|
||||
for x := 0; x < w; x++ {
|
||||
ti := (top*w + x) * 3
|
||||
bi := (bot*w + x) * 3
|
||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀",
|
||||
buf[ti], buf[ti+1], buf[ti+2],
|
||||
buf[bi], buf[bi+1], buf[bi+2])
|
||||
}
|
||||
b.WriteString("\x1b[0m")
|
||||
if row != rows-1 {
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FitEven clamps a terminal geometry to an even pixel height for the
|
||||
// half-block renderer and returns pixel dimensions (pw, ph) for the decoder.
|
||||
func FitEven(cols, rows int) (pw, ph int) {
|
||||
if cols < 8 {
|
||||
cols = 8
|
||||
}
|
||||
if rows < 4 {
|
||||
rows = 4
|
||||
}
|
||||
return cols, (rows - 1) * 2 // leave one status line
|
||||
}
|
||||
213
internal/calls/calls.go
Normal file
213
internal/calls/calls.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// Package calls renders PairUX video calls as truecolor ASCII in the
|
||||
// terminal: `ssh video-<code>@host` joins directly, `ssh video@host` prompts
|
||||
// for a code. Codes are minted by PairUX — the SSH surface never creates
|
||||
// calls, it only joins existing ones.
|
||||
package calls
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/ssh"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/ascii"
|
||||
)
|
||||
|
||||
// RouteCode extracts the call code from an SSH username: "video" → "",
|
||||
// "video-abc123" → "abc123". Second return is false when the username is
|
||||
// not a video route at all.
|
||||
func RouteCode(username string) (string, bool) {
|
||||
u := strings.ToLower(username)
|
||||
if u == "video" {
|
||||
return "", true
|
||||
}
|
||||
if strings.HasPrefix(u, "video-") && len(u) > len("video-") {
|
||||
return u[len("video-"):], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Handle runs the call UI on the session. Standalone surface: leaving the
|
||||
// call ends the SSH session.
|
||||
func Handle(s ssh.Session, code, identity string) error {
|
||||
ptyReq, winCh, hasPty := s.Pty()
|
||||
if !hasPty {
|
||||
_, _ = s.Write([]byte("video calls need a terminal (ssh -t)\r\n"))
|
||||
return nil
|
||||
}
|
||||
w, h := ptyReq.Window.Width, ptyReq.Window.Height
|
||||
if w <= 0 {
|
||||
w = 80
|
||||
}
|
||||
if h <= 0 {
|
||||
h = 24
|
||||
}
|
||||
m := &model{
|
||||
code: code,
|
||||
identity: identity,
|
||||
cfg: ConfigFromEnv(),
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
p := tea.NewProgram(m,
|
||||
tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
|
||||
|
||||
go func() {
|
||||
for w := range winCh {
|
||||
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
|
||||
}
|
||||
}()
|
||||
_, err := p.Run()
|
||||
if m.sess != nil {
|
||||
m.sess.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type frameMsg string
|
||||
type statusMsg string
|
||||
type joinedMsg struct {
|
||||
sess *session
|
||||
err error
|
||||
}
|
||||
|
||||
type model struct {
|
||||
code string
|
||||
identity string
|
||||
cfg Config
|
||||
|
||||
input string // code entry buffer
|
||||
sess *session
|
||||
frame string
|
||||
status string
|
||||
errMsg string
|
||||
|
||||
width, height int
|
||||
pw int // pixel width locked at join time (decoder output width)
|
||||
joining bool
|
||||
}
|
||||
|
||||
var (
|
||||
vTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
|
||||
vDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
vErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
)
|
||||
|
||||
func (m *model) Init() tea.Cmd {
|
||||
if m.code != "" {
|
||||
return m.join()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// join connects in the background and pumps frames/status into the program.
|
||||
func (m *model) join() tea.Cmd {
|
||||
m.joining = true
|
||||
code := m.code
|
||||
pw, ph := ascii.FitEven(m.width, m.height)
|
||||
m.pw = pw // decoder output is locked to this width for the session
|
||||
cfg, id := m.cfg, m.identity
|
||||
return func() tea.Msg {
|
||||
sess, err := join(cfg, code, id, pw, ph)
|
||||
return joinedMsg{sess: sess, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// nextFrame renders one decoded frame at the locked decoder width.
|
||||
func (m *model) nextFrame() tea.Cmd {
|
||||
sess, pw := m.sess, m.pw
|
||||
return func() tea.Msg {
|
||||
buf, ok := <-sess.Frames
|
||||
if !ok {
|
||||
return statusMsg("stream ended")
|
||||
}
|
||||
ph := (len(buf) / 3) / pw
|
||||
return frameMsg(ascii.FrameRGB(buf, pw, ph))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) pump() tea.Cmd {
|
||||
sess := m.sess
|
||||
return tea.Batch(
|
||||
m.nextFrame(),
|
||||
func() tea.Msg { return statusMsg(<-sess.Status) },
|
||||
)
|
||||
}
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
case joinedMsg:
|
||||
m.joining = false
|
||||
if msg.err != nil {
|
||||
m.errMsg = msg.err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.sess = msg.sess
|
||||
return m, m.pump()
|
||||
case frameMsg:
|
||||
m.frame = string(msg)
|
||||
return m, m.nextFrame()
|
||||
case statusMsg:
|
||||
m.status = string(msg)
|
||||
if m.sess != nil {
|
||||
return m, func() tea.Msg { return statusMsg(<-m.sess.Status) }
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
if m.sess == nil && m.code == "" && msg.String() == "q" {
|
||||
break // let people type codes containing q
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Code entry mode.
|
||||
if m.sess == nil && !m.joining && m.code == "" {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if strings.TrimSpace(m.input) != "" {
|
||||
m.code = strings.TrimSpace(strings.ToLower(m.input))
|
||||
return m, m.join()
|
||||
}
|
||||
case "backspace":
|
||||
if len(m.input) > 0 {
|
||||
m.input = m.input[:len(m.input)-1]
|
||||
}
|
||||
default:
|
||||
if len(msg.String()) == 1 && len(m.input) < 64 {
|
||||
m.input += msg.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) View() string {
|
||||
switch {
|
||||
case m.errMsg != "":
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
vTitle.Render("PairUX video") + "\n\n" +
|
||||
vErr.Render(m.errMsg) + "\n\n" +
|
||||
vDim.Render("esc to leave"))
|
||||
case m.sess == nil && !m.joining:
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
vTitle.Render("PairUX video") + "\n\n" +
|
||||
"Enter your call code (from pairux.com):\n\n" +
|
||||
" > " + m.input + "█\n\n" +
|
||||
vDim.Render("enter join · esc leave — don't have a code? create the call in PairUX first"))
|
||||
case m.joining:
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
vTitle.Render("PairUX video") + "\n\n joining " + m.code + "…")
|
||||
case m.frame == "":
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
vTitle.Render("PairUX video") + "\n\n " + m.status + "\n\n" +
|
||||
vDim.Render("esc to leave"))
|
||||
default:
|
||||
return m.frame + "\r\n" + vDim.Render(" "+m.code+" · "+m.status+" · esc to leave")
|
||||
}
|
||||
}
|
||||
223
internal/calls/livekit.go
Normal file
223
internal/calls/livekit.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// LiveKit subscriber: joins a PairUX room, takes the first remote VP8 video
|
||||
// track, remuxes RTP into IVF, and has ffmpeg decode + scale it into raw
|
||||
// RGB24 frames sized for the terminal grid.
|
||||
package calls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
lksdk "github.com/livekit/server-sdk-go/v2"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfwriter"
|
||||
)
|
||||
|
||||
// Config is the LiveKit deployment shared with PairUX.
|
||||
type Config struct {
|
||||
URL, Key, Secret string
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads AGENTBBS_LIVEKIT_* with LIVEKIT_* fallbacks, matching
|
||||
// PairUX's env shape (LIVEKIT_API_KEY / LIVEKIT_API_SECRET).
|
||||
func ConfigFromEnv() Config {
|
||||
pick := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return Config{
|
||||
URL: pick("AGENTBBS_LIVEKIT_URL", "LIVEKIT_URL", "NEXT_PUBLIC_LIVEKIT_URL"),
|
||||
Key: pick("AGENTBBS_LIVEKIT_KEY", "LIVEKIT_API_KEY"),
|
||||
Secret: pick("AGENTBBS_LIVEKIT_SECRET", "LIVEKIT_API_SECRET"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) ok() bool { return c.URL != "" && c.Key != "" && c.Secret != "" }
|
||||
|
||||
// session is one live subscription: frames arrive on Frames sized pw*ph*3.
|
||||
type session struct {
|
||||
Frames chan []byte
|
||||
Status chan string
|
||||
room *lksdk.Room
|
||||
ffmpeg *exec.Cmd
|
||||
ivf io.WriteCloser
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// join connects to room `code` as a hidden-ish subscriber and starts the
|
||||
// decode pipeline targeting pw x ph pixels.
|
||||
func join(cfg Config, code, identity string, pw, ph int) (*session, error) {
|
||||
if !cfg.ok() {
|
||||
return nil, fmt.Errorf("video calls are not configured on this host (LIVEKIT_URL/API_KEY/API_SECRET)")
|
||||
}
|
||||
|
||||
s := &session{
|
||||
Frames: make(chan []byte, 2),
|
||||
Status: make(chan string, 8),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// ffmpeg: IVF (VP8) on stdin → raw RGB24 frames on stdout.
|
||||
ffin, ivfW := io.Pipe()
|
||||
s.ivf = ivfW
|
||||
if dump := os.Getenv("AGENTBBS_VIDEO_DEBUG"); dump != "" {
|
||||
if f, err := os.Create(dump); err == nil {
|
||||
s.ivf = teeWriteCloser{io.MultiWriter(ivfW, f), ivfW, f}
|
||||
}
|
||||
}
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-probesize", "32", "-analyzeduration", "0",
|
||||
"-fflags", "nobuffer", "-flags", "low_delay",
|
||||
"-f", "ivf", "-i", "pipe:0",
|
||||
"-vf", fmt.Sprintf("scale=%d:%d", pw, ph),
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1",
|
||||
)
|
||||
cmd.Stdin = ffin
|
||||
cmd.Stderr = os.Stderr // surfaces decode errors in the server log
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg: %w", err)
|
||||
}
|
||||
s.ffmpeg = cmd
|
||||
|
||||
go func() { // frame pump: drop frames rather than lag
|
||||
size := pw * ph * 3
|
||||
for {
|
||||
buf := make([]byte, size)
|
||||
if _, err := io.ReadFull(out, buf); err != nil {
|
||||
close(s.Frames)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.Frames <- buf:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
gotTrack := false
|
||||
cb := &lksdk.RoomCallback{
|
||||
ParticipantCallback: lksdk.ParticipantCallback{
|
||||
OnTrackSubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) {
|
||||
if gotTrack || track.Kind() != webrtc.RTPCodecTypeVideo {
|
||||
return
|
||||
}
|
||||
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||
s.status("skipping non-VP8 track from " + rp.Identity())
|
||||
return
|
||||
}
|
||||
gotTrack = true
|
||||
s.status("video from " + rp.Identity())
|
||||
// PLI is what makes the SFU start forwarding from a
|
||||
// keyframe; without it a fresh subscriber starves.
|
||||
rp.WritePLI(track.SSRC())
|
||||
go s.keyframeTicker(rp, track.SSRC())
|
||||
go s.consume(track)
|
||||
},
|
||||
},
|
||||
OnDisconnected: func() { s.status("disconnected") },
|
||||
}
|
||||
|
||||
room, err := lksdk.ConnectToRoom(cfg.URL, lksdk.ConnectInfo{
|
||||
APIKey: cfg.Key,
|
||||
APISecret: cfg.Secret,
|
||||
RoomName: code,
|
||||
ParticipantIdentity: identity,
|
||||
}, cb)
|
||||
if err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("livekit: %w", err)
|
||||
}
|
||||
s.room = room
|
||||
s.status("joined " + code + " — waiting for video…")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// keyframeTicker re-requests keyframes so late joins and packet loss recover
|
||||
// quickly; a periodic full refresh is cheap at terminal resolutions.
|
||||
func (s *session) keyframeTicker(rp *lksdk.RemoteParticipant, ssrc webrtc.SSRC) {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
rp.WritePLI(ssrc)
|
||||
}
|
||||
}
|
||||
|
||||
// consume remuxes the track's RTP into the IVF pipe until it ends.
|
||||
func (s *session) consume(track *webrtc.TrackRemote) {
|
||||
w, err := ivfwriter.NewWith(s.ivf)
|
||||
if err != nil {
|
||||
s.status("ivf: " + err.Error())
|
||||
return
|
||||
}
|
||||
n := 0
|
||||
for {
|
||||
pkt, _, err := track.ReadRTP()
|
||||
if err != nil {
|
||||
s.status(fmt.Sprintf("track ended after %d packets: %v", n, err))
|
||||
_ = w.Close()
|
||||
return
|
||||
}
|
||||
if err := w.WriteRTP(pkt); err != nil {
|
||||
s.status("ivf write: " + err.Error())
|
||||
return
|
||||
}
|
||||
n++
|
||||
if n == 1 || n%500 == 0 {
|
||||
s.status(fmt.Sprintf("receiving (%d rtp packets)", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) status(msg string) {
|
||||
select {
|
||||
case s.Status <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// teeWriteCloser mirrors the IVF stream to a debug file (AGENTBBS_VIDEO_DEBUG).
|
||||
type teeWriteCloser struct {
|
||||
io.Writer
|
||||
pipe io.WriteCloser
|
||||
file io.Closer
|
||||
}
|
||||
|
||||
func (t teeWriteCloser) Close() error {
|
||||
_ = t.file.Close()
|
||||
return t.pipe.Close()
|
||||
}
|
||||
|
||||
// Close tears the whole pipeline down.
|
||||
func (s *session) Close() {
|
||||
select {
|
||||
case <-s.done:
|
||||
default:
|
||||
close(s.done)
|
||||
}
|
||||
if s.room != nil {
|
||||
s.room.Disconnect()
|
||||
}
|
||||
if s.ivf != nil {
|
||||
_ = s.ivf.Close()
|
||||
}
|
||||
if s.ffmpeg != nil && s.ffmpeg.Process != nil {
|
||||
_ = s.ffmpeg.Process.Kill()
|
||||
_ = s.ffmpeg.Wait()
|
||||
}
|
||||
}
|
||||
177
internal/chat/chat.go
Normal file
177
internal/chat/chat.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// Package chat is the agent@ surface: talk to the operator's AI agent (or,
|
||||
// once the M2 admin console lands, the operator live).
|
||||
//
|
||||
// The agent backend is one configurable command, AGENTBBS_AGENT_CMD: each
|
||||
// user message is piped to its stdin, stdout comes back as the reply. Point
|
||||
// it at anything — `claude -p`, a logicsrc/commandboard agent, a shell
|
||||
// script. Unset = a polite "leave a message" mode (messages are persisted
|
||||
// either way, so the operator can read them later).
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/ssh"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
const historyLines = 200
|
||||
|
||||
// Handle runs the chat UI; leaving ends the SSH session.
|
||||
func Handle(s ssh.Session, st store.Store, user auth.User) error {
|
||||
ptyReq, winCh, hasPty := s.Pty()
|
||||
if !hasPty {
|
||||
_, _ = s.Write([]byte("agent chat needs a terminal (ssh -t)\r\n"))
|
||||
return nil
|
||||
}
|
||||
m := &model{
|
||||
st: st,
|
||||
user: user,
|
||||
width: ptyReq.Window.Width,
|
||||
height: ptyReq.Window.Height,
|
||||
}
|
||||
if msgs, err := st.RecentChats(user.Name, 20); err == nil {
|
||||
for _, c := range msgs {
|
||||
m.lines = append(m.lines, render(c.Role, c.Text))
|
||||
}
|
||||
if len(msgs) > 0 {
|
||||
m.lines = append(m.lines, cDim.Render("— earlier conversation —"))
|
||||
}
|
||||
}
|
||||
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
|
||||
go func() {
|
||||
for w := range winCh {
|
||||
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
|
||||
}
|
||||
}()
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
cTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
|
||||
cYou = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
cAgent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c084fc"))
|
||||
cDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
)
|
||||
|
||||
func render(role, text string) string {
|
||||
if role == "user" {
|
||||
return cYou.Render("you ") + text
|
||||
}
|
||||
return cAgent.Render("agent ") + text
|
||||
}
|
||||
|
||||
type replyMsg struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
type model struct {
|
||||
st store.Store
|
||||
user auth.User
|
||||
|
||||
lines []string
|
||||
input string
|
||||
waiting bool
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd { return nil }
|
||||
|
||||
// ask pipes the message to the configured agent command.
|
||||
func (m *model) ask(text string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cmdline := strings.TrimSpace(os.Getenv("AGENTBBS_AGENT_CMD"))
|
||||
if cmdline == "" {
|
||||
return replyMsg{text: "The operator's agent isn't wired up right now — your message is saved and a human will read it."}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
parts := strings.Fields(cmdline)
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return replyMsg{err: err}
|
||||
}
|
||||
reply := strings.TrimSpace(string(out))
|
||||
if reply == "" {
|
||||
reply = "(no reply)"
|
||||
}
|
||||
return replyMsg{text: reply}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
case replyMsg:
|
||||
m.waiting = false
|
||||
if msg.err != nil {
|
||||
m.lines = append(m.lines, cErr.Render("agent error: "+msg.err.Error()))
|
||||
return m, nil
|
||||
}
|
||||
_ = m.st.AddChat(m.user.StoreID, m.user.Name, "agent", msg.text)
|
||||
for _, l := range strings.Split(msg.text, "\n") {
|
||||
m.lines = append(m.lines, render("agent", l))
|
||||
}
|
||||
if len(m.lines) > historyLines {
|
||||
m.lines = m.lines[len(m.lines)-historyLines:]
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
text := strings.TrimSpace(m.input)
|
||||
if text == "" || m.waiting {
|
||||
return m, nil
|
||||
}
|
||||
m.input = ""
|
||||
m.waiting = true
|
||||
_ = m.st.AddChat(m.user.StoreID, m.user.Name, "user", text)
|
||||
m.lines = append(m.lines, render("user", text))
|
||||
return m, m.ask(text)
|
||||
case "backspace":
|
||||
if len(m.input) > 0 {
|
||||
m.input = m.input[:len(m.input)-1]
|
||||
}
|
||||
default:
|
||||
if msg.Type == tea.KeyRunes || msg.String() == " " {
|
||||
m.input += string(msg.Runes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) View() string {
|
||||
rows := m.height - 4
|
||||
if rows < 3 {
|
||||
rows = 3
|
||||
}
|
||||
start := 0
|
||||
if len(m.lines) > rows {
|
||||
start = len(m.lines) - rows
|
||||
}
|
||||
body := strings.Join(m.lines[start:], "\n")
|
||||
prompt := "> " + m.input + "█"
|
||||
if m.waiting {
|
||||
prompt = cDim.Render("agent is thinking…")
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(
|
||||
cTitle.Render("agent@ — talk to profullstack") + cDim.Render(" (esc to leave)") + "\n" +
|
||||
body + "\n\n" + prompt)
|
||||
}
|
||||
|
|
@ -35,6 +35,10 @@ type Store interface {
|
|||
EnsureUser(name, kind, pubkeyFP string) (User, error)
|
||||
// UserByFingerprint finds an account by SSH key fingerprint.
|
||||
UserByFingerprint(fp string) (User, bool, error)
|
||||
// UserByName finds an account by exact username (no creation).
|
||||
UserByName(name string) (User, bool, error)
|
||||
// LastSeen reports the start of the user's most recent session.
|
||||
LastSeen(userID int64) (time.Time, bool, error)
|
||||
|
||||
RecordSession(userID int64, username, remote, route string) (int64, error)
|
||||
EndSession(sessionID int64) error
|
||||
|
|
@ -46,9 +50,20 @@ type Store interface {
|
|||
PodPaidUntil(userID int64) (time.Time, bool, error)
|
||||
GrantPod(userID int64, until time.Time, paymentRef string) error
|
||||
|
||||
// Chat transcripts for the agent@ surface.
|
||||
AddChat(userID int64, username, role, text string) error
|
||||
RecentChats(username string, n int) ([]ChatMessage, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ChatMessage is one line of an agent@ conversation.
|
||||
type ChatMessage struct {
|
||||
Role string // "user" or "agent"
|
||||
Text string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// ErrKeyMismatch means a username is already registered with another key.
|
||||
var ErrKeyMismatch = errors.New("username registered with a different key")
|
||||
|
||||
|
|
@ -92,6 +107,15 @@ CREATE TABLE IF NOT EXISTS scores (
|
|||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_messages(username, id);
|
||||
CREATE TABLE IF NOT EXISTS pod_subscriptions (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
||||
paid_until TEXT NOT NULL,
|
||||
|
|
@ -211,4 +235,65 @@ func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UserByName(name string) (User, bool, error) {
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) LastSeen(userID int64) (time.Time, bool, error) {
|
||||
var at string
|
||||
err := s.db.QueryRow(`SELECT started_at FROM sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1`, userID).Scan(&at)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, at)
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) AddChat(userID int64, username, role, text string) error {
|
||||
var uid any
|
||||
if userID > 0 {
|
||||
uid = userID
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO chat_messages (user_id, username, role, text) VALUES (?,?,?,?)`,
|
||||
uid, username, role, text)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT role, text, created_at FROM (
|
||||
SELECT id, role, text, created_at FROM chat_messages
|
||||
WHERE username = ? ORDER BY id DESC LIMIT ?
|
||||
) ORDER BY id ASC`, username, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ChatMessage
|
||||
for rows.Next() {
|
||||
var m ChatMessage
|
||||
var at string
|
||||
if err := rows.Scan(&m.Role, &m.Text, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue