update httpgw: add dbus and ninep support
This commit is contained in:
parent
1f9052db76
commit
250af7967f
|
|
@ -0,0 +1,649 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dbusService = "org.ollie.SessionManager"
|
||||||
|
dbusPath = "/org/ollie/SessionManager"
|
||||||
|
dbusIface = "org.ollie.SessionManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dbusConn() (*dbus.Conn, error) {
|
||||||
|
return dbus.ConnectSessionBus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusCall(method string, args ...interface{}) (*dbus.Call, error) {
|
||||||
|
conn, err := dbusConn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dbus connect: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
obj := conn.Object(dbusService, dbusPath)
|
||||||
|
call := obj.Call(dbusIface+"."+method, 0, args...)
|
||||||
|
if call.Err != nil {
|
||||||
|
return nil, call.Err
|
||||||
|
}
|
||||||
|
return call, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbusHandler routes HTTP requests to D-Bus method calls.
|
||||||
|
//
|
||||||
|
// API:
|
||||||
|
//
|
||||||
|
// GET /sessions - ListSessions
|
||||||
|
// POST /sessions - CreateSession
|
||||||
|
// DELETE /sessions/{id} - KillSession
|
||||||
|
// PATCH /sessions/{id} - RenameSession
|
||||||
|
//
|
||||||
|
// POST /sessions/{id}/submit - Submit prompt
|
||||||
|
// POST /sessions/{id}/interrupt - Interrupt
|
||||||
|
//
|
||||||
|
// GET /sessions/{id}/state - GetState
|
||||||
|
// GET /sessions/{id}/chat - GetChat (query: offset)
|
||||||
|
// GET /sessions/{id}/usage - GetUsage
|
||||||
|
// GET /sessions/{id}/cost - GetCost
|
||||||
|
// GET /sessions/{id}/config - GetConfig
|
||||||
|
// POST /sessions/{id}/config - SetConfig
|
||||||
|
// GET /sessions/{id}/context - GetContext
|
||||||
|
//
|
||||||
|
// GET /backends - ListBackends
|
||||||
|
// GET /sessions/{id}/models - ListModels
|
||||||
|
// GET /agents - ListAgents
|
||||||
|
//
|
||||||
|
// GET /sessions/{id}/peers - PeerList
|
||||||
|
// POST /sessions/{id}/peers - PeerAdd
|
||||||
|
// DELETE /sessions/{id}/peers/{peer_id} - PeerRemove
|
||||||
|
// POST /sessions/{id}/peers/{peer_id}/submit - PeerSubmit
|
||||||
|
func dbusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
p = strings.TrimSuffix(p, "/")
|
||||||
|
|
||||||
|
parts := strings.Split(p, "/")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
// GET/POST /sessions
|
||||||
|
case len(parts) == 1 && parts[0] == "sessions":
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
dbusListSessions(w)
|
||||||
|
case http.MethodPost:
|
||||||
|
dbusCreateSession(w, r)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/{id}
|
||||||
|
case len(parts) == 2 && parts[0] == "sessions":
|
||||||
|
sessionID := parts[1]
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
dbusGetSession(w, sessionID)
|
||||||
|
case http.MethodDelete:
|
||||||
|
dbusKillSession(w, sessionID)
|
||||||
|
case http.MethodPatch:
|
||||||
|
dbusRenameSession(w, r, sessionID)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/{id}/{action}
|
||||||
|
case len(parts) == 3 && parts[0] == "sessions":
|
||||||
|
sessionID := parts[1]
|
||||||
|
action := parts[2]
|
||||||
|
switch action {
|
||||||
|
case "submit":
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusSubmit(w, r, sessionID)
|
||||||
|
case "interrupt":
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusInterrupt(w, sessionID)
|
||||||
|
case "state":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusGetState(w, sessionID)
|
||||||
|
case "chat":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusGetChat(w, r, sessionID)
|
||||||
|
case "usage":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusGetUsage(w, sessionID)
|
||||||
|
case "cost":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusGetCost(w, sessionID)
|
||||||
|
case "config":
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
dbusGetConfig(w, sessionID)
|
||||||
|
case http.MethodPost:
|
||||||
|
dbusSetConfig(w, r, sessionID)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"})
|
||||||
|
}
|
||||||
|
case "context":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusGetContext(w, sessionID)
|
||||||
|
case "models":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusListModels(w, sessionID)
|
||||||
|
case "peers":
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
dbusPeerList(w, sessionID)
|
||||||
|
case http.MethodPost:
|
||||||
|
dbusPeerAdd(w, r, sessionID)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/{id}/peers/{peer_id}
|
||||||
|
case len(parts) == 4 && parts[0] == "sessions" && parts[2] == "peers":
|
||||||
|
sessionID := parts[1]
|
||||||
|
peerID := parts[3]
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodDelete:
|
||||||
|
dbusPeerRemove(w, sessionID, peerID)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "DELETE only"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// /sessions/{id}/peers/{peer_id}/submit
|
||||||
|
case len(parts) == 5 && parts[0] == "sessions" && parts[2] == "peers" && parts[4] == "submit":
|
||||||
|
sessionID := parts[1]
|
||||||
|
peerID := parts[3]
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusPeerSubmit(w, r, sessionID, peerID)
|
||||||
|
|
||||||
|
// GET /backends
|
||||||
|
case len(parts) == 1 && parts[0] == "backends":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusListBackends(w)
|
||||||
|
|
||||||
|
// GET /agents
|
||||||
|
case len(parts) == 1 && parts[0] == "agents":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dbusListAgents(w)
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sessions ---
|
||||||
|
|
||||||
|
func dbusListSessions(w http.ResponseWriter) {
|
||||||
|
call, err := dbusCall("ListSessions")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sessions []string
|
||||||
|
if err := call.Store(&sessions); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []map[string]string
|
||||||
|
for _, entry := range sessions {
|
||||||
|
parts := strings.SplitN(entry, "\t", 4)
|
||||||
|
if len(parts) >= 4 {
|
||||||
|
result = append(result, map[string]string{
|
||||||
|
"id": parts[0],
|
||||||
|
"state": parts[1],
|
||||||
|
"model": parts[2],
|
||||||
|
"agent": parts[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = []map[string]string{}
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i]["id"] < result[j]["id"]
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusCreateSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
CWD string `json:"cwd"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Agent string `json:"agent"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
call, err := dbusCall("CreateSession", req.CWD, req.Backend, req.Model, req.Agent)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var sessionID string
|
||||||
|
if err := call.Store(&sessionID); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]string{"id": sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusGetSession(w http.ResponseWriter, sessionID string) {
|
||||||
|
// Return config as the session detail view
|
||||||
|
dbusGetConfig(w, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusKillSession(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("KillSession", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusRenameSession(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call, err := dbusCall("RenameSession", sessionID, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "rename failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interaction ---
|
||||||
|
|
||||||
|
func dbusSubmit(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
var req struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call, err := dbusCall("Submit", sessionID, req.Prompt)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"submitted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusInterrupt(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("Interrupt", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"interrupted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State queries ---
|
||||||
|
|
||||||
|
func dbusGetState(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("GetState", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var state string
|
||||||
|
if err := call.Store(&state); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusGetChat(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
offsetStr := r.URL.Query().Get("offset")
|
||||||
|
var offset int64
|
||||||
|
if offsetStr != "" {
|
||||||
|
offset, _ = strconv.ParseInt(offsetStr, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dbusConn()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
obj := conn.Object(dbusService, dbusPath)
|
||||||
|
call := obj.Call(dbusIface+".GetChat", 0, sessionID, offset)
|
||||||
|
if call.Err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": call.Err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
var newOffset int64
|
||||||
|
if err := call.Store(&text, &newOffset); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("X-Chat-Offset", strconv.FormatInt(newOffset, 10))
|
||||||
|
w.Write([]byte(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusGetUsage(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("GetUsage", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var usage string
|
||||||
|
if err := call.Store(&usage); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"usage": usage})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusGetCost(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("GetCost", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cost string
|
||||||
|
if err := call.Store(&cost); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"cost": cost})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
|
||||||
|
func dbusGetConfig(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("GetConfig", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var config string
|
||||||
|
if err := call.Store(&config); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Parse key=value\n into JSON object
|
||||||
|
m := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(config, "\n"), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, _ := strings.Cut(line, "=")
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusSetConfig(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
var req struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call, err := dbusCall("SetConfig", sessionID, req.Key, req.Value)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "set config failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context ---
|
||||||
|
|
||||||
|
func dbusGetContext(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("GetContext", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var context string
|
||||||
|
if err := call.Store(&context); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Already JSONL — return as-is with appropriate content type
|
||||||
|
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||||
|
w.Write([]byte(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Backends/Models/Agents ---
|
||||||
|
|
||||||
|
func dbusListBackends(w http.ResponseWriter) {
|
||||||
|
call, err := dbusCall("ListBackends")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var backends []string
|
||||||
|
if err := call.Store(&backends); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, backends)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusListModels(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("ListModels", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var models []string
|
||||||
|
if err := call.Store(&models); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusListAgents(w http.ResponseWriter) {
|
||||||
|
call, err := dbusCall("ListAgents")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var agents []string
|
||||||
|
if err := call.Store(&agents); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Peers ---
|
||||||
|
|
||||||
|
func dbusPeerList(w http.ResponseWriter, sessionID string) {
|
||||||
|
call, err := dbusCall("PeerList", sessionID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var peers []string
|
||||||
|
if err := call.Store(&peers); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if peers == nil {
|
||||||
|
peers = []string{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, peers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusPeerAdd(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
var req struct {
|
||||||
|
PeerID string `json:"peer_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call, err := dbusCall("PeerAdd", sessionID, req.PeerID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "peer add failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]bool{"added": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusPeerRemove(w http.ResponseWriter, sessionID, peerID string) {
|
||||||
|
call, err := dbusCall("PeerRemove", sessionID, peerID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbusPeerSubmit(w http.ResponseWriter, r *http.Request, sessionID, peerID string) {
|
||||||
|
var req struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
call, err := dbusCall("PeerSubmit", sessionID, peerID, req.Prompt)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var success bool
|
||||||
|
if err := call.Store(&success); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer submit failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"submitted": true})
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
|
|
@ -2,4 +2,7 @@ module ollie-httpgw
|
||||||
|
|
||||||
go 1.25.6
|
go 1.25.6
|
||||||
|
|
||||||
require 9fans.net/go v0.0.7
|
require (
|
||||||
|
9fans.net/go v0.0.7
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -3,6 +3,8 @@
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
|
|
||||||
495
main.go
495
main.go
|
|
@ -1,13 +1,15 @@
|
||||||
// ollie-httpgw - HTTP gateway for the ollie 9P server.
|
// ollie-httpgw - HTTP gateway for ollie session management.
|
||||||
//
|
//
|
||||||
// Maps HTTP methods to 9P file operations:
|
// Supports two backend modes:
|
||||||
//
|
//
|
||||||
// GET /path - read file content or list directory entries
|
// dbus (default) - translates HTTP to D-Bus calls against org.ollie.SessionManager
|
||||||
// PUT /path - write request body to file (truncate)
|
// 9p - translates HTTP to 9P file operations
|
||||||
// POST /path - create file (name = last path element)
|
//
|
||||||
// DELETE /path - remove file
|
// Usage:
|
||||||
// HEAD /path - stat file (metadata in headers)
|
//
|
||||||
// PATCH /path - wstat (rename via JSON {"name": "newname"})
|
// ollie-httpgw [start|fgstart|stop|status] # dbus mode (default)
|
||||||
|
// ollie-httpgw 9p [start|fgstart|stop|status] # 9p mode
|
||||||
|
// ollie-httpgw dbus [start|fgstart|stop|status] # explicit dbus mode
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -15,18 +17,14 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"9fans.net/go/plan9"
|
|
||||||
"9fans.net/go/plan9/client"
|
"9fans.net/go/plan9/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,7 +32,7 @@ import (
|
||||||
var openapiSpec []byte
|
var openapiSpec []byte
|
||||||
|
|
||||||
var (
|
var (
|
||||||
listen = flag.String("listen", ":8080", "HTTP listen address")
|
listen = flag.String("listen", ":8011", "HTTP listen address")
|
||||||
service = flag.String("service", "ollie", "9P service name to connect to")
|
service = flag.String("service", "ollie", "9P service name to connect to")
|
||||||
network = flag.String("net", "", "network type (unix, tcp); empty = use namespace")
|
network = flag.String("net", "", "network type (unix, tcp); empty = use namespace")
|
||||||
addr = flag.String("addr", "", "9P server address; empty = use namespace service")
|
addr = flag.String("addr", "", "9P server address; empty = use namespace service")
|
||||||
|
|
@ -42,16 +40,32 @@ var (
|
||||||
|
|
||||||
const pidFile = "ollie-httpgw.pid"
|
const pidFile = "ollie-httpgw.pid"
|
||||||
|
|
||||||
|
// backendMode tracks which backend we're using.
|
||||||
|
var backendMode = "dbus"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) >= 2 {
|
args := os.Args[1:]
|
||||||
subcmd := os.Args[1]
|
|
||||||
|
// Parse optional mode prefix: "dbus" or "9p"
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "dbus":
|
||||||
|
backendMode = "dbus"
|
||||||
|
args = args[1:]
|
||||||
|
case "9p":
|
||||||
|
backendMode = "9p"
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) >= 1 {
|
||||||
|
subcmd := args[0]
|
||||||
switch subcmd {
|
switch subcmd {
|
||||||
case "start", "fgstart", "stop", "status":
|
case "start", "fgstart", "stop", "status":
|
||||||
flag.CommandLine.Parse(os.Args[2:]) //nolint:errcheck
|
flag.CommandLine.Parse(args[1:]) //nolint:errcheck
|
||||||
ns := client.Namespace()
|
ns := client.Namespace()
|
||||||
if ns == "" {
|
if ns == "" {
|
||||||
fmt.Fprintln(os.Stderr, "no namespace")
|
ns = os.TempDir()
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
pidPath := filepath.Join(ns, pidFile)
|
pidPath := filepath.Join(ns, pidFile)
|
||||||
switch subcmd {
|
switch subcmd {
|
||||||
|
|
@ -60,7 +74,7 @@ func main() {
|
||||||
fmt.Println("ollie-httpgw already running")
|
fmt.Println("ollie-httpgw already running")
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
daemonize()
|
daemonize(args)
|
||||||
case "fgstart":
|
case "fgstart":
|
||||||
if gwRunning() {
|
if gwRunning() {
|
||||||
fmt.Println("ollie-httpgw already running")
|
fmt.Println("ollie-httpgw already running")
|
||||||
|
|
@ -80,8 +94,9 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: no subcommand — run in foreground directly (no pid file).
|
// Legacy: no subcommand — run in foreground directly (no pid file).
|
||||||
flag.Parse()
|
flag.CommandLine.Parse(args) //nolint:errcheck
|
||||||
serve()
|
serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,9 +109,9 @@ func gwRunning() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func daemonize() {
|
func daemonize(subcmdArgs []string) {
|
||||||
exe, _ := os.Executable()
|
exe, _ := os.Executable()
|
||||||
args := []string{"fgstart", "-listen", *listen, "-service", *service}
|
args := []string{backendMode, "fgstart", "-listen", *listen, "-service", *service}
|
||||||
if *network != "" {
|
if *network != "" {
|
||||||
args = append(args, "-net", *network)
|
args = append(args, "-net", *network)
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +124,7 @@ func daemonize() {
|
||||||
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
|
fmt.Fprintf(os.Stderr, "failed to start: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid)
|
fmt.Printf("ollie-httpgw started (pid %d, mode %s)\n", cmd.Process.Pid, backendMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopGateway(pidPath string) {
|
func stopGateway(pidPath string) {
|
||||||
|
|
@ -138,434 +153,22 @@ func serve() {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write(openapiSpec)
|
w.Write(openapiSpec)
|
||||||
})
|
})
|
||||||
http.HandleFunc("/", handler)
|
|
||||||
log.Printf("ollie-httpgw listening on %s", *listen)
|
switch backendMode {
|
||||||
|
case "dbus":
|
||||||
|
http.HandleFunc("/", dbusHandler)
|
||||||
|
case "9p":
|
||||||
|
http.HandleFunc("/", ninepHandler)
|
||||||
|
default:
|
||||||
|
log.Fatalf("unknown backend mode: %s", backendMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("ollie-httpgw listening on %s (mode: %s)", *listen, backendMode)
|
||||||
log.Fatal(http.ListenAndServe(*listen, nil))
|
log.Fatal(http.ListenAndServe(*listen, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func dial() (*client.Fsys, error) {
|
|
||||||
if *addr != "" {
|
|
||||||
n := *network
|
|
||||||
if n == "" {
|
|
||||||
n = "tcp"
|
|
||||||
}
|
|
||||||
return client.Mount(n, *addr)
|
|
||||||
}
|
|
||||||
return client.MountService(*service)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dirEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size uint64 `json:"size"`
|
|
||||||
Mode uint32 `json:"mode"`
|
|
||||||
Mtime uint32 `json:"mtime"`
|
|
||||||
IsDir bool `json:"is_dir"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func dirToEntry(d *plan9.Dir) dirEntry {
|
|
||||||
return dirEntry{
|
|
||||||
Name: d.Name,
|
|
||||||
Size: d.Length,
|
|
||||||
Mode: uint32(d.Mode),
|
|
||||||
Mtime: d.Mtime,
|
|
||||||
IsDir: d.Mode&plan9.DMDIR != 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) {
|
|
||||||
w.Header().Set("X-9p-Name", d.Name)
|
|
||||||
w.Header().Set("X-9p-Size", fmt.Sprintf("%d", d.Length))
|
|
||||||
w.Header().Set("X-9p-Mode", fmt.Sprintf("%o", d.Mode))
|
|
||||||
w.Header().Set("X-9p-Mtime", fmt.Sprintf("%d", d.Mtime))
|
|
||||||
if d.Mode&plan9.DMDIR != 0 {
|
|
||||||
w.Header().Set("X-9p-Type", "dir")
|
|
||||||
} else {
|
|
||||||
w.Header().Set("X-9p-Type", "file")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/open" {
|
|
||||||
handleOpen(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fsys, err := dial()
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer fsys.Close()
|
|
||||||
|
|
||||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
||||||
|
|
||||||
// Internal paths not exposed via HTTP.
|
|
||||||
first, _, _ := strings.Cut(p, "/")
|
|
||||||
// Paths never exposed via HTTP.
|
|
||||||
switch first {
|
|
||||||
case "p", "help", "tr", "u", "x", "tmp":
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Read-only paths — only GET and HEAD allowed.
|
|
||||||
switch first {
|
|
||||||
case "", "sk":
|
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "read-only"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
base := path.Base(p)
|
|
||||||
switch base {
|
|
||||||
case "systemprompt", "statewait":
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
||||||
return
|
|
||||||
case "new":
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
handleWrite(w, r, fsys, p)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
handleRead(w, fsys, p)
|
|
||||||
case http.MethodHead:
|
|
||||||
handleStat(w, fsys, p)
|
|
||||||
case http.MethodPost:
|
|
||||||
if base == "chat" {
|
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleWrite(w, r, fsys, p)
|
|
||||||
case http.MethodPut:
|
|
||||||
handlePut(w, r, fsys, p)
|
|
||||||
case http.MethodDelete:
|
|
||||||
handleRemove(w, fsys, p)
|
|
||||||
case http.MethodPatch:
|
|
||||||
handleWstat(w, r, fsys, p)
|
|
||||||
case http.MethodOptions:
|
|
||||||
handleOptions(w, r, p)
|
|
||||||
default:
|
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allowedMethods = "GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS"
|
|
||||||
|
|
||||||
var methodDescriptions = map[string]string{
|
|
||||||
"GET": "read file content or list directory entries",
|
|
||||||
"HEAD": "stat file (metadata in response headers)",
|
|
||||||
"PUT": "write raw body to file (truncate)",
|
|
||||||
"POST": "write to file or trigger action",
|
|
||||||
"DELETE": "remove file or session",
|
|
||||||
"PATCH": "rename via JSON {\"name\": \"newname\"}",
|
|
||||||
"OPTIONS": "describe available operations for this path",
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleOptions(w http.ResponseWriter, _ *http.Request, p string) {
|
|
||||||
w.Header().Set("Allow", allowedMethods)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"path": "/" + p,
|
|
||||||
"methods": methodDescriptions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
|
||||||
d, err := fsys.Stat(p)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStatHeaders(w, d)
|
|
||||||
|
|
||||||
if d.Mode&plan9.DMDIR != 0 {
|
|
||||||
fid, err := fsys.Open(p, plan9.OREAD)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer fid.Close()
|
|
||||||
dirs, err := fid.Dirreadall()
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p == "s" {
|
|
||||||
var ids []string
|
|
||||||
for _, de := range dirs {
|
|
||||||
if de.Mode&plan9.DMDIR != 0 {
|
|
||||||
ids = append(ids, de.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, ids)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries := make([]dirEntry, len(dirs))
|
|
||||||
for i, de := range dirs {
|
|
||||||
entries[i] = dirToEntry(de)
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, entries)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fid, err := fsys.Open(p, plan9.OREAD)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer fid.Close()
|
|
||||||
content, err := io.ReadAll(fid)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p == "models" {
|
|
||||||
writeJSON(w, http.StatusOK, parseModels(content))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p == "s/idx" {
|
|
||||||
writeJSON(w, http.StatusOK, parseIdx(content))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if path.Base(p) == "chat" {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.Write(content)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if path.Base(p) == "cfg" {
|
|
||||||
m := parseContent(content, "cfg")
|
|
||||||
if mm, ok := m.(map[string]string); ok {
|
|
||||||
// Inject state from sibling file.
|
|
||||||
stateFile := path.Dir(p) + "/state"
|
|
||||||
if sf, err := fsys.Open(stateFile, plan9.OREAD); err == nil {
|
|
||||||
if sb, err := io.ReadAll(sf); err == nil {
|
|
||||||
mm["state"] = strings.TrimSpace(string(sb))
|
|
||||||
}
|
|
||||||
sf.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, m)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, parseContent(content, path.Base(p)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type idxEntry struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
State string `json:"state"`
|
|
||||||
CWD string `json:"cwd"`
|
|
||||||
Backend string `json:"backend"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelsEntry struct {
|
|
||||||
Backend string `json:"backend"`
|
|
||||||
Models []string `json:"models"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseModels(data []byte) []modelsEntry {
|
|
||||||
groups := make(map[string][]string)
|
|
||||||
var order []string
|
|
||||||
for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b, m, ok := strings.Cut(line, "\t")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := groups[b]; !exists {
|
|
||||||
order = append(order, b)
|
|
||||||
}
|
|
||||||
groups[b] = append(groups[b], m)
|
|
||||||
}
|
|
||||||
out := make([]modelsEntry, len(order))
|
|
||||||
for i, b := range order {
|
|
||||||
out[i] = modelsEntry{Backend: b, Models: groups[b]}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseIdx(data []byte) []idxEntry {
|
|
||||||
var entries []idxEntry
|
|
||||||
for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields := strings.SplitN(line, "\t", 5)
|
|
||||||
if len(fields) != 5 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, idxEntry{
|
|
||||||
ID: fields[0],
|
|
||||||
State: fields[1],
|
|
||||||
CWD: fields[2],
|
|
||||||
Backend: fields[3],
|
|
||||||
Model: fields[4],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseContent detects the wire format and returns the appropriate Go value:
|
|
||||||
// - key=value lines → map[string]string
|
|
||||||
// - tab-separated records → [][]string
|
|
||||||
// - multiple newline-separated values → []string
|
|
||||||
// - single value → map[string]string{name: value}
|
|
||||||
func parseContent(data []byte, name string) any {
|
|
||||||
s := strings.TrimRight(string(data), "\n")
|
|
||||||
if s == "" {
|
|
||||||
return map[string]string{}
|
|
||||||
}
|
|
||||||
first, _, _ := strings.Cut(s, "\n")
|
|
||||||
if strings.Contains(first, "=") {
|
|
||||||
m := make(map[string]string)
|
|
||||||
for line := range strings.SplitSeq(s, "\n") {
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
k, v, _ := strings.Cut(line, "=")
|
|
||||||
m[k] = v
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
if strings.Contains(first, "\t") {
|
|
||||||
var rows [][]string
|
|
||||||
for line := range strings.SplitSeq(s, "\n") {
|
|
||||||
if line != "" {
|
|
||||||
rows = append(rows, strings.Split(line, "\t"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
var lines []string
|
|
||||||
for line := range strings.SplitSeq(s, "\n") {
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
if len(lines) == 1 {
|
|
||||||
return map[string]string{name: lines[0]}
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// serializeKV serializes a map to newline-separated key=value lines.
|
|
||||||
func serializeKV(m map[string]any) []byte {
|
|
||||||
var sb strings.Builder
|
|
||||||
for k, v := range m {
|
|
||||||
fmt.Fprintf(&sb, "%s=%v\n", k, v)
|
|
||||||
}
|
|
||||||
return []byte(sb.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
|
||||||
d, err := fsys.Stat(p)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStatHeaders(w, d)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleOpen(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p := r.URL.Query().Get("path")
|
|
||||||
if p == "" {
|
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing path"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
editor := os.Getenv("EDITOR")
|
|
||||||
if editor == "" {
|
|
||||||
editor = "xdg-open"
|
|
||||||
}
|
|
||||||
if err := exec.Command(editor, p).Start(); err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"opened": p})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlePut(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fid, err := fsys.Open(p, plan9.OWRITE|plan9.OTRUNC)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, err := fid.Write(body)
|
|
||||||
fid.Close()
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
|
||||||
var m map[string]any
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// If the body has a single key matching the file name, write just the value.
|
|
||||||
// Otherwise serialize as key=value lines (e.g. for "new").
|
|
||||||
var payload []byte
|
|
||||||
base := path.Base(p)
|
|
||||||
if len(m) == 1 {
|
|
||||||
if v, ok := m[base]; ok {
|
|
||||||
payload = []byte(fmt.Sprintf("%v", v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if payload == nil {
|
|
||||||
payload = serializeKV(m)
|
|
||||||
}
|
|
||||||
fid, err := fsys.Open(p, plan9.OWRITE)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
n, err := fid.Write(payload)
|
|
||||||
fid.Close()
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
|
||||||
if err := fsys.Remove(p); err != nil {
|
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d := &plan9.Dir{Name: req.Name}
|
|
||||||
if err := fsys.Wstat(p, d); err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"9fans.net/go/plan9"
|
||||||
|
"9fans.net/go/plan9/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dial() (*client.Fsys, error) {
|
||||||
|
if *addr != "" {
|
||||||
|
n := *network
|
||||||
|
if n == "" {
|
||||||
|
n = "tcp"
|
||||||
|
}
|
||||||
|
return client.Mount(n, *addr)
|
||||||
|
}
|
||||||
|
return client.MountService(*service)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dirEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Mode uint32 `json:"mode"`
|
||||||
|
Mtime uint32 `json:"mtime"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirToEntry(d *plan9.Dir) dirEntry {
|
||||||
|
return dirEntry{
|
||||||
|
Name: d.Name,
|
||||||
|
Size: d.Length,
|
||||||
|
Mode: uint32(d.Mode),
|
||||||
|
Mtime: d.Mtime,
|
||||||
|
IsDir: d.Mode&plan9.DMDIR != 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) {
|
||||||
|
w.Header().Set("X-9p-Name", d.Name)
|
||||||
|
w.Header().Set("X-9p-Size", fmt.Sprintf("%d", d.Length))
|
||||||
|
w.Header().Set("X-9p-Mode", fmt.Sprintf("%o", d.Mode))
|
||||||
|
w.Header().Set("X-9p-Mtime", fmt.Sprintf("%d", d.Mtime))
|
||||||
|
if d.Mode&plan9.DMDIR != 0 {
|
||||||
|
w.Header().Set("X-9p-Type", "dir")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("X-9p-Type", "file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ninepHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/open" {
|
||||||
|
handleOpen(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fsys, err := dial()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fsys.Close()
|
||||||
|
|
||||||
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
|
||||||
|
// Internal paths not exposed via HTTP.
|
||||||
|
first, _, _ := strings.Cut(p, "/")
|
||||||
|
switch first {
|
||||||
|
case "p", "help", "tr", "u", "x", "tmp":
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read-only paths — only GET and HEAD allowed.
|
||||||
|
switch first {
|
||||||
|
case "", "sk":
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "read-only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := path.Base(p)
|
||||||
|
switch base {
|
||||||
|
case "systemprompt", "statewait":
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
case "new":
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
handleWrite(w, r, fsys, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
handleRead(w, fsys, p)
|
||||||
|
case http.MethodHead:
|
||||||
|
handleStat(w, fsys, p)
|
||||||
|
case http.MethodPost:
|
||||||
|
if base == "chat" {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleWrite(w, r, fsys, p)
|
||||||
|
case http.MethodPut:
|
||||||
|
handlePut(w, r, fsys, p)
|
||||||
|
case http.MethodDelete:
|
||||||
|
handleRemove(w, fsys, p)
|
||||||
|
case http.MethodPatch:
|
||||||
|
handleWstat(w, r, fsys, p)
|
||||||
|
case http.MethodOptions:
|
||||||
|
handleNinepOptions(w, r, p)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedMethods = "GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS"
|
||||||
|
|
||||||
|
var methodDescriptions = map[string]string{
|
||||||
|
"GET": "read file content or list directory entries",
|
||||||
|
"HEAD": "stat file (metadata in response headers)",
|
||||||
|
"PUT": "write raw body to file (truncate)",
|
||||||
|
"POST": "write to file or trigger action",
|
||||||
|
"DELETE": "remove file or session",
|
||||||
|
"PATCH": "rename via JSON {\"name\": \"newname\"}",
|
||||||
|
"OPTIONS": "describe available operations for this path",
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNinepOptions(w http.ResponseWriter, _ *http.Request, p string) {
|
||||||
|
w.Header().Set("Allow", allowedMethods)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"path": "/" + p,
|
||||||
|
"methods": methodDescriptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
||||||
|
d, err := fsys.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatHeaders(w, d)
|
||||||
|
|
||||||
|
if d.Mode&plan9.DMDIR != 0 {
|
||||||
|
fid, err := fsys.Open(p, plan9.OREAD)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fid.Close()
|
||||||
|
dirs, err := fid.Dirreadall()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p == "s" {
|
||||||
|
var ids []string
|
||||||
|
for _, de := range dirs {
|
||||||
|
if de.Mode&plan9.DMDIR != 0 {
|
||||||
|
ids = append(ids, de.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ids)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries := make([]dirEntry, len(dirs))
|
||||||
|
for i, de := range dirs {
|
||||||
|
entries[i] = dirToEntry(de)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, entries)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fid, err := fsys.Open(p, plan9.OREAD)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fid.Close()
|
||||||
|
content, err := io.ReadAll(fid)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p == "models" {
|
||||||
|
writeJSON(w, http.StatusOK, parseModels(content))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p == "s/idx" {
|
||||||
|
writeJSON(w, http.StatusOK, parseIdx(content))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if path.Base(p) == "chat" {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Write(content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if path.Base(p) == "cfg" {
|
||||||
|
m := parseContent(content, "cfg")
|
||||||
|
if mm, ok := m.(map[string]string); ok {
|
||||||
|
// Inject state from sibling file.
|
||||||
|
stateFile := path.Dir(p) + "/state"
|
||||||
|
if sf, err := fsys.Open(stateFile, plan9.OREAD); err == nil {
|
||||||
|
if sb, err := io.ReadAll(sf); err == nil {
|
||||||
|
mm["state"] = strings.TrimSpace(string(sb))
|
||||||
|
}
|
||||||
|
sf.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, parseContent(content, path.Base(p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
type idxEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CWD string `json:"cwd"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelsEntry struct {
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Models []string `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseModels(data []byte) []modelsEntry {
|
||||||
|
groups := make(map[string][]string)
|
||||||
|
var order []string
|
||||||
|
for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, m, ok := strings.Cut(line, "\t")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := groups[b]; !exists {
|
||||||
|
order = append(order, b)
|
||||||
|
}
|
||||||
|
groups[b] = append(groups[b], m)
|
||||||
|
}
|
||||||
|
out := make([]modelsEntry, len(order))
|
||||||
|
for i, b := range order {
|
||||||
|
out[i] = modelsEntry{Backend: b, Models: groups[b]}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIdx(data []byte) []idxEntry {
|
||||||
|
var entries []idxEntry
|
||||||
|
for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.SplitN(line, "\t", 5)
|
||||||
|
if len(fields) != 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, idxEntry{
|
||||||
|
ID: fields[0],
|
||||||
|
State: fields[1],
|
||||||
|
CWD: fields[2],
|
||||||
|
Backend: fields[3],
|
||||||
|
Model: fields[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContent detects the wire format and returns the appropriate Go value:
|
||||||
|
// - key=value lines → map[string]string
|
||||||
|
// - tab-separated records → [][]string
|
||||||
|
// - multiple newline-separated values → []string
|
||||||
|
// - single value → map[string]string{name: value}
|
||||||
|
func parseContent(data []byte, name string) any {
|
||||||
|
s := strings.TrimRight(string(data), "\n")
|
||||||
|
if s == "" {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
first, _, _ := strings.Cut(s, "\n")
|
||||||
|
if strings.Contains(first, "=") {
|
||||||
|
m := make(map[string]string)
|
||||||
|
for line := range strings.SplitSeq(s, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, _ := strings.Cut(line, "=")
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if strings.Contains(first, "\t") {
|
||||||
|
var rows [][]string
|
||||||
|
for line := range strings.SplitSeq(s, "\n") {
|
||||||
|
if line != "" {
|
||||||
|
rows = append(rows, strings.Split(line, "\t"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
for line := range strings.SplitSeq(s, "\n") {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
if len(lines) == 1 {
|
||||||
|
return map[string]string{name: lines[0]}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeKV serializes a map to newline-separated key=value lines.
|
||||||
|
func serializeKV(m map[string]any) []byte {
|
||||||
|
var sb strings.Builder
|
||||||
|
for k, v := range m {
|
||||||
|
fmt.Fprintf(&sb, "%s=%v\n", k, v)
|
||||||
|
}
|
||||||
|
return []byte(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
||||||
|
d, err := fsys.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatHeaders(w, d)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := r.URL.Query().Get("path")
|
||||||
|
if p == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editor := os.Getenv("EDITOR")
|
||||||
|
if editor == "" {
|
||||||
|
editor = "xdg-open"
|
||||||
|
}
|
||||||
|
if err := exec.Command(editor, p).Start(); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"opened": p})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePut(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fid, err := fsys.Open(p, plan9.OWRITE|plan9.OTRUNC)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := fid.Write(body)
|
||||||
|
fid.Close()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If the body has a single key matching the file name, write just the value.
|
||||||
|
// Otherwise serialize as key=value lines (e.g. for "new").
|
||||||
|
var payload []byte
|
||||||
|
base := path.Base(p)
|
||||||
|
if len(m) == 1 {
|
||||||
|
if v, ok := m[base]; ok {
|
||||||
|
payload = []byte(fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload == nil {
|
||||||
|
payload = serializeKV(m)
|
||||||
|
}
|
||||||
|
fid, err := fsys.Open(p, plan9.OWRITE)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := fid.Write(payload)
|
||||||
|
fid.Close()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) {
|
||||||
|
if err := fsys.Remove(p); err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := &plan9.Dir{Name: req.Name}
|
||||||
|
if err := fsys.Wstat(p, d); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
384
openapi.json
384
openapi.json
|
|
@ -2,110 +2,92 @@
|
||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "ollie-httpgw",
|
"title": "ollie-httpgw",
|
||||||
"description": "HTTP gateway for the ollie 9P server.",
|
"description": "HTTP gateway for ollie session management. Supports D-Bus (default) and 9P backend modes.",
|
||||||
"version": "1.0.0"
|
"version": "2.0.0"
|
||||||
},
|
},
|
||||||
|
"servers": [
|
||||||
|
{ "url": "http://localhost:8011", "description": "Default local gateway" }
|
||||||
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/{path}": {
|
"/sessions": {
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"description": "9P file or directory path (may contain slashes for nested paths)",
|
|
||||||
"schema": { "type": "string" },
|
|
||||||
"example": "s/idx"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Read file or list directory",
|
"tags": ["dbus"],
|
||||||
"description": "Returns parsed file content as a JSON object or array, or a directory listing.",
|
"summary": "List sessions",
|
||||||
|
"description": "Returns all active sessions with their id, state, model, and agent.",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "File content or directory listing",
|
"description": "Session list",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"oneOf": [
|
"type": "array",
|
||||||
{ "title": "Key-value file", "type": "object", "additionalProperties": { "type": "string" } },
|
"items": { "$ref": "#/components/schemas/SessionSummary" }
|
||||||
{ "title": "List file", "type": "array", "items": { "type": "string" } },
|
|
||||||
{ "title": "Session index", "type": "array", "items": { "$ref": "#/components/schemas/IdxEntry" } },
|
|
||||||
{ "title": "Directory", "type": "array", "items": { "$ref": "#/components/schemas/DirEntry" } }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"404": { "$ref": "#/components/responses/Error" },
|
|
||||||
"502": { "$ref": "#/components/responses/Error" }
|
"502": { "$ref": "#/components/responses/Error" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"head": {
|
|
||||||
"summary": "Stat file or directory",
|
|
||||||
"description": "Returns 9P metadata in response headers. No body.",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Stat headers set",
|
|
||||||
"headers": { "$ref": "#/components/headers/StatHeaders" }
|
|
||||||
},
|
|
||||||
"404": { "$ref": "#/components/responses/Error" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Write to file or trigger action",
|
"tags": ["dbus"],
|
||||||
"description": "Writes a JSON object to a file, serialized as key=value lines. Used for session creation (POST /s/new), config updates (POST /s/{id}/cfg), and control actions (POST /s/{id}/ctl, /s/{id}/prompt, /s/{id}/fifo.in).",
|
"summary": "Create a new session",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": false,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": { "$ref": "#/components/schemas/CreateSessionRequest" }
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"201": {
|
||||||
"description": "Bytes written",
|
"description": "Session created",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": { "id": { "type": "string" } },
|
||||||
"bytes_written": { "type": "integer" }
|
"required": ["id"]
|
||||||
},
|
|
||||||
"required": ["bytes_written"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": { "$ref": "#/components/responses/Error" },
|
"400": { "$ref": "#/components/responses/Error" },
|
||||||
"404": { "$ref": "#/components/responses/Error" },
|
"502": { "$ref": "#/components/responses/Error" }
|
||||||
"405": { "$ref": "#/components/responses/Error" },
|
}
|
||||||
"500": { "$ref": "#/components/responses/Error" }
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get session config/detail",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Session config as key-value object",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"summary": "Remove file or session",
|
"tags": ["dbus"],
|
||||||
"description": "Removes the file or kills the session at the given path.",
|
"summary": "Kill session",
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": { "description": "Removed" },
|
"204": { "description": "Session killed" },
|
||||||
"404": { "$ref": "#/components/responses/Error" }
|
"404": { "$ref": "#/components/responses/Error" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patch": {
|
"patch": {
|
||||||
"summary": "Rename file or session",
|
"tags": ["dbus"],
|
||||||
"description": "Renames the file or session via 9P wstat.",
|
"summary": "Rename session",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": { "name": { "type": "string" } },
|
||||||
"name": { "type": "string", "description": "New name" }
|
|
||||||
},
|
|
||||||
"required": ["name"]
|
"required": ["name"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,67 +95,261 @@
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": { "description": "Renamed" },
|
"204": { "description": "Renamed" },
|
||||||
"400": { "$ref": "#/components/responses/Error" },
|
"404": { "$ref": "#/components/responses/Error" }
|
||||||
"500": { "$ref": "#/components/responses/Error" }
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"options": {
|
},
|
||||||
"summary": "Describe available operations",
|
"/sessions/{id}/submit": {
|
||||||
"description": "Returns the allowed HTTP methods and a description of each operation for this path.",
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
"responses": {
|
"post": {
|
||||||
"200": {
|
"tags": ["dbus"],
|
||||||
"description": "Available methods",
|
"summary": "Submit a prompt to the session",
|
||||||
"content": {
|
"requestBody": {
|
||||||
"application/json": {
|
"required": true,
|
||||||
"schema": { "$ref": "#/components/schemas/OptionsResponse" }
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "prompt": { "type": "string" } },
|
||||||
|
"required": ["prompt"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Submitted", "content": { "application/json": { "schema": { "type": "object", "properties": { "submitted": { "type": "boolean" } } } } } },
|
||||||
|
"404": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/interrupt": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"post": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Interrupt the running session",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Interrupted", "content": { "application/json": { "schema": { "type": "object", "properties": { "interrupted": { "type": "boolean" } } } } } },
|
||||||
|
"404": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/state": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get session state",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "State", "content": { "application/json": { "schema": { "type": "object", "properties": { "state": { "type": "string" } } } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/chat": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "format": "int64", "default": 0 }, "description": "Byte offset to resume from" }
|
||||||
|
],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get chat transcript",
|
||||||
|
"description": "Returns chat text from the given offset. The X-Chat-Offset response header contains the new offset for subsequent requests.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Chat text",
|
||||||
|
"headers": {
|
||||||
|
"X-Chat-Offset": { "schema": { "type": "integer", "format": "int64" }, "description": "New byte offset for next poll" }
|
||||||
|
},
|
||||||
|
"content": { "text/plain": { "schema": { "type": "string" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/usage": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get token usage",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Usage string", "content": { "application/json": { "schema": { "type": "object", "properties": { "usage": { "type": "string" } } } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/cost": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get session cost",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Cost string", "content": { "application/json": { "schema": { "type": "object", "properties": { "cost": { "type": "string" } } } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/config": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get session config",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Config as key-value", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Set a config value",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": { "type": "string" },
|
||||||
|
"value": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["key", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } },
|
||||||
|
"400": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/context": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Get message context (JSONL)",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "JSONL messages", "content": { "application/x-ndjson": { "schema": { "type": "string" } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/models": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "List models for session's backend",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Model list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/peers": {
|
||||||
|
"parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "List peers",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Peer IDs", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Add a peer",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "peer_id": { "type": "string" } },
|
||||||
|
"required": ["peer_id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": { "description": "Peer added" },
|
||||||
|
"400": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/peers/{peer_id}": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
],
|
||||||
|
"delete": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Remove a peer",
|
||||||
|
"responses": {
|
||||||
|
"204": { "description": "Peer removed" },
|
||||||
|
"404": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/sessions/{id}/peers/{peer_id}/submit": {
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||||
|
{ "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||||
|
],
|
||||||
|
"post": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "Submit prompt to a peer session",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "prompt": { "type": "string" } },
|
||||||
|
"required": ["prompt"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Submitted" },
|
||||||
|
"404": { "$ref": "#/components/responses/Error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/backends": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "List available backends",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Backend names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/agents": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["dbus"],
|
||||||
|
"summary": "List available agents",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Agent names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"IdxEntry": {
|
"SessionSummary": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string" },
|
"id": { "type": "string" },
|
||||||
"state": { "type": "string" },
|
"state": { "type": "string" },
|
||||||
"cwd": { "type": "string" },
|
"model": { "type": "string" },
|
||||||
"backend": { "type": "string" },
|
"agent": { "type": "string" }
|
||||||
"model": { "type": "string" }
|
|
||||||
},
|
},
|
||||||
"required": ["id", "state", "cwd", "backend", "model"]
|
"required": ["id", "state", "model", "agent"]
|
||||||
},
|
},
|
||||||
"DirEntry": {
|
"CreateSessionRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string" },
|
"cwd": { "type": "string", "description": "Working directory (defaults to $HOME)" },
|
||||||
"size": { "type": "integer", "format": "uint64" },
|
"backend": { "type": "string", "description": "Backend name (e.g. ollama, anthropic)" },
|
||||||
"mode": { "type": "integer", "format": "uint32" },
|
"model": { "type": "string", "description": "Model name" },
|
||||||
"mtime": { "type": "integer", "format": "uint32" },
|
"agent": { "type": "string", "description": "Agent name (default: 'default')" }
|
||||||
"is_dir": { "type": "boolean" }
|
}
|
||||||
},
|
|
||||||
"required": ["name", "size", "mode", "mtime", "is_dir"]
|
|
||||||
},
|
},
|
||||||
"Error": {
|
"Error": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": { "error": { "type": "string" } },
|
||||||
"error": { "type": "string" }
|
|
||||||
},
|
|
||||||
"required": ["error"]
|
"required": ["error"]
|
||||||
},
|
|
||||||
"OptionsResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": { "type": "string" },
|
|
||||||
"methods": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["path", "methods"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
@ -185,16 +361,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"headers": {
|
|
||||||
"StatHeaders": {
|
|
||||||
"description": "9P stat fields returned as response headers",
|
|
||||||
"X-9p-Name": { "schema": { "type": "string" } },
|
|
||||||
"X-9p-Size": { "schema": { "type": "integer" } },
|
|
||||||
"X-9p-Mode": { "schema": { "type": "string" } },
|
|
||||||
"X-9p-Mtime": { "schema": { "type": "integer" } },
|
|
||||||
"X-9p-Type": { "schema": { "type": "string", "enum": ["file", "dir"] } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue