Basic settings screen

This commit is contained in:
space-nuko
2023-05-28 18:41:54 -05:00
parent 4d8390115d
commit e411d29f09
16 changed files with 659 additions and 1915 deletions

View File

@@ -14,7 +14,7 @@
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"svelte-check": "svelte-check",
"prebuild": "pnpm run build:css && pnpm --filter=klecks lang:build",
"prebuild": "pnpm run build:css",
"build:css": "pollen -c gradio/js/theme/src/pollen.config.cjs && mv src/pollen.css node_modules/@gradio/theme/src"
},
"devDependencies": {
@@ -85,7 +85,6 @@
"framework7": "^8.0.3",
"framework7-svelte": "^8.0.3",
"img-comparison-slider": "^8.0.0",
"klecks": "workspace:*",
"pollen-css": "^4.6.2",
"radix-icons-svelte": "^1.2.1",
"style-mod": "^4.0.3",

1894
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,4 +2,3 @@ packages:
- 'gradio/js/*'
- 'gradio/client/js'
- 'litegraph/packages/*'
- 'klecks'

View File

@@ -1,9 +0,0 @@
{
"comfyUIHostname": "localhost",
"comfyUIPort": 8188,
"alwaysStripUserState": false,
"promptForWorkflowName": false,
"confirmWhenUnloadingUnsavedChanges": false,
"builtInTemplates": ["ControlNet", "LoRA x5", "Model Loader", "Positive_Negative", "Seed Randomizer"],
"cacheBuiltInResources": true
}

View File

@@ -12,6 +12,7 @@
import notify from "$lib/notify";
import ComfyBoxWorkflowsView from "./ComfyBoxWorkflowsView.svelte";
import GlobalModal from "./GlobalModal.svelte";
import ComfySettingsView from "./ComfySettingsView.svelte";
export let app: ComfyApp = undefined;
let hasShownUIHelpToast: boolean = false;
@@ -63,6 +64,7 @@
<ComfyBoxWorkflowsView {app} {uiTheme} />
</SidebarItem>
<SidebarItem id="settings" name="Settings" icon={Gear}>
<ComfySettingsView {app} />
</SidebarItem>
</Sidebar>
</div>

View File

@@ -261,17 +261,18 @@ export default class ComfyApp {
return Promise.resolve();
}
/*
* TODO
*/
async loadConfig() {
try {
const config = await fetch(`/config.json`, { cache: "no-store" });
const newConfig = await config.json() as ConfigState;
configState.set({ ...get(configState), ...newConfig });
console.log("Loading config.json...")
const config = localStorage.getItem("config")
if (config == null)
configState.loadDefault();
else
configState.load(JSON.parse(config));
}
catch (error) {
console.error(`Failed to load config`, error)
console.error(`Failed to load config, falling back to defaults`, error)
configState.loadDefault();
}
}

View File

@@ -101,7 +101,6 @@
color: var(--body-text-color);
}
&.selected {
color: var(--body-text-color);
background-color: var(--panel-background-fill);
}
}

View File

