1
0
Fork 0
forked from lthn/LEM

feat: scaffold LEM Desktop app (Wails v3 system tray + Docker stack)

Inspired by BugSETI architecture — system tray with WebView2 windows,
Docker Compose stack (Forgejo + InfluxDB + inference proxy), and
scoring agent integration. Builds as signed native binary on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-15 17:43:19 +00:00
parent 9fac5749c2
commit 774f097855
No known key found for this signature in database
GPG key ID: AF404715446AEB41
14 changed files with 2021 additions and 0 deletions

View file

@ -0,0 +1,122 @@
package main
import (
"context"
"log"
"sync"
"forge.lthn.ai/lthn/lem/pkg/lem"
"github.com/wailsapp/wails/v3/pkg/application"
)
// AgentRunner wraps the scoring agent for desktop use.
// Provides start/stop/status for the tray and dashboard.
type AgentRunner struct {
apiURL string
influxURL string
influxDB string
m3Host string
baseModel string
workDir string
mu sync.RWMutex
running bool
task string
cancel context.CancelFunc
}
// NewAgentRunner creates an AgentRunner.
func NewAgentRunner(apiURL, influxURL, influxDB, m3Host, baseModel, workDir string) *AgentRunner {
return &AgentRunner{
apiURL: apiURL,
influxURL: influxURL,
influxDB: influxDB,
m3Host: m3Host,
baseModel: baseModel,
workDir: workDir,
}
}
// ServiceName returns the Wails service name.
func (a *AgentRunner) ServiceName() string {
return "AgentRunner"
}
// ServiceStartup is called when the Wails app starts.
func (a *AgentRunner) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("AgentRunner started")
return nil
}
// IsRunning returns whether the agent is currently running.
func (a *AgentRunner) IsRunning() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.running
}
// CurrentTask returns the current task description.
func (a *AgentRunner) CurrentTask() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.task
}
// Start begins the scoring agent in a background goroutine.
func (a *AgentRunner) Start() error {
a.mu.Lock()
if a.running {
a.mu.Unlock()
return nil
}
ctx, cancel := context.WithCancel(context.Background())
a.cancel = cancel
a.running = true
a.task = "Starting..."
a.mu.Unlock()
go func() {
defer func() {
a.mu.Lock()
a.running = false
a.task = ""
a.cancel = nil
a.mu.Unlock()
}()
log.Println("Scoring agent started via desktop")
// Use the same RunAgent function from pkg/lem.
// Build args matching the CLI flags.
args := []string{
"--api-url", a.apiURL,
"--influx", a.influxURL,
"--influx-db", a.influxDB,
"--m3-host", a.m3Host,
"--base-model", a.baseModel,
"--work-dir", a.workDir,
}
// Run in the background — RunAgent blocks until cancelled.
// We use a goroutine-safe wrapper here.
_ = ctx // Agent doesn't support context cancellation yet.
_ = args
lem.RunAgent(args)
}()
return nil
}
// Stop stops the scoring agent.
func (a *AgentRunner) Stop() {
a.mu.Lock()
defer a.mu.Unlock()
if a.cancel != nil {
a.cancel()
}
a.running = false
a.task = ""
log.Println("Scoring agent stopped via desktop")
}

View file

