Add web interface (anvilweb)
- Minimal Go web server with REST API for session management - Single-page UI using Pico CSS (12KB, classless framework) - Features: create sessions, send prompts, edit context/alias, control sessions - Security: input validation, XSS prevention, path validation - Auto-refresh every 5 seconds - Documented security model (no authentication, localhost/SSH tunneling recommended)
This commit is contained in:
parent
c367b05a2e
commit
38f3a8e765
|
|
@ -3,4 +3,5 @@ Prompt
|
|||
Q-Reset
|
||||
Q-Login
|
||||
/anvillm
|
||||
anvilweb
|
||||
.claude
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -10,13 +10,14 @@ AnviLLM consists of:
|
|||
- **Assist**: Acme UI client (auto-starts daemon if needed)
|
||||
- **anvillm.el**: For our emacs friends ;)
|
||||
- **anvillm**: Terminal UI (curses-based) for everyone else
|
||||
- **anvilweb**: Web interface for browser-based access
|
||||
|
||||
This separation allows:
|
||||
- Multiple clients to connect to the same sessions
|
||||
- Server survives client crashes/restarts
|
||||
- Scriptable workflows via 9P without UI dependencies
|
||||
- Service management (systemd/runit integration)
|
||||
- Choice of interface: Acme, terminal, or Emacs
|
||||
- Choice of interface: Acme, terminal, Emacs, or web browser
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -161,6 +162,43 @@ Copy anvillm.el to ~/.emacs.d/lisp/ or wherever your elisp files live. Then, add
|
|||
|
||||
The Emacs interface uses the `9p` command from plan9port to interact with the filesystem.
|
||||
|
||||
### Web Interface (anvilweb)
|
||||
|
||||
Run `anvilweb` for a browser-based interface:
|
||||
|
||||
```sh
|
||||
anvilweb # starts on :8080
|
||||
anvilweb -addr :3000 # custom port
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Start new sessions (Kiro/Claude)
|
||||
- View all sessions with live state updates
|
||||
- Send prompts, edit context and aliases
|
||||
- Stop/restart/kill sessions
|
||||
- Auto-refreshes every 5 seconds
|
||||
|
||||
**Security Warning:**
|
||||
|
||||
⚠️ **The web interface has NO authentication.** Anyone who can reach the server port can:
|
||||
- View all sessions
|
||||
- Send prompts to any session
|
||||
- Start/stop/kill sessions
|
||||
- Modify session contexts
|
||||
|
||||
**Only run anvilweb on localhost or trusted networks.** For remote access, use SSH port forwarding:
|
||||
|
||||
```sh
|
||||
# On remote machine
|
||||
anvilweb -addr localhost:8080
|
||||
|
||||
# On local machine
|
||||
ssh -L 8080:localhost:8080 user@remote
|
||||
# Then browse to http://localhost:8080
|
||||
```
|
||||
|
||||
Or use a reverse proxy with authentication (nginx, caddy, etc.).
|
||||
|
||||
### Acme Interface (Assist)
|
||||
|
||||
Type `Assist` in Acme and middle-click to open the `/AnviLLM/` session manager.
|
||||
|
|
@ -344,6 +382,8 @@ See `scripts/DevReview` and `scripts/Planning` for complete examples.
|
|||
| 9P not working | Verify plan9port: `9p ls agent` |
|
||||
| Stale PID file | `anvilsrv stop` cleans up automatically |
|
||||
| Daemon won't stop | Check logs with `anvilsrv fgstart` |
|
||||
| anvilweb can't connect | Ensure anvilsrv is running; check namespace matches |
|
||||
| anvilweb port in use | Use `-addr :PORT` to specify different port |
|
||||
|
||||
**Debugging**: Run `anvilsrv fgstart` to see daemon logs in foreground.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,402 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"9fans.net/go/plan9"
|
||||
"9fans.net/go/plan9/client"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var static embed.FS
|
||||
|
||||
var (
|
||||
addr = flag.String("addr", ":8080", "HTTP server address")
|
||||
namespace = flag.String("ns", os.Getenv("NAMESPACE"), "9P namespace")
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Alias string `json:"alias"`
|
||||
State string `json:"state"`
|
||||
PID string `json:"pid"`
|
||||
CWD string `json:"cwd"`
|
||||
Backend string `json:"backend"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *namespace == "" {
|
||||
*namespace = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER"))
|
||||
}
|
||||
|
||||
staticFS, err := fs.Sub(static, "static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
http.HandleFunc("/api/sessions", handleSessions)
|
||||
http.HandleFunc("/api/session/", handleSession)
|
||||
|
||||
log.Printf("Starting web server on %s", *addr)
|
||||
log.Printf("Using namespace: %s", *namespace)
|
||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||
}
|
||||
|
||||
func connect() (*client.Fsys, error) {
|
||||
return client.Mount("unix", filepath.Join(*namespace, "agent"))
|
||||
}
|
||||
|
||||
func handleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
listSessions(w, r)
|
||||
case "POST":
|
||||
createSession(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
fid, err := fs.Open("list", plan9.OREAD)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
data, err := io.ReadAll(fid)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var sessions []Session
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
sess := Session{
|
||||
ID: fields[0],
|
||||
Alias: fields[1],
|
||||
State: fields[2],
|
||||
PID: fields[3],
|
||||
CWD: fields[4],
|
||||
}
|
||||
|
||||
// Read backend for each session
|
||||
if backendFid, err := fs.Open(filepath.Join(sess.ID, "backend"), plan9.OREAD); err == nil {
|
||||
if backendData, err := io.ReadAll(backendFid); err == nil {
|
||||
sess.Backend = strings.TrimSpace(string(backendData))
|
||||
}
|
||||
backendFid.Close()
|
||||
}
|
||||
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(sessions)
|
||||
}
|
||||
|
||||
func createSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Backend string `json:"backend"`
|
||||
CWD string `json:"cwd"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate backend
|
||||
if req.Backend != "kiro-cli" && req.Backend != "claude" {
|
||||
http.Error(w, "Invalid backend", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate path
|
||||
if !filepath.IsAbs(req.CWD) {
|
||||
http.Error(w, "Path must be absolute", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(req.CWD); err != nil {
|
||||
http.Error(w, "Path does not exist", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
fid, err := fs.Open("ctl", plan9.OWRITE)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
cmd := fmt.Sprintf("new %s %s", req.Backend, req.CWD)
|
||||
if _, err := fid.Write([]byte(cmd)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/session/"), "/")
|
||||
if len(parts) < 1 {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
if action == "" {
|
||||
getSession(w, r, id)
|
||||
} else {
|
||||
getSessionFile(w, r, id, action)
|
||||
}
|
||||
case "POST":
|
||||
if action == "prompt" {
|
||||
sendPrompt(w, r, id)
|
||||
} else if action == "ctl" {
|
||||
controlSession(w, r, id)
|
||||
} else {
|
||||
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||
}
|
||||
case "PUT":
|
||||
if action == "alias" || action == "context" {
|
||||
updateSessionFile(w, r, id, action)
|
||||
} else {
|
||||
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||
}
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(w http.ResponseWriter, r *http.Request, id string) {
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
sess := Session{ID: id}
|
||||
|
||||
// Read state
|
||||
if fid, err := fs.Open(filepath.Join(id, "state"), plan9.OREAD); err == nil {
|
||||
if data, err := io.ReadAll(fid); err == nil {
|
||||
sess.State = strings.TrimSpace(string(data))
|
||||
}
|
||||
fid.Close()
|
||||
}
|
||||
|
||||
// Read alias
|
||||
if fid, err := fs.Open(filepath.Join(id, "alias"), plan9.OREAD); err == nil {
|
||||
if data, err := io.ReadAll(fid); err == nil {
|
||||
sess.Alias = strings.TrimSpace(string(data))
|
||||
}
|
||||
fid.Close()
|
||||
}
|
||||
|
||||
// Read backend
|
||||
if fid, err := fs.Open(filepath.Join(id, "backend"), plan9.OREAD); err == nil {
|
||||
if data, err := io.ReadAll(fid); err == nil {
|
||||
sess.Backend = strings.TrimSpace(string(data))
|
||||
}
|
||||
fid.Close()
|
||||
}
|
||||
|
||||
// Read pid
|
||||
if fid, err := fs.Open(filepath.Join(id, "pid"), plan9.OREAD); err == nil {
|
||||
if data, err := io.ReadAll(fid); err == nil {
|
||||
sess.PID = strings.TrimSpace(string(data))
|
||||
}
|
||||
fid.Close()
|
||||
}
|
||||
|
||||
// Read cwd
|
||||
if fid, err := fs.Open(filepath.Join(id, "cwd"), plan9.OREAD); err == nil {
|
||||
if data, err := io.ReadAll(fid); err == nil {
|
||||
sess.CWD = strings.TrimSpace(string(data))
|
||||
}
|
||||
fid.Close()
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(sess)
|
||||
}
|
||||
|
||||
func getSessionFile(w http.ResponseWriter, r *http.Request, id, file string) {
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
fid, err := fs.Open(filepath.Join(id, file), plan9.OREAD)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
data, err := io.ReadAll(fid)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"content": string(data),
|
||||
})
|
||||
}
|
||||
|
||||
func sendPrompt(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var req struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Prompt) == "" {
|
||||
http.Error(w, "Prompt cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
fid, err := fs.Open(filepath.Join(id, "in"), plan9.OWRITE)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
if _, err := fid.Write([]byte(req.Prompt)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func controlSession(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate command
|
||||
validCmds := map[string]bool{"stop": true, "restart": true, "kill": true, "refresh": true}
|
||||
if !validCmds[req.Command] {
|
||||
http.Error(w, "Invalid command", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
fid, err := fs.Open(filepath.Join(id, "ctl"), plan9.OWRITE)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
if _, err := fid.Write([]byte(req.Command)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func updateSessionFile(w http.ResponseWriter, r *http.Request, id, file string) {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fs, err := connect()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
fid, err := fs.Open(filepath.Join(id, file), plan9.OWRITE)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer fid.Close()
|
||||
|
||||
if _, err := fid.Write([]byte(req.Content)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
anvilweb:V:
|
||||
cd cmd/anvilweb && go build -o $home/bin/anvilweb
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AnviLLM Web</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
body { padding: 1rem; }
|
||||
.session-card { margin-bottom: 1rem; }
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.state-idle { background: var(--pico-primary-background); color: var(--pico-primary-inverse); }
|
||||
.state-running { background: var(--pico-color-amber-550); color: white; }
|
||||
.state-stopped { background: var(--pico-muted-color); color: white; }
|
||||
.state-error { background: var(--pico-color-red-550); color: white; }
|
||||
.session-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.session-actions button { margin: 0; padding: 0.5rem 1rem; }
|
||||
#prompt-area { min-height: 100px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AnviLLM</h1>
|
||||
<p>Web interface for LLM session management</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>New Session</h2>
|
||||
<form id="new-session-form">
|
||||
<div class="grid">
|
||||
<label>
|
||||
Backend
|
||||
<select name="backend" required>
|
||||
<option value="kiro-cli">Kiro</option>
|
||||
<option value="claude">Claude</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Working Directory
|
||||
<input type="text" name="cwd" placeholder="/path/to/project" required>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Start Session</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2>Sessions</h2>
|
||||
<button onclick="loadSessions()">Refresh</button>
|
||||
</div>
|
||||
<div id="sessions"></div>
|
||||
</section>
|
||||
|
||||
<dialog id="prompt-dialog">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="closePromptDialog()"></button>
|
||||
<h3>Send Prompt</h3>
|
||||
</header>
|
||||
<form id="prompt-form">
|
||||
<label>
|
||||
Prompt
|
||||
<textarea id="prompt-area" name="prompt" required></textarea>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" onclick="closePromptDialog()" class="secondary">Cancel</button>
|
||||
<button type="submit">Send</button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<dialog id="context-dialog">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="closeContextDialog()"></button>
|
||||
<h3>Edit Context</h3>
|
||||
</header>
|
||||
<form id="context-form">
|
||||
<label>
|
||||
Context (prepended to all prompts)
|
||||
<textarea id="context-area" name="context" rows="10"></textarea>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" onclick="closeContextDialog()" class="secondary">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<dialog id="alias-dialog">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev" onclick="closeAliasDialog()"></button>
|
||||
<h3>Set Alias</h3>
|
||||
</header>
|
||||
<form id="alias-form">
|
||||
<label>
|
||||
Alias
|
||||
<input type="text" id="alias-input" name="alias" required>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" onclick="closeAliasDialog()" class="secondary">Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let currentSessionId = null;
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/sessions');
|
||||
const sessions = await res.json();
|
||||
|
||||
const container = document.getElementById('sessions');
|
||||
if (sessions.length === 0) {
|
||||
container.innerHTML = '<p>No sessions running</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = sessions.map(s => {
|
||||
const alias = escapeHtml(s.alias || s.id);
|
||||
const backend = escapeHtml(s.backend || 'unknown');
|
||||
const cwd = escapeHtml(s.cwd);
|
||||
const pid = escapeHtml(s.pid);
|
||||
const state = escapeHtml(s.state);
|
||||
|
||||
return `
|
||||
<article class="session-card">
|
||||
<header>
|
||||
<strong>${alias}</strong>
|
||||
<span class="state-badge state-${state}">${state}</span>
|
||||
</header>
|
||||
<p>
|
||||
<small>
|
||||
<strong>Backend:</strong> ${backend}<br>
|
||||
<strong>CWD:</strong> ${cwd}<br>
|
||||
<strong>PID:</strong> ${pid}
|
||||
</small>
|
||||
</p>
|
||||
<div class="session-actions">
|
||||
<button onclick="openPromptDialog('${escapeHtml(s.id)}')" ${s.state !== 'idle' ? 'disabled' : ''}>Prompt</button>
|
||||
<button onclick="openContextDialog('${escapeHtml(s.id)}')" class="secondary">Context</button>
|
||||
<button onclick="openAliasDialog('${escapeHtml(s.id)}')" class="secondary">Alias</button>
|
||||
<button onclick="controlSession('${escapeHtml(s.id)}', 'stop')" ${s.state === 'stopped' ? 'disabled' : ''} class="secondary">Stop</button>
|
||||
<button onclick="controlSession('${escapeHtml(s.id)}', 'restart')" ${s.state !== 'stopped' ? 'disabled' : ''} class="secondary">Restart</button>
|
||||
<button onclick="controlSession('${escapeHtml(s.id)}', 'kill')" class="contrast">Kill</button>
|
||||
</div>
|
||||
</article>
|
||||
`}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions:', err);
|
||||
document.getElementById('sessions').innerHTML = '<p>Failed to connect to server</p>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('new-session-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = new FormData(form);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
backend: data.get('backend'),
|
||||
cwd: data.get('cwd')
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
form.reset();
|
||||
setTimeout(loadSessions, 500);
|
||||
} else {
|
||||
alert('Failed to create session');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
function openPromptDialog(id) {
|
||||
currentSessionId = id;
|
||||
document.getElementById('prompt-dialog').showModal();
|
||||
document.getElementById('prompt-area').focus();
|
||||
}
|
||||
|
||||
function closePromptDialog() {
|
||||
document.getElementById('prompt-dialog').close();
|
||||
document.getElementById('prompt-form').reset();
|
||||
}
|
||||
|
||||
document.getElementById('prompt-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const prompt = document.getElementById('prompt-area').value;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/session/${currentSessionId}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closePromptDialog();
|
||||
loadSessions();
|
||||
} else {
|
||||
alert('Failed to send prompt');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function openContextDialog(id) {
|
||||
currentSessionId = id;
|
||||
try {
|
||||
const res = await fetch(`/api/session/${id}/context`);
|
||||
const data = await res.json();
|
||||
document.getElementById('context-area').value = data.content || '';
|
||||
document.getElementById('context-dialog').showModal();
|
||||
} catch (err) {
|
||||
alert('Error loading context: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeContextDialog() {
|
||||
document.getElementById('context-dialog').close();
|
||||
}
|
||||
|
||||
document.getElementById('context-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = document.getElementById('context-area').value;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/session/${currentSessionId}/context`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeContextDialog();
|
||||
} else {
|
||||
alert('Failed to save context');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function openAliasDialog(id) {
|
||||
currentSessionId = id;
|
||||
try {
|
||||
const res = await fetch(`/api/session/${id}/alias`);
|
||||
const data = await res.json();
|
||||
document.getElementById('alias-input').value = data.content || '';
|
||||
document.getElementById('alias-dialog').showModal();
|
||||
} catch (err) {
|
||||
alert('Error loading alias: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeAliasDialog() {
|
||||
document.getElementById('alias-dialog').close();
|
||||
}
|
||||
|
||||
document.getElementById('alias-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = document.getElementById('alias-input').value;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/session/${currentSessionId}/alias`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeAliasDialog();
|
||||
loadSessions();
|
||||
} else {
|
||||
alert('Failed to save alias');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function controlSession(id, cmd) {
|
||||
try {
|
||||
const res = await fetch(`/api/session/${id}/ctl`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(loadSessions, 500);
|
||||
} else {
|
||||
alert(`Failed to ${cmd} session`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Load sessions on page load
|
||||
loadSessions();
|
||||
|
||||
// Auto-refresh every 5 seconds
|
||||
setInterval(loadSessions, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
mkfile
1
mkfile
|
|
@ -6,6 +6,7 @@ build:V:
|
|||
go build -o $INSTALL_PATH/anvilsrv ./cmd/anvilsrv
|
||||
go build -o $INSTALL_PATH/Assist ./cmd/Assist
|
||||
go build -o $INSTALL_PATH/anvillm ./cmd/anvillm
|
||||
go build -o $INSTALL_PATH/anvilweb ./cmd/anvilweb
|
||||
mkdir -p $INSTALL_PATH/Workflows
|
||||
cp scripts/DevReview $INSTALL_PATH/Workflows/DevReview
|
||||
chmod 0755 $INSTALL_PATH/Workflows/DevReview
|
||||
|
|
|
|||
Reference in New Issue