waveterm/frontend/util/util.ts
Sylvie Crowe ef30221e0b
Allow AI Presets To Automatically Update Widgets (#1954)
This change makes it so changes to the presets file are no longer being
written to metadata. Instead, the preset data is read separately
(although it is still possible to override it with metadata if done
manually).

Because presets are being tracked separately, if the associated metadata
key is not set, changes to the `presets/ai.json` file will be applied
immediately without switching the preset choice. Note that `ai:preset`
is still used in metadata to associate a block with a preset.

Additionallly, this introduces a database migration to clear out the
metadata items starting with `ai:` except for `ai:preset`. This will
allow the change to apply to existing blocks in addition to new ones.
2025-02-13 13:38:12 -08:00

428 lines
13 KiB
TypeScript

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0s
import base64 from "base64-js";
import clsx from "clsx";
import { Atom, atom, Getter, SetStateAction, Setter, useAtomValue } from "jotai";
import { debounce, throttle } from "throttle-debounce";
const prevValueCache = new WeakMap<any, any>(); // stores a previous value for a deep equal comparison (used with the deepCompareReturnPrev function)
function isBlank(str: string): boolean {
return str == null || str == "";
}
function base64ToString(b64: string): string {
if (b64 == null) {
return null;
}
if (b64 == "") {
return "";
}
const stringBytes = base64.toByteArray(b64);
return new TextDecoder().decode(stringBytes);
}
function stringToBase64(input: string): string {
const stringBytes = new TextEncoder().encode(input);
return base64.fromByteArray(stringBytes);
}
function base64ToArray(b64: string): Uint8Array {
const rawStr = atob(b64);
const rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length));
for (let i = 0; i < rawStr.length; i++) {
rtnArr[i] = rawStr.charCodeAt(i);
}
return rtnArr;
}
function boundNumber(num: number, min: number, max: number): number {
if (num == null || typeof num != "number" || isNaN(num)) {
return null;
}
return Math.min(Math.max(num, min), max);
}
// key must be a suitable weakmap key. pass the new value
// it will return the prevValue (for object equality) if the new value is deep equal to the prev value
function deepCompareReturnPrev(key: any, newValue: any): any {
if (key == null) {
return newValue;
}
const previousValue = prevValueCache.get(key);
if (previousValue !== undefined && JSON.stringify(newValue) === JSON.stringify(previousValue)) {
return previousValue;
}
prevValueCache.set(key, newValue);
return newValue;
}
// works for json-like objects (arrays, objects, strings, numbers, booleans)
function jsonDeepEqual(v1: any, v2: any): boolean {
if (v1 === v2) {
return true;
}
if (typeof v1 !== typeof v2) {
return false;
}
if ((v1 == null && v2 != null) || (v1 != null && v2 == null)) {
return false;
}
if (typeof v1 === "object") {
if (Array.isArray(v1) && Array.isArray(v2)) {
if (v1.length !== v2.length) {
return false;
}
for (let i = 0; i < v1.length; i++) {
if (!jsonDeepEqual(v1[i], v2[i])) {
return false;
}
}
return true;
} else {
const keys1 = Object.keys(v1);
const keys2 = Object.keys(v2);
if (keys1.length !== keys2.length) {
return false;
}
for (let key of keys1) {
if (!jsonDeepEqual(v1[key], v2[key])) {
return false;
}
}
return true;
}
}
return false;
}
function makeIconClass(icon: string, fw: boolean, opts?: { spin?: boolean; defaultIcon?: string }): string {
if (isBlank(icon)) {
if (opts?.defaultIcon != null) {
return makeIconClass(opts.defaultIcon, fw, { spin: opts?.spin });
}
return null;
}
if (icon.match(/^(solid@)?[a-z0-9-]+$/)) {
// strip off "solid@" prefix if it exists
icon = icon.replace(/^solid@/, "");
return clsx(`fa fa-solid fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
}
if (icon.match(/^regular@[a-z0-9-]+$/)) {
// strip off the "regular@" prefix if it exists
icon = icon.replace(/^regular@/, "");
return clsx(`fa fa-sharp fa-regular fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
}
if (icon.match(/^brands@[a-z0-9-]+$/)) {
// strip off the "brands@" prefix if it exists
icon = icon.replace(/^brands@/, "");
return clsx(`fa fa-brands fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
}
if (icon.match(/^custom@[a-z0-9-]+$/)) {
// strip off the "custom@" prefix if it exists
icon = icon.replace(/^custom@/, "");
return clsx(`fa fa-kit fa-${icon}`, fw ? "fa-fw" : null, opts?.spin ? "fa-spin" : null);
}
if (opts?.defaultIcon != null) {
return makeIconClass(opts.defaultIcon, fw, { spin: opts?.spin });
}
return null;
}
/**
* A wrapper function for running a promise and catching any errors
* @param f The promise to run
*/
function fireAndForget(f: () => Promise<any>) {
f()?.catch((e) => {
console.log("fireAndForget error", e);
});
}
const promiseWeakMap = new WeakMap<Promise<any>, ResolvedValue<any>>();
type ResolvedValue<T> = {
pending: boolean;
error: any;
value: T;
};
// returns the value, pending state, and error of a promise
function getPromiseState<T>(promise: Promise<T>): [T, boolean, any] {
if (promise == null) {
return [null, false, null];
}
if (promiseWeakMap.has(promise)) {
const value = promiseWeakMap.get(promise);
return [value.value, value.pending, value.error];
}
const value: ResolvedValue<T> = {
pending: true,
error: null,
value: null,
};
promise.then(
(result) => {
value.pending = false;
value.error = null;
value.value = result;
},
(error) => {
value.pending = false;
value.error = error;
}
);
promiseWeakMap.set(promise, value);
return [value.value, value.pending, value.error];
}
// returns the value of a promise, or a default value if the promise is still pending (or had an error)
function getPromiseValue<T>(promise: Promise<T>, def: T): T {
const [value, pending, error] = getPromiseState(promise);
if (pending || error) {
return def;
}
return value;
}
function jotaiLoadableValue<T>(value: Loadable<T>, def: T): T {
if (value.state === "hasData") {
return value.data;
}
return def;
}
const NullAtom = atom(null);
function useAtomValueSafe<T>(atom: Atom<T> | Atom<Promise<T>>): T {
if (atom == null) {
return useAtomValue(NullAtom) as T;
}
return useAtomValue(atom);
}
/**
* Simple wrapper function that lazily evaluates the provided function and caches its result for future calls.
* @param callback The function to lazily run.
* @returns The result of the function.
*/
const lazy = <T extends (...args: any[]) => any>(callback: T) => {
let res: ReturnType<T>;
let processed = false;
return (...args: Parameters<T>): ReturnType<T> => {
if (processed) return res;
res = callback(...args);
processed = true;
return res;
};
};
/**
* Generates an external link by appending the given URL to the "https://extern?" endpoint.
*
* @param {string} url - The URL to be encoded and appended to the external link.
* @return {string} The generated external link.
*/
function makeExternLink(url: string): string {
return "https://extern?" + encodeURIComponent(url);
}
function atomWithThrottle<T>(initialValue: T, delayMilliseconds = 500): AtomWithThrottle<T> {
// DO NOT EXPORT currentValueAtom as using this atom to set state can cause
// inconsistent state between currentValueAtom and throttledValueAtom
const _currentValueAtom = atom(initialValue);
const throttledValueAtom = atom(initialValue, (get, set, update: SetStateAction<T>) => {
const prevValue = get(_currentValueAtom);
const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update;
set(_currentValueAtom, nextValue);
throttleUpdate(get, set);
});
const throttleUpdate = throttle(delayMilliseconds, (get: Getter, set: Setter) => {
const curVal = get(_currentValueAtom);
set(throttledValueAtom, curVal);
});
return {
currentValueAtom: atom((get) => get(_currentValueAtom)),
throttledValueAtom,
};
}
function atomWithDebounce<T>(initialValue: T, delayMilliseconds = 500): AtomWithDebounce<T> {
// DO NOT EXPORT currentValueAtom as using this atom to set state can cause
// inconsistent state between currentValueAtom and debouncedValueAtom
const _currentValueAtom = atom(initialValue);
const debouncedValueAtom = atom(initialValue, (get, set, update: SetStateAction<T>) => {
const prevValue = get(_currentValueAtom);
const nextValue = typeof update === "function" ? (update as (prev: T) => T)(prevValue) : update;
set(_currentValueAtom, nextValue);
debounceUpdate(get, set);
});
const debounceUpdate = debounce(delayMilliseconds, (get: Getter, set: Setter) => {
const curVal = get(_currentValueAtom);
set(debouncedValueAtom, curVal);
});
return {
currentValueAtom: atom((get) => get(_currentValueAtom)),
debouncedValueAtom,
};
}
function getPrefixedSettings(settings: SettingsType, prefix: string): SettingsType {
const rtn: SettingsType = {};
if (settings == null || isBlank(prefix)) {
return rtn;
}
for (const key in settings) {
if (key == prefix || key.startsWith(prefix + ":")) {
rtn[key] = settings[key];
}
}
return rtn;
}
function countGraphemes(str: string): number {
if (str == null) {
return 0;
}
// this exists (need to hack TS to get it to not show an error)
const seg = new (Intl as any).Segmenter(undefined, { granularity: "grapheme" });
return Array.from(seg.segment(str)).length;
}
function makeConnRoute(conn: string): string {
if (isBlank(conn)) {
return "conn:local";
}
return "conn:" + conn;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeNativeLabel(platform: string, isDirectory: boolean, isParent: boolean) {
let managerName: string;
if (!isDirectory && !isParent) {
managerName = "Default Application";
} else if (platform == "darwin") {
managerName = "Finder";
} else if (platform == "win32") {
managerName = "Explorer";
} else {
managerName = "File Manager";
}
let fileAction: string;
if (isParent) {
fileAction = "Reveal";
} else if (isDirectory) {
fileAction = "Open Directory";
} else {
fileAction = "Open File";
}
return `${fileAction} in ${managerName}`;
}
function mergeMeta(meta: MetaType, metaUpdate: MetaType, prefix?: string): MetaType {
const rtn: MetaType = {};
// Helper function to check if a key matches the prefix criteria
const shouldIncludeKey = (key: string): boolean => {
if (prefix === undefined) {
return true;
}
if (prefix === "") {
return !key.includes(":");
}
return key.startsWith(prefix + ":");
};
// Copy original meta (only keys matching prefix criteria)
for (const [k, v] of Object.entries(meta)) {
if (shouldIncludeKey(k)) {
rtn[k] = v;
}
}
// Deal with "section:*" keys (only if they match prefix criteria)
for (const k of Object.keys(metaUpdate)) {
if (!k.endsWith(":*")) {
continue;
}
if (!metaUpdate[k]) {
continue;
}
const sectionPrefix = k.slice(0, -2); // Remove ':*' suffix
if (sectionPrefix === "") {
continue;
}
// Only process if this section matches our prefix criteria
if (!shouldIncludeKey(sectionPrefix)) {
continue;
}
// Delete "[sectionPrefix]" and all keys that start with "[sectionPrefix]:"
const prefixColon = sectionPrefix + ":";
for (const k2 of Object.keys(rtn)) {
if (k2 === sectionPrefix || k2.startsWith(prefixColon)) {
delete rtn[k2];
}
}
}
// Deal with regular keys (only if they match prefix criteria)
for (const [k, v] of Object.entries(metaUpdate)) {
if (!shouldIncludeKey(k)) {
continue;
}
if (k.endsWith(":*")) {
continue;
}
if (v === null || v === undefined) {
delete rtn[k];
continue;
}
rtn[k] = v;
}
return rtn;
}
export {
atomWithDebounce,
atomWithThrottle,
base64ToArray,
base64ToString,
boundNumber,
countGraphemes,
deepCompareReturnPrev,
fireAndForget,
getPrefixedSettings,
getPromiseState,
getPromiseValue,
isBlank,
jotaiLoadableValue,
jsonDeepEqual,
lazy,
makeConnRoute,
makeExternLink,
makeIconClass,
makeNativeLabel,
mergeMeta,
sleep,
stringToBase64,
useAtomValueSafe,
};