@@ -559,6 +559,7 @@
.target-name {
background: var(--input-background-fill);
border-color: var(--input-border-color);
white-space: nowrap;
.title {

View File

@@ -0,0 +1,230 @@
<script lang="ts">
import { CONFIG_CATEGORIES, CONFIG_DEFS_BY_CATEGORY, type ConfigDefAny } from "$lib/stores/configDefs";
import { capitalize } from "$lib/utils";
import { Checkbox } from "@gradio/form";
import configState from "$lib/stores/configState";
import type ComfyApp from "./ComfyApp";
import NumberInput from "./NumberInput.svelte";
import Textbox from "@gradio/form/src/Textbox.svelte";
import { Button } from "@gradio/button";
import { SvelteToast } from "@zerodevx/svelte-toast";
import notify from "$lib/notify";
export let app: ComfyApp
let selectedCategory = CONFIG_CATEGORIES[0];
let changes = {}
const toastOptions = {
intro: { duration: 200 },
theme: {
'--toastBarHeight': 0
}
}
function selectCategory(category: string) {
selectedCategory = category;
}
function setOption(def: ConfigDefAny, value: any) {
if (!configState.validateConfigOption(def, value)) {
console.warn(`[configState] Invalid value for option ${def.name} (${v}), setting to default (${def.defaultValue})`);
value = def.defaultValue
}
changes[def.name] = value;
}
function doSave() {
$configState = { ...$configState, ...changes };
changes = {};
const json = JSON.stringify($configState);
localStorage.setItem("config", json);
notify("Config applied!", { type: "success" })
}
function doReset() {
if (!confirm("Are you sure you want to reset the config to the defaults?"))
return;
configState.loadDefault();
notify("Config reset!")
}
</script>
<div class="comfy-settings">
<div class="comfy-settings-categories">
{#each CONFIG_CATEGORIES as category}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="comfy-settings-category" class:selected={selectedCategory === category} on:click={() => selectCategory(category)}>
{capitalize(category)}
</div>
{/each}
</div>
<div class="comfy-settings-main">
{#if selectedCategory}
{@const categoryDefs = CONFIG_DEFS_BY_CATEGORY[selectedCategory]}
{#key $configState}
<div class="comfy-settings-entries">
{#each categoryDefs as def}
{@const value = $configState[def.name]}
<div class="comfy-settings-entry">
<div class="name">{def.name}</div>
{#if def.type === "boolean"}
<span class="ctrl checkbox">
<Checkbox label={def.description} {value} on:change={(e) => setOption(def, e.detail)} />
</span>
{:else if def.type === "number"}
<div class="description">{def.description}</div>
<span class="ctrl number">
<NumberInput label="" min={def.options.min} max={def.options.max} step={def.options.step} {value} on:release={(e) => setOption(def, e.detail)} />
</span>
{:else if def.type === "string"}
<div class="description">{def.description}</div>
<span class="ctrl textbox">
<Textbox label="" lines={1} max_lines={1} {value} on:change={(e) => setOption(def, e.detail)} />
</span>
{:else if def.type === "string[]"}
<div class="description">{def.description}</div>
<span class="ctrl string-array">
{value.join(",")}
</span>
{:else}
(Unknown config type {def.type})
{/if}
</div>
{/each}
</div>
{/key}
{:else}
Please select a category.
{/if}
<div class="comfy-settings-bottom-bar">
<div>
<div class="left">
<Button variant="secondary" on:click={doReset}>
Reset
</Button>
</div>
<div class="right">
<Button variant="primary" on:click={doSave}>
Save
</Button>
</div>
</div>
</div>
</div>
<SvelteToast options={toastOptions} />
</div>
<style lang="scss">
$bottom-bar-height: 5rem;
.comfy-settings {
color: var(--body-text-color);
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.comfy-settings-categories {
width: 20rem;
height: 100%;
color: var(--neutral-500);
background: var(--neutral-800);
border-left: 2px solid var(--comfy-splitpanes-background-fill);
}
.comfy-settings-category {
padding: 2rem 3rem;
font-size: 14pt;
border-bottom: 1px solid grey;
cursor: pointer;
&.selected {
color: var(--body-text-color);
background: var(--neutral-700);
}
}
.comfy-settings-main {
width: 100%;
height: calc(100% - $bottom-bar-height);
}
.comfy-settings-entries {
padding: 3rem 3rem;
height: 100%;
}
.comfy-settings-entry {
padding: 1rem 3rem;
.name {
font-weight: bold;
font-size: 13pt;
}
.description {
font-size: 11pt;
color: var(--neutral-400);
}
.ctrl {
margin-top: 0.5rem;
min-width: 5rem;
display: block;
&:not(.checkbox) {
width: 20rem;
}
&.textbox {
:global(span) {
display: block !important;
}
}
&.checkbox {
display: inline-flex !important;
padding: 0 0.75rem;
:global(label) {
color: var(--neutral-400);
font-size: 11pt;
}
}
}
}
.comfy-settings-bottom-bar {
background: var(--neutral-900);
width: 100%;
border-top: 2px solid var(--neutral-800);
gap: var(--layout-gap);
overflow-x: hidden;
height: $bottom-bar-height;
justify-content: center;
padding: 0 2rem;
margin: auto;
position: relative;
flex-direction: column;
display: flex;
> div {
width: 100%;
display: flex;
gap: var(--layout-gap);
margin: auto;
flex-wrap: nowrap;
}
.left {
left: 0;
}
.right {
margin-left: auto;
}
}
</style>

View File

@@ -41,7 +41,7 @@
#lightboxModal{
display: none;
position: fixed;
z-index: 1001;
z-index: var(--layer-top);
left: 0;
top: 0;
width: 100%;

View File

@@ -4,8 +4,8 @@
import { createEventDispatcher } from "svelte";
export let value: number = 0;
export let min: number = -1024
export let max: number = 1024
export let min: number | null = null
export let max: number | null = null
export let step: number = 1;
export let label: string = "";
export let disabled: boolean = false;
@@ -41,9 +41,11 @@
<div class="wrap">
<div class="head">
{#if label}
<label>
<BlockTitle>{label}</BlockTitle>
</label>
{/if}
<input
data-testid="number-input"
type="number"
@@ -83,11 +85,11 @@
border: var(--input-border-width) solid var(--input-border-color);
border-radius: var(--input-radius);
background: var(--input-background-fill);
padding: var(--size-2) var(--size-2);
padding: var(--input-padding);
color: var(--body-text-color);
font-size: var(--input-text-size);
line-height: var(--line-sm);
text-align: center;
// text-align: center;
}
input:disabled {
-webkit-text-fill-color: var(--body-text-color);
@@ -95,11 +97,18 @@
opacity: 1;
}
input[type="number"]:focus {
input[type="number"] {
&:focus {
box-shadow: var(--input-shadow-focus);
border-color: var(--input-border-color-focus);
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
opacity: 100%;
}
}
input::placeholder {
color: var(--input-placeholder-color);
}
@@ -107,4 +116,5 @@
input[disabled] {
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,148 @@
/*
* Supported config option types.
*/
type ConfigDefType = "boolean" | "number" | "string" | "string[]";
// A simple parameter description interface
export interface ConfigDef<IdType extends string, TypeType extends ConfigDefType, ValueType, OptionsType = any> {
// This generic `IdType` is what makes the "array of keys and values to new
// interface definition" thing work
name: IdType;
type: TypeType,
description?: string,
category: string,
defaultValue: ValueType,
options: OptionsType
}
export type ConfigDefAny = ConfigDef<string, any, any>
type ConfigDefBoolean<IdType extends string> = ConfigDef<IdType, "boolean", boolean>;
type NumberOptions = {
min?: number,
max?: number,
step: number
}
type ConfigDefNumber<IdType extends string> = ConfigDef<IdType, "number", number, NumberOptions>;
type ConfigDefString<IdType extends string> = ConfigDef<IdType, "string", string>;
type ConfigDefStringArray<IdType extends string> = ConfigDef<IdType, "string[]", string[]>;
// Configuration parameters ------------------------------------
const defComfyUIHostname: ConfigDefString<"comfyUIHostname"> = {
name: "comfyUIHostname",
type: "string",
defaultValue: "localhost",
category: "backend",
description: "Backend domain for ComfyUI",
options: {}
};
const defComfyUIPort: ConfigDefNumber<"comfyUIPort"> = {
name: "comfyUIPort",
type: "number",
defaultValue: 8188,
category: "backend",
description: "Backend port for ComfyUI",
options: {
min: 1,
max: 65535,
step: 1
}
};
const defAlwaysStripUserState: ConfigDefBoolean<"alwaysStripUserState"> = {
name: "alwaysStripUserState",
type: "boolean",
defaultValue: false,
category: "behavior",
description: "Strip user state even if saving to local storage",
options: {}
};
const defPromptForWorkflowName: ConfigDefBoolean<"promptForWorkflowName"> = {
name: "promptForWorkflowName",
type: "boolean",
defaultValue: false,
category: "behavior",
description: "When saving, always prompt for a name to save the workflow as",
options: {}
};
const defConfirmWhenUnloadingUnsavedChanges: ConfigDefBoolean<"confirmWhenUnloadingUnsavedChanges"> = {
name: "confirmWhenUnloadingUnsavedChanges",
type: "boolean",
defaultValue: true,
category: "behavior",
description: "When closing the tab, open the confirmation window if there's unsaved changes",
options: {}
};
const defCacheBuiltInResources: ConfigDefBoolean<"cacheBuiltInResources"> = {
name: "cacheBuiltInResources",
type: "boolean",
defaultValue: true,
category: "behavior",
description: "Cache loading of built-in resources to save network use",
options: {}
};
const defBuiltInTemplates: ConfigDefStringArray<"builtInTemplates"> = {
name: "builtInTemplates",
type: "string[]",
defaultValue: ["ControlNet", "LoRA x5", "Model Loader", "Positive_Negative", "Seed Randomizer"],
category: "templates",
description: "Basenames of templates that can be loaded from public/templates. Saves LocalStorage space.",
options: {}
};
// Configuration exports ------------------------------------
export const CONFIG_DEFS = [
defComfyUIHostname,
defComfyUIPort,
defAlwaysStripUserState,
defPromptForWorkflowName,
defConfirmWhenUnloadingUnsavedChanges,
defCacheBuiltInResources,
defBuiltInTemplates,
] as const;
export const CONFIG_DEFS_BY_NAME: Record<string, ConfigDefAny>
= CONFIG_DEFS.reduce((dict, def) => {
if (def.name in dict)
throw new Error(`Duplicate named config definition: ${def.name}`)
dict[def.name] = def;
return dict
}, {})
export const CONFIG_DEFS_BY_CATEGORY: Record<string, ConfigDefAny[]>
= CONFIG_DEFS.reduce((dict, def) => {
dict[def.category] ||= []
dict[def.category].push(def)
return dict
}, {})
export const CONFIG_CATEGORIES: string[]
= CONFIG_DEFS.reduce((arr, def) => {
if (!arr.includes(def.category))
arr.push(def.category)
return arr
}, [])
type Config<T extends ReadonlyArray<Readonly<ConfigDef<string, ConfigDefType, any>>>> = {
[K in T[number]["name"]]: Extract<T[number], { name: K }>["defaultValue"]
} extends infer O
? { [P in keyof O]: O[P] }
: never;
export type ConfigState = Config<typeof CONFIG_DEFS>
const pairs: [string, any][] = CONFIG_DEFS.map(item => { return [item.name, structuredClone(item.defaultValue)] })
export const defaultConfig: ConfigState = pairs.reduce((dict, v) => { dict[v[0]] = v[1]; return dict; }, {}) as any;

View File

@@ -1,130 +1,85 @@
import { debounce } from '$lib/utils';
import { toHashMap } from '@litegraph-ts/core';
import { get, writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { z, type ZodTypeAny } from "zod"
type ConfigDefType = "boolean" | "number" | "string" | "string[]";
import { defaultConfig, type ConfigState, type ConfigDefAny, CONFIG_DEFS_BY_NAME } from './configDefs';
type ConfigStateOps = {
getBackendURL: () => string,
save: () => void,
load: () => ConfigState
load: (data: any) => ConfigState
loadDefault: () => ConfigState
setConfigOption: (def: ConfigDefAny, v: any) => boolean
validateConfigOption: (def: ConfigDefAny, v: any) => boolean
}
export type WritableConfigStateStore = Writable<ConfigState> & ConfigStateOps;
const store: Writable<ConfigState> = writable(
{
comfyUIHostname: "localhost",
comfyUIPort: 8188,
alwaysStripUserState: false,
promptForWorkflowName: false,
confirmWhenUnloadingUnsavedChanges: true,
builtInTemplates: [],
cacheBuiltInResources: true,
})
type Conf2 = {
name: string;
type: ZodTypeAny;
defaultValue: any;
description: string;
};
const def2ComfyUIHostname: Conf2 = {
name: 'backend.comfyUIHostname',
type: z.string(),
defaultValue: 'localhost',
description: 'Backend domain for ComfyUI',
};
const def2ComfyUIPort: Conf2 = {
name: 'backend.comfyUIPort',
type: z.number(),
defaultValue: 8188,
description: 'Backend port for ComfyUI',
};
const def2AlwaysStripUserState: Conf2 = {
name: 'behavior.alwaysStripUserState',
type: z.boolean(),
defaultValue: false,
description: 'Strip user state even if saving to local storage',
};
export const allconfs: ReadonlyArray<Conf2> = [
def2ComfyUIHostname,
def2ComfyUIPort,
def2AlwaysStripUserState,
] as const;
const confItems: any = {};
const confDefaults: any = {}
for (const item of allconfs) {
const trail = item.name.split('.');
let theI = confItems;
let theDef = confDefaults;
for (const category of trail.slice(0, -1)) {
theI[category] ||= { __category__: true };
theI = theI[category];
theDef[category] ||= {};
theDef = theDef[category];
}
const optionName = trail[trail.length - 1];
theI[optionName] = item;
theDef[optionName] = item.defaultValue;
}
function recurse(item: Record<string, any>, trail: string[] = []): ZodTypeAny {
let defaultValue = confDefaults
for (const name of trail) {
defaultValue = defaultValue[name];
}
for (const [key, value] of Object.entries(item)) {
if (value.__category__) {
delete value['__category__'];
item[key] = recurse(value, trail.concat(key));
} else {
const result = value.type.safeParse(value.defaultValue);
if (!result.success) {
throw new Error(
`Default value for config item ${value.name} did not pass type matcher: ${result.error}`
);
}
item[key] = value.type.catch(value.defaultValue);
}
}
return z.object(item).catch({ ...defaultValue });
}
export const Config2 = recurse(confItems);
export type ConfigStore = z.infer<typeof Config2>;
const stor: ConfigStore = {
backend: {},
a: "foo"
}
const store: Writable<ConfigState> = writable({ ...defaultConfig })
function getBackendURL(): string {
const state = get(store);
return `${window.location.protocol}//${state.comfyUIHostname}:${state.comfyUIPort}`
}
function save() {
function validateConfigOption(def: ConfigDefAny, v: any): boolean {
switch (def.type) {
case "boolean":
return typeof v === "boolean";
case "number":
return typeof v === "number";
case "string":
return typeof v === "string";
case "string[]":
return Array.isArray(v) && v.every(vs => typeof vs === "string");
}
return false;
}
function load(): ConfigState {
function setConfigOption(def: ConfigDefAny, v: any): boolean {
let valid = false;
store.update(state => {
valid = validateConfigOption(def, v);
if (!valid) {
console.warn(`[configState] Invalid value for option ${def.name} (${v}), setting to default (${def.defaultValue})`);
state[def.name] = structuredClone(def.defaultValue);
}
else {
state[def.name] = v
}
return state;
})
return valid;
}
function load(data: any): ConfigState {
store.set({ ...defaultConfig })
if (data != null && typeof data === "object") {
for (const [k, v] of Object.entries(data)) {
const def = CONFIG_DEFS_BY_NAME[k]
if (def == null) {
delete data[k]
continue;
}
setConfigOption(def, v);
}
}
return get(store);
}
function loadDefault() {
return load(null);
}
const configStateStore: WritableConfigStateStore =
{
...store,
getBackendURL,
save,
load
validateConfigOption,
setConfigOption,
load,
loadDefault
}
export default configStateStore;

View File

@@ -690,7 +690,7 @@ const ALL_ATTRIBUTES: AttributesSpecList = [
}
]
}
];
] as const;
// This is needed so the specs can be iterated with svelte's keyed #each.
let i = 0;

View File

@@ -109,6 +109,9 @@ body {
&:hover:not(:disabled) {
background: var(--block-background-fill);
}
&.selected {
background: var(--panel-background-fill);
}
}
&:hover:not(:disabled) {
@@ -117,11 +120,12 @@ body {
&:active:not(:disabled) {
filter: brightness(50%)
}
&.selected:not(:disabled) {
filter: brightness(80%)
&.selected {
color: var(--body-text-color);
filter: none;
}
&:disabled {
&:disabled:not(.selected) {
background: var(--neutral-700);
color: var(--neutral-400);
opacity: 50%;

View File

@@ -1,60 +1,33 @@
import { get } from "svelte/store";
import configState, { Config2 } from "$lib/stores/configState"
import configState, { type ConfigState } from "$lib/stores/configState"
import { expect } from 'vitest';
import UnitTest from "../UnitTest";
import { Watch } from "@litegraph-ts/nodes-basic";
export default class configStateTests extends UnitTest {
test__parse__parsesBasic() {
const testConf = {
backend: {
comfyUIHostname: 'test',
comfyUIPort: 8187,
},
behavior: {
alwaysStripUserState: false,
},
};
test__loadsDefaultsFromInvalid() {
const saved = "foo"
expect(Config2.safeParse(testConf).success).toEqual(true);
const config = configState.load(saved)
expect(config).toBeInstanceOf(Object)
expect(config.comfyUIHostname).toEqual("localhost")
}
test__parse__parsesFallbacks() {
const testConf = {
backend: {},
behavior: {}
};
test__loadsDefaultsFromBlank() {
const saved = {}
const result = Config2.safeParse(testConf)
console.warn(result)
expect(result.success).toEqual(true);
expect(result.data).toEqual({
backend: {
comfyUIHostname: "localhost",
comfyUIPort: 8188
},
behavior: {
alwaysStripUserState: false
}
});
const config = configState.load(saved)
expect(config).toBeInstanceOf(Object)
expect(config.comfyUIHostname).toEqual("localhost")
}
test__parse__parsesFallbacks2() {
const testConf = "foo";
test__loadsDefaultsFromInvalidValues() {
const saved = {
comfyUIHostname: 1234 as any
}
const result = Config2.safeParse(testConf)
console.warn(result)
expect(result.success).toEqual(true);
expect(result.data).toEqual({
backend: {
comfyUIHostname: "localhost",
comfyUIPort: 8188
},
behavior: {
alwaysStripUserState: false
}
});
const config = configState.load(saved)
expect(config).toBeInstanceOf(Object)
expect(config.comfyUIHostname).toEqual("localhost")
}
}