@ -0,0 +1,299 @@
package main
import (
"context"
"fmt"
"log"
"sync"
"time"
"forge.lthn.ai/lthn/lem/pkg/lem"
"github.com/wailsapp/wails/v3/pkg/application"
)
// DashboardService bridges pkg/lem CLI functions for the desktop UI.
// Provides real-time status, model inventory, and scoring progress
// to the frontend via Wails bindings.
type DashboardService struct {
influx *lem.InfluxClient
dbPath string
mu sync.RWMutex
// Cached state (refreshed periodically).
trainingStatus []TrainingRow
generationStats GenerationStats
modelInventory []ModelInfo
lastRefresh time.Time
}
// TrainingRow represents a single model's training progress.
type TrainingRow struct {
Model string `json:"model"`
RunID string `json:"runId"`
Status string `json:"status"`
Iteration int `json:"iteration"`
TotalIters int `json:"totalIters"`
Pct float64 `json:"pct"`
Loss float64 `json:"loss"`
}
// GenerationStats shows golden set and expansion progress.
type GenerationStats struct {
GoldenCompleted int `json:"goldenCompleted"`
GoldenTarget int `json:"goldenTarget"`
GoldenPct float64 `json:"goldenPct"`
ExpansionCompleted int `json:"expansionCompleted"`
ExpansionTarget int `json:"expansionTarget"`
ExpansionPct float64 `json:"expansionPct"`
}
// ModelInfo represents a model in the inventory.
type ModelInfo struct {
Name string `json:"name"`
Tag string `json:"tag"`
Accuracy float64 `json:"accuracy"`
Iterations int `json:"iterations"`
Status string `json:"status"`
}
// AgentStatus represents the scoring agent's current state.
type AgentStatus struct {
Running bool `json:"running"`
CurrentTask string `json:"currentTask"`
Scored int `json:"scored"`
Remaining int `json:"remaining"`
LastScore string `json:"lastScore"`
}
// DashboardSnapshot is the complete UI state sent to the frontend.
type DashboardSnapshot struct {
Training []TrainingRow `json:"training"`
Generation GenerationStats `json:"generation"`
Models []ModelInfo `json:"models"`
Agent AgentStatus `json:"agent"`
DBPath string `json:"dbPath"`
UpdatedAt string `json:"updatedAt"`
}
// NewDashboardService creates a DashboardService.
func NewDashboardService(influxURL, influxDB, dbPath string) *DashboardService {
return &DashboardService{
influx: lem.NewInfluxClient(influxURL, influxDB),
dbPath: dbPath,
}
}
// ServiceName returns the Wails service name.
func (d *DashboardService) ServiceName() string {
return "DashboardService"
}
// ServiceStartup is called when the Wails app starts.
func (d *DashboardService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("DashboardService started")
go d.refreshLoop(ctx)
return nil
}
// GetSnapshot returns the complete dashboard state.
func (d *DashboardService) GetSnapshot() DashboardSnapshot {
d.mu.RLock()
defer d.mu.RUnlock()
return DashboardSnapshot{
Training: d.trainingStatus,
Generation: d.generationStats,
Models: d.modelInventory,
DBPath: d.dbPath,
UpdatedAt: d.lastRefresh.Format(time.RFC3339),
}
}
// GetTraining returns current training status.
func (d *DashboardService) GetTraining() []TrainingRow {
d.mu.RLock()
defer d.mu.RUnlock()
return d.trainingStatus
}
// GetGeneration returns generation progress.
func (d *DashboardService) GetGeneration() GenerationStats {
d.mu.RLock()
defer d.mu.RUnlock()
return d.generationStats
}
// GetModels returns the model inventory.
func (d *DashboardService) GetModels() []ModelInfo {
d.mu.RLock()
defer d.mu.RUnlock()
return d.modelInventory
}
// Refresh forces an immediate data refresh.
func (d *DashboardService) Refresh() error {
return d.refresh()
}
// RunQuery executes an ad-hoc SQL query against DuckDB.
func (d *DashboardService) RunQuery(sql string) ([]map[string]interface{}, error) {
if d.dbPath == "" {
return nil, fmt.Errorf("no database configured")
}
db, err := lem.OpenDB(d.dbPath)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
defer db.Close()
rows, err := db.QueryRows(sql)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
return rows, nil
}
func (d *DashboardService) refreshLoop(ctx context.Context) {
// Initial refresh.
if err := d.refresh(); err != nil {
log.Printf("Dashboard refresh error: %v", err)
}
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := d.refresh(); err != nil {
log.Printf("Dashboard refresh error: %v", err)
}
}
}
}
func (d *DashboardService) refresh() error {
d.mu.Lock()
defer d.mu.Unlock()
// Query training status from InfluxDB.
rows, err := d.influx.QuerySQL(`
SELECT model, run_id, status, iteration, total_iters, pct
FROM training_status
ORDER BY time DESC LIMIT 10
`)
if err == nil {
d.trainingStatus = nil
for _, row := range rows {
d.trainingStatus = append(d.trainingStatus, TrainingRow{
Model: strVal(row, "model"),
RunID: strVal(row, "run_id"),
Status: strVal(row, "status"),
Iteration: intVal(row, "iteration"),
TotalIters: intVal(row, "total_iters"),
Pct: floatVal(row, "pct"),
})
}
}
// Query latest loss per model.
lossRows, err := d.influx.QuerySQL(`
SELECT model, loss FROM training_loss
WHERE loss_type = 'train'
ORDER BY time DESC LIMIT 10
`)
if err == nil {
lossMap := make(map[string]float64)
for _, row := range lossRows {
model := strVal(row, "model")
if _, exists := lossMap[model]; !exists {
lossMap[model] = floatVal(row, "loss")
}
}
for i, t := range d.trainingStatus {
if loss, ok := lossMap[t.Model]; ok {
d.trainingStatus[i].Loss = loss
}
}
}
// Query golden set progress.
goldenRows, err := d.influx.QuerySQL(`
SELECT completed, target, pct FROM golden_gen_progress
ORDER BY time DESC LIMIT 1
`)
if err == nil && len(goldenRows) > 0 {
d.generationStats.GoldenCompleted = intVal(goldenRows[0], "completed")
d.generationStats.GoldenTarget = intVal(goldenRows[0], "target")
d.generationStats.GoldenPct = floatVal(goldenRows[0], "pct")
}
// Query expansion progress.
expRows, err := d.influx.QuerySQL(`
SELECT completed, target, pct FROM expansion_progress
ORDER BY time DESC LIMIT 1
`)
if err == nil && len(expRows) > 0 {
d.generationStats.ExpansionCompleted = intVal(expRows[0], "completed")
d.generationStats.ExpansionTarget = intVal(expRows[0], "target")
d.generationStats.ExpansionPct = floatVal(expRows[0], "pct")
}
// Query model capability scores.
capRows, err := d.influx.QuerySQL(`
SELECT model, label, accuracy, iteration FROM capability_score
WHERE category = 'overall'
ORDER BY time DESC LIMIT 20
`)
if err == nil {
d.modelInventory = nil
seen := make(map[string]bool)
for _, row := range capRows {
label := strVal(row, "label")
if seen[label] {
continue
}
seen[label] = true
d.modelInventory = append(d.modelInventory, ModelInfo{
Name: strVal(row, "model"),
Tag: label,
Accuracy: floatVal(row, "accuracy"),
Iterations: intVal(row, "iteration"),
Status: "scored",
})
}
}
d.lastRefresh = time.Now()
return nil
}
func strVal(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
return fmt.Sprintf("%v", v)
}
return ""
}
func intVal(m map[string]interface{}, key string) int {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
return 0
}
func floatVal(m map[string]interface{}, key string) float64 {
if v, ok := m[key]; ok {
if f, ok := v.(float64); ok {
return f
}
}
return 0
}

226
cmd/lem-desktop/docker.go Normal file
View file

@ -0,0 +1,226 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
// DockerService manages the LEM Docker compose stack.
// Provides start/stop/status for Forgejo, InfluxDB, and inference services.
type DockerService struct {
composeFile string
mu sync.RWMutex
services map[string]ContainerStatus
}
// ContainerStatus represents a Docker container's state.
type ContainerStatus struct {
Name string `json:"name"`
Image string `json:"image"`
Status string `json:"status"`
Health string `json:"health"`
Ports string `json:"ports"`
Running bool `json:"running"`
}
// StackStatus represents the overall stack state.
type StackStatus struct {
Running bool `json:"running"`
Services map[string]ContainerStatus `json:"services"`
ComposeDir string `json:"composeDir"`
}
// NewDockerService creates a DockerService.
// composeDir should point to the deploy/ directory containing docker-compose.yml.
func NewDockerService(composeDir string) *DockerService {
return &DockerService{
composeFile: filepath.Join(composeDir, "docker-compose.yml"),
services: make(map[string]ContainerStatus),
}
}
// ServiceName returns the Wails service name.
func (d *DockerService) ServiceName() string {
return "DockerService"
}
// ServiceStartup is called when the Wails app starts.
func (d *DockerService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("DockerService started")
go d.statusLoop(ctx)
return nil
}
// Start brings up the full Docker compose stack.
func (d *DockerService) Start() error {
log.Println("Starting LEM stack...")
return d.compose("up", "-d")
}
// Stop takes down the Docker compose stack.
func (d *DockerService) Stop() error {
log.Println("Stopping LEM stack...")
return d.compose("down")
}
// Restart restarts the full stack.
func (d *DockerService) Restart() error {
if err := d.Stop(); err != nil {
return err
}
return d.Start()
}
// StartService starts a single service.
func (d *DockerService) StartService(name string) error {
return d.compose("up", "-d", name)
}
// StopService stops a single service.
func (d *DockerService) StopService(name string) error {
return d.compose("stop", name)
}
// RestartService restarts a single service.
func (d *DockerService) RestartService(name string) error {
return d.compose("restart", name)
}
// Logs returns recent logs for a service.
func (d *DockerService) Logs(name string, lines int) (string, error) {
if lines <= 0 {
lines = 50
}
out, err := d.composeOutput("logs", "--tail", fmt.Sprintf("%d", lines), "--no-color", name)
if err != nil {
return "", err
}
return out, nil
}
// GetStatus returns the current stack status.
func (d *DockerService) GetStatus() StackStatus {
d.mu.RLock()
defer d.mu.RUnlock()
running := false
for _, s := range d.services {
if s.Running {
running = true
break
}
}
return StackStatus{
Running: running,
Services: d.services,
ComposeDir: filepath.Dir(d.composeFile),
}
}
// IsRunning returns whether any services are running.
func (d *DockerService) IsRunning() bool {
d.mu.RLock()
defer d.mu.RUnlock()
for _, s := range d.services {
if s.Running {
return true
}
}
return false
}
// Pull pulls latest images for all services.
func (d *DockerService) Pull() error {
return d.compose("pull")
}
func (d *DockerService) compose(args ...string) error {
fullArgs := append([]string{"compose", "-f", d.composeFile}, args...)
cmd := exec.Command("docker", fullArgs...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker compose %s: %w: %s", strings.Join(args, " "), err, string(out))
}
return nil
}
func (d *DockerService) composeOutput(args ...string) (string, error) {
fullArgs := append([]string{"compose", "-f", d.composeFile}, args...)
cmd := exec.Command("docker", fullArgs...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker compose %s: %w: %s", strings.Join(args, " "), err, string(out))
}
return string(out), nil
}
func (d *DockerService) refreshStatus() {
out, err := d.composeOutput("ps", "--format", "json")
if err != nil {
return
}
d.mu.Lock()
defer d.mu.Unlock()
d.services = make(map[string]ContainerStatus)
// docker compose ps --format json outputs one JSON object per line.
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" {
continue
}
var container struct {
Name string `json:"Name"`
Image string `json:"Image"`
Service string `json:"Service"`
Status string `json:"Status"`
Health string `json:"Health"`
State string `json:"State"`
Ports string `json:"Ports"`
}
if err := json.Unmarshal([]byte(line), &container); err != nil {
continue
}
name := container.Service
if name == "" {
name = container.Name
}
d.services[name] = ContainerStatus{
Name: container.Name,
Image: container.Image,
Status: container.Status,
Health: container.Health,
Ports: container.Ports,
Running: container.State == "running",
}
}
}
func (d *DockerService) statusLoop(ctx context.Context) {
d.refreshStatus()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.refreshStatus()
}
}
}

View file

@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEM Dashboard</title>
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-card: #334155;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent: #3b82f6;
--accent-green: #22c55e;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
--border: #475569;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
--wails-draggable: drag;
}
.header h1 { font-size: 18px; font-weight: 600; }
.header .status { font-size: 13px; color: var(--text-secondary); }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
padding: 24px;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.card h2 {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 12px;
}
.card.full-width { grid-column: 1 / -1; }
.progress-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.progress-label {
min-width: 120px;
font-size: 13px;
font-weight: 500;
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--bg-card);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
}
.progress-fill.green { background: var(--accent-green); }
.progress-fill.blue { background: var(--accent); }
.progress-fill.amber { background: var(--accent-amber); }
.progress-value {
font-size: 12px;
color: var(--text-secondary);
min-width: 60px;
text-align: right;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 6px 8px;
color: var(--text-secondary);
font-weight: 500;
border-bottom: 1px solid var(--border);
}
td {
padding: 6px 8px;
border-bottom: 1px solid rgba(71, 85, 105, 0.3);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-green { background: rgba(34, 197, 94, 0.2); color: var(--accent-green); }
.badge-amber { background: rgba(245, 158, 11, 0.2); color: var(--accent-amber); }
.badge-red { background: rgba(239, 68, 68, 0.2); color: var(--accent-red); }
.badge-blue { background: rgba(59, 130, 246, 0.2); color: var(--accent); }
.controls {
display: flex;
gap: 8px;
margin-top: 12px;
}
button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: var(--border); }
button.primary { background: var(--accent); border-color: var(--accent); }
button.primary:hover { background: #2563eb; }
button.danger { background: var(--accent-red); border-color: var(--accent-red); }
button.danger:hover { background: #dc2626; }
.service-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.service-item {
background: var(--bg-card);
border-radius: 6px;
padding: 10px;
}
.service-item .name {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
}
.service-item .detail {
font-size: 11px;
color: var(--text-secondary);
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.dot-green { background: var(--accent-green); }
.dot-red { background: var(--accent-red); }
.dot-amber { background: var(--accent-amber); }
.empty-state {
text-align: center;
padding: 24px;
color: var(--text-secondary);
font-size: 13px;
}
.footer {
padding: 12px 24px;
font-size: 11px;
color: var(--text-secondary);
text-align: center;
border-top: 1px solid var(--border);
}
</style>
</head>
<body>
<div class="header">
<h1>LEM Dashboard</h1>
<span class="status" id="statusText">Connecting...</span>
</div>
<div class="grid">
<!-- Training Progress -->
<div class="card">
<h2>Training Progress</h2>
<div id="trainingList"></div>
</div>
<!-- Generation Progress -->
<div class="card">
<h2>Generation</h2>
<div id="generationList"></div>
</div>
<!-- Model Scoreboard -->
<div class="card full-width">
<h2>Model Scoreboard</h2>
<div id="scoreboardContainer"></div>
</div>
<!-- Docker Services -->
<div class="card">
<h2>Services</h2>
<div id="serviceGrid" class="service-grid"></div>
<div class="controls">
<button id="btnStack" class="primary" onclick="toggleStack()">Start Services</button>
<button onclick="refreshAll()">Refresh</button>
</div>
</div>
<!-- Scoring Agent -->
<div class="card">
<h2>Scoring Agent</h2>
<div id="agentStatus"></div>
<div class="controls">
<button id="btnAgent" class="primary" onclick="toggleAgent()">Start Agent</button>
</div>
</div>
</div>
<div class="footer" id="footerText">LEM v0.1.0</div>
<script>
// Safe DOM helpers — no innerHTML.
function el(tag, attrs, children) {
var e = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function(k) {
if (k === 'className') e.className = attrs[k];
else if (k === 'textContent') e.textContent = attrs[k];
else e.setAttribute(k, attrs[k]);
});
}
if (children) {
children.forEach(function(c) {
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
else if (c) e.appendChild(c);
});
}
return e;
}
function clear(id) {
var container = document.getElementById(id);
while (container.firstChild) container.removeChild(container.firstChild);
return container;
}
function makeProgressRow(label, pct, value, colorClass) {
var row = el('div', {className: 'progress-row'});
row.appendChild(el('span', {className: 'progress-label', textContent: label}));
var bar = el('div', {className: 'progress-bar'});
var fill = el('div', {className: 'progress-fill ' + (colorClass || 'blue')});
fill.style.width = Math.min(100, pct).toFixed(1) + '%';
bar.appendChild(fill);
row.appendChild(bar);
row.appendChild(el('span', {className: 'progress-value', textContent: value}));
return row;
}
function makeBadge(text, colorClass) {
return el('span', {className: 'badge ' + colorClass, textContent: text});
}
function makeDot(colorClass) {
return el('span', {className: 'dot ' + colorClass});
}
// Render functions.
function renderTraining(training) {
var container = clear('trainingList');
if (!training || training.length === 0) {
container.appendChild(el('div', {className: 'empty-state', textContent: 'No training data'}));
return;
}
training.forEach(function(t) {
var pct = t.totalIters > 0 ? (t.iteration / t.totalIters * 100) : 0;
var value = t.iteration + '/' + t.totalIters;
if (t.loss > 0) value += ' loss=' + t.loss.toFixed(3);
var color = t.status === 'complete' ? 'green' : t.status === 'training' ? 'blue' : 'amber';
container.appendChild(makeProgressRow(t.model, pct, value, color));
});
}
function renderGeneration(gen) {
var container = clear('generationList');
if (!gen) {
container.appendChild(el('div', {className: 'empty-state', textContent: 'No generation data'}));
return;
}
container.appendChild(makeProgressRow(
'Golden Set',
gen.goldenPct || 0,
(gen.goldenCompleted || 0) + '/' + (gen.goldenTarget || 0),
'green'
));
container.appendChild(makeProgressRow(
'Expansion',
gen.expansionPct || 0,
(gen.expansionCompleted || 0) + '/' + (gen.expansionTarget || 0),
'blue'
));
}
function renderScoreboard(models) {
var container = clear('scoreboardContainer');
if (!models || models.length === 0) {
container.appendChild(el('div', {className: 'empty-state', textContent: 'No scored models yet'}));
return;
}
var table = el('table');
var thead = el('thead');
var headerRow = el('tr');
['Model', 'Tag', 'Accuracy', 'Iterations', 'Status'].forEach(function(h) {
headerRow.appendChild(el('th', {textContent: h}));
});
thead.appendChild(headerRow);
table.appendChild(thead);
var tbody = el('tbody');
models.forEach(function(m) {
var row = el('tr');
row.appendChild(el('td', {textContent: m.name}));
row.appendChild(el('td', {textContent: m.tag}));
var accTd = el('td');
var accPct = (m.accuracy * 100).toFixed(1) + '%';
var accColor = m.accuracy >= 0.8 ? 'badge-green' : m.accuracy >= 0.5 ? 'badge-amber' : 'badge-red';
accTd.appendChild(makeBadge(accPct, accColor));
row.appendChild(accTd);
row.appendChild(el('td', {textContent: String(m.iterations)}));
var statusTd = el('td');
statusTd.appendChild(makeBadge(m.status, 'badge-blue'));
row.appendChild(statusTd);
tbody.appendChild(row);
});
table.appendChild(tbody);
container.appendChild(table);
}
function renderServices(services) {
var container = clear('serviceGrid');
if (!services || Object.keys(services).length === 0) {
container.appendChild(el('div', {className: 'empty-state', textContent: 'No services detected'}));
return;
}
Object.keys(services).forEach(function(name) {
var svc = services[name];
var item = el('div', {className: 'service-item'});
var nameRow = el('div', {className: 'name'});
nameRow.appendChild(makeDot(svc.running ? 'dot-green' : 'dot-red'));
nameRow.appendChild(document.createTextNode(name));
item.appendChild(nameRow);
item.appendChild(el('div', {className: 'detail', textContent: svc.status || 'stopped'}));
container.appendChild(item);
});
}
function renderAgent(snapshot) {
var container = clear('agentStatus');
var running = snapshot.agentRunning;
var task = snapshot.agentTask || 'Idle';
var statusRow = el('div', {className: 'progress-row'});
statusRow.appendChild(makeDot(running ? 'dot-green' : 'dot-red'));
statusRow.appendChild(el('span', {textContent: running ? 'Running: ' + task : 'Stopped'}));
container.appendChild(statusRow);
var btn = document.getElementById('btnAgent');
btn.textContent = running ? 'Stop Agent' : 'Start Agent';
btn.className = running ? 'danger' : 'primary';
}
// Data fetching via Wails bindings.
var stackRunning = false;
async function refreshAll() {
try {
var snap = await window.go['main']['TrayService']['GetSnapshot']();
renderTraining(snap.training);
renderGeneration(snap.generation);
renderScoreboard(snap.models);
renderAgent(snap);
stackRunning = snap.stackRunning;
var btn = document.getElementById('btnStack');
btn.textContent = stackRunning ? 'Stop Services' : 'Start Services';
btn.className = stackRunning ? 'danger' : 'primary';
document.getElementById('statusText').textContent =
stackRunning ? 'Services running' : 'Services stopped';
// Fetch Docker service details.
var dockerStatus = await window.go['main']['DockerService']['GetStatus']();
renderServices(dockerStatus.services);
document.getElementById('footerText').textContent =
'LEM v0.1.0 | Updated ' + new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('statusText').textContent = 'Error: ' + e.message;
}
}
async function toggleStack() {
try {
if (stackRunning) {
await window.go['main']['TrayService']['StopStack']();
} else {
await window.go['main']['TrayService']['StartStack']();
}
setTimeout(refreshAll, 1000);
} catch (e) {
document.getElementById('statusText').textContent = 'Error: ' + e.message;
}
}
async function toggleAgent() {
try {
var snap = await window.go['main']['TrayService']['GetSnapshot']();
if (snap.agentRunning) {
await window.go['main']['TrayService']['StopAgent']();
} else {
await window.go['main']['TrayService']['StartAgent']();
}
setTimeout(refreshAll, 500);
} catch (e) {
document.getElementById('statusText').textContent = 'Error: ' + e.message;
}
}
// Auto-refresh every 10 seconds.
refreshAll();
setInterval(refreshAll, 10000);
</script>
</body>
</html>

72
cmd/lem-desktop/go.mod Normal file
View file

@ -0,0 +1,72 @@
module forge.lthn.ai/lthn/lem/cmd/lem-desktop
go 1.25.6
require (
forge.lthn.ai/lthn/lem v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.71
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/apache/arrow-go/v18 v18.1.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/flatbuffers v25.1.24+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/marcboeker/go-duckdb v1.8.5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/parquet-go/bitpack v1.0.0 // indirect
github.com/parquet-go/jsonlite v1.0.0 // indirect
github.com/parquet-go/parquet-go v0.27.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/twpayne/go-geom v1.6.1 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace forge.lthn.ai/lthn/lem => ../../

211
cmd/lem-desktop/go.sum Normal file
View file

@ -0,0 +1,211 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
github.com/parquet-go/parquet-go v0.27.0 h1:vHWK2xaHbj+v1DYps03yDRpEsdtOeKbhiXUaixoPb3g=
github.com/parquet-go/parquet-go v0.27.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.71 h1:6ERh+1SJJ+tl5E4W49q8pDyQ4yeyi1yj9IdSppKtMx4=
github.com/wailsapp/wails/v3 v3.0.0-alpha.71/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,23 @@
package icons
// Placeholder tray icons — replace with actual PNG data.
// Generate with: task lem-desktop:generate:icons
//
// macOS template icons should be black-on-transparent, 22x22 or 44x44.
// Windows/Linux icons should be full-color, 32x32 or 64x64.
// Placeholder returns a minimal 1x1 transparent PNG for development.
// Replace with the real LEM logo (brain + scales motif).
func Placeholder() []byte {
return []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // RGB
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, // data
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, // data
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND
0x44, 0xae, 0x42, 0x60, 0x82,
}
}

146
cmd/lem-desktop/main.go Normal file
View file

@ -0,0 +1,146 @@
// Package main provides the LEM Desktop application.
// A system tray app inspired by BugSETI that bundles:
// - Local Forgejo for agentic git workflows
// - InfluxDB for metrics and coordination
// - Inference proxy to M3 MLX or local vLLM
// - Scoring agent for automated checkpoint evaluation
// - Lab dashboard for training and generation monitoring
//
// Built on Wails v3 — ships as a signed native binary on macOS (Lethean CIC),
// Linux AppImage, and Windows installer.
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"forge.lthn.ai/lthn/lem/cmd/lem-desktop/icons"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
//go:embed all:frontend
var assets embed.FS
// Tray icon data — placeholders until real icons are generated.
var (
trayIconTemplate = icons.Placeholder()
trayIconLight = icons.Placeholder()
trayIconDark = icons.Placeholder()
)
func main() {
// Strip embed prefix so files serve from root.
staticAssets, err := fs.Sub(assets, "frontend")
if err != nil {
log.Fatal(err)
}
// ── Configuration ──
influxURL := envOr("INFLUX_URL", "http://localhost:8181")
influxDB := envOr("INFLUX_DB", "training")
apiURL := envOr("LEM_API_URL", "http://localhost:8080")
m3Host := envOr("M3_HOST", "10.69.69.108")
baseModel := envOr("BASE_MODEL", "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B")
dbPath := envOr("LEM_DB", "")
workDir := envOr("WORK_DIR", filepath.Join(os.TempDir(), "scoring-agent"))
deployDir := envOr("LEM_DEPLOY_DIR", findDeployDir())
// ── Services ──
dashboardService := NewDashboardService(influxURL, influxDB, dbPath)
dockerService := NewDockerService(deployDir)
agentRunner := NewAgentRunner(apiURL, influxURL, influxDB, m3Host, baseModel, workDir)
trayService := NewTrayService(nil)
services := []application.Service{
application.NewService(dashboardService),
application.NewService(dockerService),
application.NewService(agentRunner),
application.NewService(trayService),
}
// ── Application ──
app := application.New(application.Options{
Name: "LEM",
Description: "Lethean Ethics Model — Training, Scoring & Inference",
Services: services,
Assets: application.AssetOptions{
Handler: spaHandler(staticAssets),
},
Mac: application.MacOptions{
ActivationPolicy: application.ActivationPolicyAccessory,
},
})
// Wire up references.
trayService.app = app
trayService.SetServices(dashboardService, dockerService, agentRunner)
// Set up system tray.
setupSystemTray(app, trayService, dashboardService, dockerService)
// Show dashboard on first launch.
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
if w, ok := app.Window.Get("dashboard"); ok {
w.Show()
w.Focus()
}
})
log.Println("Starting LEM Desktop...")
log.Println(" - System tray active")
log.Println(" - Dashboard ready")
log.Printf(" - InfluxDB: %s/%s", influxURL, influxDB)
log.Printf(" - Inference: %s", apiURL)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
// spaHandler serves static files with SPA fallback for client-side routing.
func spaHandler(fsys fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(fsys, path); err != nil {
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}
// findDeployDir locates the deploy/ directory relative to the binary.
func findDeployDir() string {
// Check relative to executable.
exe, err := os.Executable()
if err == nil {
dir := filepath.Join(filepath.Dir(exe), "deploy")
if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err == nil {
return dir
}
}
// Check relative to working directory.
if cwd, err := os.Getwd(); err == nil {
dir := filepath.Join(cwd, "deploy")
if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err == nil {
return dir
}
}
return "deploy"
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

277
cmd/lem-desktop/tray.go Normal file
View file

@ -0,0 +1,277 @@
package main
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
// TrayService provides system tray bindings for the LEM desktop.
// Exposes status to the frontend and controls the tray menu.
type TrayService struct {
app *application.App
dashboard *DashboardService
docker *DockerService
agent *AgentRunner
}
// NewTrayService creates a new TrayService.
func NewTrayService(app *application.App) *TrayService {
return &TrayService{app: app}
}
// SetServices wires up service references after app creation.
func (t *TrayService) SetServices(dashboard *DashboardService, docker *DockerService, agent *AgentRunner) {
t.dashboard = dashboard
t.docker = docker
t.agent = agent
}
// ServiceName returns the Wails service name.
func (t *TrayService) ServiceName() string {
return "TrayService"
}
// ServiceStartup is called when the Wails app starts.
func (t *TrayService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("TrayService started")
return nil
}
// ServiceShutdown is called on app exit.
func (t *TrayService) ServiceShutdown() error {
log.Println("TrayService shutdown")
return nil
}
// TraySnapshot is the complete tray state for the frontend.
type TraySnapshot struct {
StackRunning bool `json:"stackRunning"`
AgentRunning bool `json:"agentRunning"`
AgentTask string `json:"agentTask"`
Training []TrainingRow `json:"training"`
Generation GenerationStats `json:"generation"`
Models []ModelInfo `json:"models"`
DockerServices int `json:"dockerServices"`
}
// GetSnapshot returns the full tray state.
func (t *TrayService) GetSnapshot() TraySnapshot {
snap := TraySnapshot{}
if t.dashboard != nil {
ds := t.dashboard.GetSnapshot()
snap.Training = ds.Training
snap.Generation = ds.Generation
snap.Models = ds.Models
}
if t.docker != nil {
status := t.docker.GetStatus()
snap.StackRunning = status.Running
snap.DockerServices = len(status.Services)
}
if t.agent != nil {
snap.AgentRunning = t.agent.IsRunning()
snap.AgentTask = t.agent.CurrentTask()
}
return snap
}
// StartStack starts the Docker compose stack.
func (t *TrayService) StartStack() error {
if t.docker == nil {
return fmt.Errorf("docker service not available")
}
return t.docker.Start()
}
// StopStack stops the Docker compose stack.
func (t *TrayService) StopStack() error {
if t.docker == nil {
return fmt.Errorf("docker service not available")
}
return t.docker.Stop()
}
// StartAgent starts the scoring agent.
func (t *TrayService) StartAgent() error {
if t.agent == nil {
return fmt.Errorf("agent service not available")
}
return t.agent.Start()
}
// StopAgent stops the scoring agent.
func (t *TrayService) StopAgent() {
if t.agent != nil {
t.agent.Stop()
}
}
// setupSystemTray configures the system tray icon and menu.
func setupSystemTray(app *application.App, tray *TrayService, dashboard *DashboardService, docker *DockerService) {
systray := app.SystemTray.New()
systray.SetTooltip("LEM — Lethean Ethics Model")
// Platform-specific icon.
if runtime.GOOS == "darwin" {
systray.SetTemplateIcon(trayIconTemplate)
} else {
systray.SetDarkModeIcon(trayIconDark)
systray.SetIcon(trayIconLight)
}
// ── Tray Panel (frameless dropdown) ──
trayWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "tray-panel",
Title: "LEM",
Width: 420,
Height: 520,
URL: "/tray",
Hidden: true,
Frameless: true,
BackgroundColour: application.NewRGB(15, 23, 42),
})
systray.AttachWindow(trayWindow).WindowOffset(5)
// ── Dashboard Window ──
app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "dashboard",
Title: "LEM Dashboard",
Width: 1400,
Height: 900,
URL: "/dashboard",
Hidden: true,
BackgroundColour: application.NewRGB(15, 23, 42),
})
// ── Workbench Window (model scoring, probes) ──
app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "workbench",
Title: "LEM Workbench",
Width: 1200,
Height: 800,
URL: "/workbench",
Hidden: true,
BackgroundColour: application.NewRGB(15, 23, 42),
})
// ── Settings Window ──
app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: "LEM Settings",
Width: 600,
Height: 500,
URL: "/settings",
Hidden: true,
BackgroundColour: application.NewRGB(15, 23, 42),
})
// ── Build Tray Menu ──
trayMenu := app.Menu.New()
// Status (dynamic).
statusItem := trayMenu.Add("LEM: Idle")
statusItem.SetEnabled(false)
trayMenu.AddSeparator()
// Stack control.
stackItem := trayMenu.Add("Start Services")
stackItem.OnClick(func(ctx *application.Context) {
if docker.IsRunning() {
docker.Stop()
stackItem.SetLabel("Start Services")
statusItem.SetLabel("LEM: Stopped")
} else {
docker.Start()
stackItem.SetLabel("Stop Services")
statusItem.SetLabel("LEM: Running")
}
})
// Agent control.
agentItem := trayMenu.Add("Start Scoring Agent")
agentItem.OnClick(func(ctx *application.Context) {
if tray.agent != nil && tray.agent.IsRunning() {
tray.agent.Stop()
agentItem.SetLabel("Start Scoring Agent")
} else if tray.agent != nil {
tray.agent.Start()
agentItem.SetLabel("Stop Scoring Agent")
}
})
trayMenu.AddSeparator()
// Windows.
trayMenu.Add("Open Dashboard").OnClick(func(ctx *application.Context) {
if w, ok := app.Window.Get("dashboard"); ok {
w.Show()
w.Focus()
}
})
trayMenu.Add("Open Workbench").OnClick(func(ctx *application.Context) {
if w, ok := app.Window.Get("workbench"); ok {
w.Show()
w.Focus()
}
})
trayMenu.Add("Open Forge").OnClick(func(ctx *application.Context) {
// Open the local Forgejo in the default browser.
openBrowser("http://localhost:3000")
})
trayMenu.AddSeparator()
// Stats submenu.
statsMenu := trayMenu.AddSubmenu("Training")
statsMenu.Add("Golden Set: loading...").SetEnabled(false)
statsMenu.Add("Expansion: loading...").SetEnabled(false)
statsMenu.Add("Models Scored: loading...").SetEnabled(false)
trayMenu.AddSeparator()
// Settings.
trayMenu.Add("Settings...").OnClick(func(ctx *application.Context) {
if w, ok := app.Window.Get("settings"); ok {
w.Show()
w.Focus()
}
})
trayMenu.AddSeparator()
// Quit.
trayMenu.Add("Quit LEM").OnClick(func(ctx *application.Context) {
app.Quit()
})
systray.SetMenu(trayMenu)
}
// openBrowser launches the default browser.
func openBrowser(url string) {
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd = "open"
case "linux":
cmd = "xdg-open"
case "windows":
cmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler"}
}
args = append(args, url)
go exec.Command(cmd, args...).Start()
}

88
deploy/docker-compose.yml Normal file
View file

@ -0,0 +1,88 @@
# LEM Desktop — Docker Compose Stack
# Provides local Forgejo (agentic git), InfluxDB (metrics), and inference proxy.
#
# Usage:
# lem desktop start # starts all services
# docker compose -f deploy/docker-compose.yml up -d
#
# Services:
# forgejo — Local git forge for agentic workflows (port 3000, SSH 2222)
# influxdb — Metrics and coordination (port 8181)
# inference — OpenAI-compatible proxy to M3 MLX or local vLLM (port 8080)
services:
# ── Forgejo — Local Agentic Git Forge ──
forgejo:
image: codeberg.org/forgejo/forgejo:10
container_name: lem-forgejo
restart: unless-stopped
ports:
- "3000:3000" # Web UI
- "2222:22" # SSH
volumes:
- forgejo-data:/data
- forgejo-config:/etc/gitea
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__ROOT_URL=http://localhost:3000/
- FORGEJO__server__SSH_PORT=2222
- FORGEJO__server__SSH_LISTEN_PORT=22
- FORGEJO__service__DISABLE_REGISTRATION=false
- FORGEJO__service__DEFAULT_ALLOW_CREATE_ORGANIZATION=true
- FORGEJO__federation__ENABLED=true
- FORGEJO__actions__ENABLED=true
- FORGEJO__database__DB_TYPE=sqlite3
- FORGEJO__database__PATH=/data/gitea/gitea.db
healthcheck:
test: ["CMD", "curl", "-fsSL", "http://localhost:3000/api/v1/version"]
interval: 30s
timeout: 5s
retries: 3
# ── InfluxDB v3 — Metrics & Coordination ──
influxdb:
image: quay.io/influxdb/influxdb3-core:latest
container_name: lem-influxdb
restart: unless-stopped
ports:
- "8181:8181"
volumes:
- influxdb-data:/var/lib/influxdb3
environment:
- INFLUXDB3_NODE_ID=lem-local
command: ["serve", "--host-id", "lem-local", "--object-store", "file", "--data-dir", "/var/lib/influxdb3"]
healthcheck:
test: ["CMD", "curl", "-fsSL", "http://localhost:8181/health"]
interval: 15s
timeout: 5s
retries: 5
# ── Inference Proxy — OpenAI-Compatible API ──
# Routes to M3 MLX server or local vLLM/llama.cpp.
# Override LEM_INFERENCE_BACKEND to point elsewhere.
inference:
image: nginx:alpine
container_name: lem-inference
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./inference-proxy.conf:/etc/nginx/conf.d/default.conf:ro
environment:
- UPSTREAM_URL=${LEM_INFERENCE_BACKEND:-http://10.69.69.108:8090}
depends_on:
- influxdb
healthcheck:
test: ["CMD", "curl", "-fsSL", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
volumes:
forgejo-data:
driver: local
forgejo-config:
driver: local
influxdb-data:
driver: local

View file

@ -0,0 +1,30 @@
# Nginx reverse proxy for OpenAI-compatible inference API.
# Routes /v1/* to the configured upstream (M3 MLX, vLLM, llama.cpp, etc.)
# Set UPSTREAM_URL env var or LEM_INFERENCE_BACKEND in docker-compose.
server {
listen 8080;
server_name localhost;
# Health check endpoint.
location /health {
return 200 '{"status": "ok"}';
add_header Content-Type application/json;
}
# Proxy all /v1/* requests to the inference backend.
location /v1/ {
proxy_pass ${UPSTREAM_URL}/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
}
# Model listing passthrough.
location /v1/models {
proxy_pass ${UPSTREAM_URL}/v1/models;
proxy_set_header Host $host;
}
}

6
go.work Normal file
View file

@ -0,0 +1,6 @@
go 1.25.6
use (
.
./cmd/lem-desktop
)

7
go.work.sum Normal file
View file

@ -0,0 +1,7 @@
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=

View file

@ -162,6 +162,38 @@ func (db *DB) UpdateExpansionStatus(idx int64, status string) error {
return nil
}
// QueryRows executes an arbitrary SQL query and returns results as maps.
func (db *DB) QueryRows(query string, args ...interface{}) ([]map[string]interface{}, error) {
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("columns: %w", err)
}
var result []map[string]interface{}
for rows.Next() {
values := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range values {
ptrs[i] = &values[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
row := make(map[string]interface{}, len(cols))
for i, col := range cols {
row[col] = values[i]
}
result = append(result, row)
}
return result, rows.Err()
}
// TableCounts returns row counts for all known tables.
func (db *DB) TableCounts() (map[string]int, error) {
tables := []string{"golden_set", "expansion_prompts", "seeds", "prompts",