diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 6f149ff0c..5ab88bd59 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -192,11 +192,18 @@ func main() { log.Printf("error ensuring wave db dir: %v\n", err) return } - err = wavebase.EnsureWaveConfigDir() + err = wconfig.EnsureWaveConfigDir() if err != nil { log.Printf("error ensuring wave config dir: %v\n", err) return } + + // TODO: rather than ensure this dir exists, we should let the editor recursively create parent dirs on save + err = wconfig.EnsureWavePresetsDir() + if err != nil { + log.Printf("error ensuring wave presets dir: %v\n", err) + return + } waveLock, err := wavebase.AcquireWaveLock() if err != nil { log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) diff --git a/emain/platform.ts b/emain/platform.ts index 1d6d06076..36089982e 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -23,23 +23,6 @@ const unamePlatform = process.platform; const unameArch: string = process.arch; keyutil.setKeyUtilPlatform(unamePlatform); -ipcMain.on("get-is-dev", (event) => { - event.returnValue = isDev; -}); -ipcMain.on("get-platform", (event, url) => { - event.returnValue = unamePlatform; -}); -ipcMain.on("get-user-name", (event) => { - const userInfo = os.userInfo(); - event.returnValue = userInfo.username; -}); -ipcMain.on("get-host-name", (event) => { - event.returnValue = os.hostname(); -}); -ipcMain.on("get-webview-preload", (event) => { - event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); -}); - // must match golang function getWaveHomeDir() { const override = process.env[WaveHomeVarName]; @@ -72,6 +55,26 @@ function getWaveSrvCwd(): string { return getWaveHomeDir(); } +ipcMain.on("get-is-dev", (event) => { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); +ipcMain.on("get-user-name", (event) => { + const userInfo = os.userInfo(); + event.returnValue = userInfo.username; +}); +ipcMain.on("get-host-name", (event) => { + event.returnValue = os.hostname(); +}); +ipcMain.on("get-webview-preload", (event) => { + event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); +}); +ipcMain.on("get-config-dir", (event) => { + event.returnValue = path.join(getWaveHomeDir(), "config"); +}); + export { getElectronAppBasePath, getElectronAppUnpackedBasePath, diff --git a/emain/preload.ts b/emain/preload.ts index 6b3e2317d..ba7027c7e 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld("api", { getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), getUserName: () => ipcRenderer.sendSync("get-user-name"), getHostName: () => ipcRenderer.sendSync("get-host-name"), + getConfigDir: () => ipcRenderer.sendSync("get-config-dir"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 4439efd93..bcc29d3c9 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -63,7 +63,6 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { // do nothing } - const showAboutModalAtom = atom(false) as PrimitiveAtom; try { getApi().onMenuItemAbout(() => { modalsModel.pushModal("AboutModal"); diff --git a/frontend/app/view/waveai/waveai.tsx b/frontend/app/view/waveai/waveai.tsx index c88d35b7c..7c100542d 100644 --- a/frontend/app/view/waveai/waveai.tsx +++ b/frontend/app/view/waveai/waveai.tsx @@ -6,7 +6,7 @@ import { Markdown } from "@/app/element/markdown"; import { TypingIndicator } from "@/app/element/typingindicator"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, fetchWaveFile, globalStore, WOS } from "@/store/global"; +import { atoms, createBlock, fetchWaveFile, getApi, globalStore, WOS } from "@/store/global"; import { BlockService, ObjectService } from "@/store/services"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, isBlank, makeIconClass } from "@/util/util"; @@ -182,26 +182,41 @@ export class WaveAiModel implements ViewModel { }); } } - + const dropdownItems = Object.entries(presets) + .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) + .map( + (preset) => + ({ + label: preset[1]["display:name"], + onClick: () => + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { + ...preset[1], + "ai:preset": preset[0], + }); + }), + }) as MenuItem + ); + dropdownItems.push({ + label: "Add AI preset...", + onClick: () => { + fireAndForget(async () => { + const path = `${getApi().getConfigDir()}/presets/ai.json`; + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + }, + }; + await createBlock(blockDef, true); + }); + }, + }); viewTextChildren.push({ elemtype: "menubutton", text: presetName, title: "Select AI Configuration", - items: Object.entries(presets) - .sort((a, b) => (a[1]["display:order"] > b[1]["display:order"] ? 1 : -1)) - .map( - (preset) => - ({ - label: preset[1]["display:name"], - onClick: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - ...preset[1], - "ai:preset": preset[0], - }); - }), - }) as MenuItem - ), + items: dropdownItems, }); return viewTextChildren; }); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 6cd1c8e55..32d770b99 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -64,6 +64,7 @@ declare global { getEnv: (varName: string) => string; getUserName: () => string; getHostName: () => string; + getConfigDir: () => string; getWebviewPreload: () => string; getAboutModalDetails: () => AboutModalDetails; getDocsiteUrl: () => string; diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index fd0cb5738..da61e4a6f 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -119,10 +119,6 @@ func EnsureWaveDBDir() error { return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), WaveDBDir), "wavedb", 0700, "wave db directory") } -func EnsureWaveConfigDir() error { - return CacheEnsureDir(filepath.Join(GetWaveHomeDir(), ConfigDir), "waveconfig", 0700, "wave config directory") -} - func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { baseLock.Lock() ok := ensureDirCache[cacheKey] diff --git a/pkg/wconfig/defaultconfig/defaultconfig.go b/pkg/wconfig/defaultconfig/defaultconfig.go index bc28a9557..9527a069c 100644 --- a/pkg/wconfig/defaultconfig/defaultconfig.go +++ b/pkg/wconfig/defaultconfig/defaultconfig.go @@ -5,5 +5,5 @@ package defaultconfig import "embed" -//go:embed *.json +//go:embed *.json all:*/*.json var ConfigFS embed.FS diff --git a/pkg/wconfig/defaultconfig/presets.json b/pkg/wconfig/defaultconfig/presets.json index 30dc05942..3f1a38135 100644 --- a/pkg/wconfig/defaultconfig/presets.json +++ b/pkg/wconfig/defaultconfig/presets.json @@ -94,23 +94,5 @@ "bg": "linear-gradient(120deg,hsla(350, 65%, 57%, 1),hsla(30,60%,60%, .75), hsla(208,69%,50%,.15), hsl(230,60%,40%)),radial-gradient(at top right,hsla(300,60%,70%,0.3),transparent),radial-gradient(at top left,hsla(330,100%,70%,.20),transparent),radial-gradient(at top right,hsla(190,100%,40%,.20),transparent),radial-gradient(at bottom left,hsla(323,54%,50%,.5),transparent),radial-gradient(at bottom left,hsla(144,54%,50%,.25),transparent)", "bg:blendmode": "overlay", "bg:text": "rgb(200, 200, 200)" - }, - "ai@global": { - "display:name": "Global default", - "display:order": -1, - "ai:*": true - }, - "ai@wave": { - "display:name": "Wave Proxy - gpt-4o-mini", - "display:order": 0, - "ai:*": true, - "ai:apitype": "", - "ai:baseurl": "", - "ai:apitoken": "", - "ai:name": "", - "ai:orgid": "", - "ai:model": "gpt-4o-mini", - "ai:maxtokens": 2048, - "ai:timeoutms": 60000 } } diff --git a/pkg/wconfig/defaultconfig/presets/ai.json b/pkg/wconfig/defaultconfig/presets/ai.json new file mode 100644 index 000000000..11c0b848e --- /dev/null +++ b/pkg/wconfig/defaultconfig/presets/ai.json @@ -0,0 +1,20 @@ +{ + "ai@global": { + "display:name": "Global default", + "display:order": -1, + "ai:*": true + }, + "ai@wave": { + "display:name": "Wave Proxy - gpt-4o-mini", + "display:order": 0, + "ai:*": true, + "ai:apitype": "", + "ai:baseurl": "", + "ai:apitoken": "", + "ai:name": "", + "ai:orgid": "", + "ai:model": "gpt-4o-mini", + "ai:maxtokens": 2048, + "ai:timeoutms": 60000 + } +} diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index f8ce9fd93..c8d344d62 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -40,8 +40,17 @@ func GetWatcher() *Watcher { } instance = &Watcher{watcher: watcher} err = instance.watcher.Add(configDirAbsPath) + const failedStr = "failed to add path %s to watcher: %v" if err != nil { - log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err) + log.Printf(failedStr, configDirAbsPath, err) + } + + subdirs := GetConfigSubdirs() + for _, dir := range subdirs { + err = instance.watcher.Add(dir) + if err != nil { + log.Printf(failedStr, dir, err) + } } }) return instance diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index cf9eeaee4..abcb22383 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -7,6 +7,8 @@ import ( "bytes" "encoding/json" "fmt" + "io/fs" + "log" "os" "path/filepath" "reflect" @@ -14,6 +16,7 @@ import ( "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) @@ -181,15 +184,19 @@ func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.Meta return rtn, cerrs } +var configDirFsys = os.DirFS(configDirAbsPath) + +func readConfigFileFS(fsys fs.FS, logPrefix string, fileName string) (waveobj.MetaMapType, []ConfigError) { + barr, readErr := fs.ReadFile(fsys, fileName) + return readConfigHelper(logPrefix+fileName, barr, readErr) +} + func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { - barr, readErr := defaultconfig.ConfigFS.ReadFile(fileName) - return readConfigHelper("defaults:"+fileName, barr, readErr) + return readConfigFileFS(defaultconfig.ConfigFS, "defaults:", fileName) } func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { - fullFileName := filepath.Join(configDirAbsPath, fileName) - barr, err := os.ReadFile(fullFileName) - return readConfigHelper(fullFileName, barr, err) + return readConfigFileFS(configDirFsys, "", fileName) } func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { @@ -222,17 +229,69 @@ func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) wave return m } -func ReadConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { - defConfig, cerrs1 := ReadDefaultsConfigFile(partName) - userConfig, cerrs2 := ReadWaveHomeConfigFile(partName) - allErrs := append(cerrs1, cerrs2...) +func mergeMetaMap(m waveobj.MetaMapType, toMerge waveobj.MetaMapType, simpleMerge bool) waveobj.MetaMapType { if simpleMerge { - return mergeMetaMapSimple(defConfig, userConfig), allErrs + return mergeMetaMapSimple(m, toMerge) } else { - return waveobj.MergeMeta(defConfig, userConfig, true), allErrs + return waveobj.MergeMeta(m, toMerge, true) } } +func selectDirEntsBySuffix(dirEnts []fs.DirEntry, fileNameSuffix string) []fs.DirEntry { + var rtn []fs.DirEntry + for _, ent := range dirEnts { + if ent.IsDir() { + continue + } + if !strings.HasSuffix(ent.Name(), fileNameSuffix) { + continue + } + rtn = append(rtn, ent) + } + return rtn +} + +func SortFileNameDescend(files []fs.DirEntry) { + sort.Slice(files, func(i, j int) bool { + return files[i].Name() > files[j].Name() + }) +} + +// Read and merge all files in the specified directory matching the supplied suffix +func readConfigFilesForDir(fsys fs.FS, logPrefix string, dirName string, fileName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + dirEnts, _ := fs.ReadDir(fsys, dirName) + suffixEnts := selectDirEntsBySuffix(dirEnts, fileName+".json") + SortFileNameDescend(suffixEnts) + var rtn waveobj.MetaMapType + var errs []ConfigError + for _, ent := range suffixEnts { + fileVal, cerrs := readConfigFileFS(fsys, logPrefix, filepath.Join(dirName, ent.Name())) + rtn = mergeMetaMap(rtn, fileVal, simpleMerge) + errs = append(errs, cerrs...) + } + return rtn, errs +} + +// Read and merge all files in the specified config filesystem matching the patterns `.json` and `/*.json` +func readConfigPartForFS(fsys fs.FS, logPrefix string, partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + config, errs := readConfigFilesForDir(fsys, logPrefix, partName, "", simpleMerge) + allErrs := errs + rtn := config + config, errs = readConfigFileFS(fsys, logPrefix, partName+".json") + allErrs = append(allErrs, errs...) + return mergeMetaMap(rtn, config, simpleMerge), allErrs +} + +// Combine files from the defaults and home directory for the specified config part name +func readConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { + defaultConfigs, cerrs := readConfigPartForFS(defaultconfig.ConfigFS, "defaults:", partName, simpleMerge) + homeConfigs, cerrs1 := readConfigPartForFS(configDirFsys, "", partName, simpleMerge) + + rtn := defaultConfigs + allErrs := append(cerrs, cerrs1...) + return mergeMetaMap(rtn, homeConfigs, simpleMerge), allErrs +} + func ReadFullConfig() FullConfigType { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) @@ -247,13 +306,15 @@ func ReadFullConfig() FullConfigType { continue } jsonTag := utilfn.GetJsonTag(field) + simpleMerge := field.Tag.Get("merge") == "" + var configPart waveobj.MetaMapType + var errs []ConfigError if jsonTag == "-" || jsonTag == "" { continue + } else { + configPart, errs = readConfigPart(jsonTag, simpleMerge) } - simpleMerge := field.Tag.Get("merge") == "" - fileName := jsonTag + ".json" - configPart, cerrs := ReadConfigPart(fileName, simpleMerge) - fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, cerrs...) + fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, errs...) if configPart != nil { fieldPtr := configRVal.Field(fieldIdx).Addr().Interface() utilfn.ReUnmarshal(fieldPtr, configPart) @@ -262,6 +323,28 @@ func ReadFullConfig() FullConfigType { return fullConfig } +func GetConfigSubdirs() []string { + var fullConfig FullConfigType + configRType := reflect.TypeOf(fullConfig) + var retVal []string + for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { + field := configRType.Field(fieldIdx) + if field.PkgPath != "" { + continue + } + configFile := field.Tag.Get("configfile") + if configFile == "-" { + continue + } + jsonTag := utilfn.GetJsonTag(field) + if jsonTag != "-" && jsonTag != "" && jsonTag != "settings" { + retVal = append(retVal, filepath.Join(configDirAbsPath, jsonTag)) + } + } + log.Printf("subdirs: %v\n", retVal) + return retVal +} + func getConfigKeyType(configKey string) reflect.Type { ctype := reflect.TypeOf(SettingsType{}) for i := 0; i < ctype.NumField(); i++ { @@ -415,6 +498,14 @@ func SetBaseConfigValue(toMerge waveobj.MetaMapType) error { return WriteWaveHomeConfigFile(SettingsFile, m) } +func EnsureWaveConfigDir() error { + return wavebase.CacheEnsureDir(configDirAbsPath, "waveconfig", 0700, "wave config directory") +} + +func EnsureWavePresetsDir() error { + return wavebase.CacheEnsureDir(filepath.Join(configDirAbsPath, "presets"), "wavepresets", 0700, "wave presets directory") +} + type WidgetConfigType struct { DisplayOrder float64 `json:"display:order,omitempty"` Icon string `json:"icon,omitempty"`