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:
parent
9fac5749c2
commit
774f097855
14 changed files with 2021 additions and 0 deletions
122
cmd/lem-desktop/agent_runner.go
Normal file
122
cmd/lem-desktop/agent_runner.go
Normal 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")
|
||||
}
|
||||
299
cmd/lem-desktop/dashboard.go
Normal file
299
cmd/lem-desktop/dashboard.go
Normal 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
226
cmd/lem-desktop/docker.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
482
cmd/lem-desktop/frontend/index.html
Normal file
482
cmd/lem-desktop/frontend/index.html
Normal 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
72
cmd/lem-desktop/go.mod
Normal 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
211
cmd/lem-desktop/go.sum
Normal 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=
|
||||
23
cmd/lem-desktop/icons/icons.go
Normal file
23
cmd/lem-desktop/icons/icons.go
Normal 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
146
cmd/lem-desktop/main.go
Normal 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
277
cmd/lem-desktop/tray.go
Normal 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
88
deploy/docker-compose.yml
Normal 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
|
||||
30
deploy/inference-proxy.conf
Normal file
30
deploy/inference-proxy.conf
Normal 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
6
go.work
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
go 1.25.6
|
||||
|
||||
use (
|
||||
.
|
||||
./cmd/lem-desktop
|
||||
)
|
||||
7
go.work.sum
Normal file
7
go.work.sum
Normal 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=
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue