mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-22 00:08:30 +00:00
Massive PR, over 13k LOC updated, 128 commits to implement the first pass at the new Wave AI panel. Two backend adapters (OpenAI and Anthropic), layout changes to support the panel, keyboard shortcuts, and a huge focus/layout change to integrate the panel seamlessly into the UI. Also fixes some small issues found during the Wave AI journey (zoom fixes, documentation, more scss removal, circular dependency issues, settings, etc)
528 lines
14 KiB
Go
528 lines
14 KiB
Go
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
|
|
"github.com/wavetermdev/waveterm/tsunami/util"
|
|
)
|
|
|
|
const SSEKeepAliveDuration = 5 * time.Second
|
|
|
|
func init() {
|
|
// Add explicit mapping for .json files
|
|
mime.AddExtensionType(".json", "application/json")
|
|
}
|
|
|
|
type handlerOpts struct {
|
|
AssetsFS fs.FS
|
|
StaticFS fs.FS
|
|
ManifestFile []byte
|
|
}
|
|
|
|
type httpHandlers struct {
|
|
Client *ClientImpl
|
|
renderLock sync.Mutex
|
|
}
|
|
|
|
func newHTTPHandlers(client *ClientImpl) *httpHandlers {
|
|
return &httpHandlers{
|
|
Client: client,
|
|
}
|
|
}
|
|
|
|
func setNoCacheHeaders(w http.ResponseWriter) {
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.Header().Set("Expires", "0")
|
|
}
|
|
|
|
func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
|
|
mux.HandleFunc("/api/render", h.handleRender)
|
|
mux.HandleFunc("/api/updates", h.handleSSE)
|
|
mux.HandleFunc("/api/data", h.handleData)
|
|
mux.HandleFunc("/api/config", h.handleConfig)
|
|
mux.HandleFunc("/api/schemas", h.handleSchemas)
|
|
mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
|
|
mux.HandleFunc("/dyn/", h.handleDynContent)
|
|
|
|
// Add handler for static files at /static/ path
|
|
if opts.StaticFS != nil {
|
|
mux.HandleFunc("/static/", h.handleStaticPathFiles(opts.StaticFS))
|
|
}
|
|
|
|
// Add fallback handler for embedded static files in production mode
|
|
if opts.AssetsFS != nil {
|
|
mux.HandleFunc("/", h.handleStaticFiles(opts.AssetsFS))
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleRender", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var feUpdate rpctypes.VDomFrontendUpdate
|
|
if err := json.Unmarshal(body, &feUpdate); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if feUpdate.ForceTakeover {
|
|
h.Client.clientTakeover(feUpdate.ClientId)
|
|
}
|
|
|
|
if err := h.Client.checkClientId(feUpdate.ClientId); err != nil {
|
|
http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
startTime := time.Now()
|
|
update, err := h.processFrontendUpdate(&feUpdate)
|
|
duration := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if update == nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
if os.Getenv("TSUNAMI_DEBUG") != "" {
|
|
log.Printf("render %4s %4dms %4dk %s", "none", duration.Milliseconds(), 0, feUpdate.Reason)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Encode to bytes first to calculate size
|
|
responseBytes, err := json.Marshal(update)
|
|
if err != nil {
|
|
log.Printf("failed to encode response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
updateSizeKB := len(responseBytes) / 1024
|
|
renderType := "inc"
|
|
if update.FullUpdate {
|
|
renderType = "full"
|
|
}
|
|
if os.Getenv("TSUNAMI_DEBUG") != "" {
|
|
log.Printf("render %4s %4dms %4dk %s", renderType, duration.Milliseconds(), updateSizeKB, feUpdate.Reason)
|
|
}
|
|
|
|
if _, err := w.Write(responseBytes); err != nil {
|
|
log.Printf("failed to write response: %v", err)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpdate) (*rpctypes.VDomBackendUpdate, error) {
|
|
h.renderLock.Lock()
|
|
defer h.renderLock.Unlock()
|
|
|
|
if feUpdate.Dispose {
|
|
log.Printf("got dispose from frontend\n")
|
|
h.Client.doShutdown("got dispose from frontend")
|
|
return nil, nil
|
|
}
|
|
|
|
if h.Client.GetIsDone() {
|
|
return nil, nil
|
|
}
|
|
|
|
h.Client.Root.RenderTs = feUpdate.Ts
|
|
|
|
// run events
|
|
h.Client.RunEvents(feUpdate.Events)
|
|
// update refs
|
|
for _, ref := range feUpdate.RefUpdates {
|
|
h.Client.Root.UpdateRef(ref)
|
|
}
|
|
|
|
var update *rpctypes.VDomBackendUpdate
|
|
var renderErr error
|
|
|
|
if feUpdate.Resync || true {
|
|
update, renderErr = h.Client.fullRender()
|
|
} else {
|
|
update, renderErr = h.Client.incrementalRender()
|
|
}
|
|
|
|
if renderErr != nil {
|
|
return nil, renderErr
|
|
}
|
|
|
|
update.CreateTransferElems()
|
|
return update, nil
|
|
}
|
|
|
|
func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleData", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
result := h.Client.Root.GetDataMap()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("failed to encode data response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleConfig", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.handleConfigGet(w, r)
|
|
case http.MethodPost:
|
|
h.handleConfigPost(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleConfigGet(w http.ResponseWriter, _ *http.Request) {
|
|
result := h.Client.Root.GetConfigMap()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("failed to encode config response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var configData map[string]any
|
|
if err := json.Unmarshal(body, &configData); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var failedKeys []string
|
|
for key, value := range configData {
|
|
atomName := "$config." + key
|
|
if err := h.Client.Root.SetAtomVal(atomName, value); err != nil {
|
|
failedKeys = append(failedKeys, key)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
var response map[string]any
|
|
if len(failedKeys) > 0 {
|
|
response = map[string]any{
|
|
"error": fmt.Sprintf("Failed to update keys: %s", strings.Join(failedKeys, ", ")),
|
|
}
|
|
} else {
|
|
response = map[string]any{
|
|
"success": true,
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleSchemas", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
configSchema := GenerateConfigSchema(h.Client.Root)
|
|
dataSchema := GenerateDataSchema(h.Client.Root)
|
|
|
|
result := map[string]any{
|
|
"config": configSchema,
|
|
"data": dataSchema,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("failed to encode schemas response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleDynContent", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
// Strip /assets prefix and update the request URL
|
|
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/dyn")
|
|
if r.URL.Path == "" {
|
|
r.URL.Path = "/"
|
|
}
|
|
|
|
h.Client.UrlHandlerMux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleSSE", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
clientId := r.URL.Query().Get("clientId")
|
|
if err := h.Client.checkClientId(clientId); err != nil {
|
|
http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Generate unique connection ID for this SSE connection
|
|
connectionId := fmt.Sprintf("%s-%d", clientId, time.Now().UnixNano())
|
|
|
|
// Register SSE channel for this connection
|
|
eventCh := h.Client.RegisterSSEChannel(connectionId)
|
|
defer h.Client.UnregisterSSEChannel(connectionId)
|
|
|
|
// Set SSE headers
|
|
setNoCacheHeaders(w)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// Use ResponseController for better flushing control
|
|
rc := http.NewResponseController(w)
|
|
if err := rc.Flush(); err != nil {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create a ticker for keepalive packets
|
|
keepaliveTicker := time.NewTicker(SSEKeepAliveDuration)
|
|
defer keepaliveTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-keepaliveTicker.C:
|
|
// Send keepalive comment
|
|
fmt.Fprintf(w, ": keepalive\n\n")
|
|
rc.Flush()
|
|
case event := <-eventCh:
|
|
if event.Event == "" {
|
|
break
|
|
}
|
|
fmt.Fprintf(w, "event: %s\n", event.Event)
|
|
fmt.Fprintf(w, "data: %s\n", string(event.Data))
|
|
fmt.Fprintf(w, "\n")
|
|
rc.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// serveFileDirectly serves a file directly from an embed.FS to avoid redirect loops
|
|
// when serving directory paths that end with "/"
|
|
func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, requestPath, fileName string) bool {
|
|
if !strings.HasSuffix(requestPath, "/") {
|
|
return false
|
|
}
|
|
|
|
// Try to serve the specified file from that directory
|
|
var filePath string
|
|
if requestPath == "/" {
|
|
filePath = fileName
|
|
} else {
|
|
filePath = strings.TrimPrefix(requestPath, "/") + fileName
|
|
}
|
|
|
|
file, err := embeddedFS.Open(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file info for modification time
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Serve the file directly with proper mod time
|
|
http.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker))
|
|
return true
|
|
}
|
|
|
|
func (h *httpHandlers) handleStaticFiles(embeddedFS fs.FS) http.HandlerFunc {
|
|
fileServer := http.FileServer(http.FS(embeddedFS))
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleStaticFiles", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
// Skip if this is an API, files, or static request (already handled by other handlers)
|
|
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") || strings.HasPrefix(r.URL.Path, "/static/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Handle any path ending with "/" to avoid redirect loops
|
|
if serveFileDirectly(w, r, embeddedFS, r.URL.Path, "index.html") {
|
|
return
|
|
}
|
|
|
|
// For other files, check if they exist before serving
|
|
filePath := strings.TrimPrefix(r.URL.Path, "/")
|
|
_, err := embeddedFS.Open(filePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Serve the file using the file server
|
|
fileServer.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleManifest", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if manifestFileBytes == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(manifestFileBytes)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleStaticPathFiles", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
// Strip /static/ prefix from the path
|
|
filePath := strings.TrimPrefix(r.URL.Path, "/static/")
|
|
if filePath == "" {
|
|
// Handle requests to "/static/" directly
|
|
if serveFileDirectly(w, r, staticFS, "/", "index.html") {
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Handle directory paths ending with "/" to avoid redirect loops
|
|
strippedPath := "/" + filePath
|
|
if serveFileDirectly(w, r, staticFS, strippedPath, "index.html") {
|
|
return
|
|
}
|
|
|
|
// Check if file exists in staticFS
|
|
_, err := staticFS.Open(filePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Create a file server and serve the file
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
|
|
// Temporarily modify the URL path for the file server
|
|
originalPath := r.URL.Path
|
|
r.URL.Path = "/" + filePath
|
|
fileServer.ServeHTTP(w, r)
|
|
r.URL.Path = originalPath
|
|
}
|
|
}
|