Compare commits
29 Commits
5a0e1aa9aa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 58f3c93541 | |||
| ae73b56022 | |||
| ad9fc9aa7a | |||
| 13c03caeae | |||
| 6593ca4d8b | |||
| ea7b2e8ec2 | |||
| 947ea31fe0 | |||
| e726dfce11 | |||
| 7655a8d441 | |||
| 2707e9ecdb | |||
| eefbe42f2b | |||
| 4d58953c9b | |||
| 6c083dad83 | |||
| cdf18d4397 | |||
| edba1686cd | |||
| cfdc05d9dd | |||
| d5c1d2bdc2 | |||
| 109f77014b | |||
| 5d02728e1c | |||
| 2c82f7cfc1 | |||
| 66a8c1f931 | |||
| bb59e88e8e | |||
| dabb32e8df | |||
| 38bf9ce942 | |||
| ca4c09e55a | |||
| 3a7b21e3e7 | |||
| 7eaafb068d | |||
| f32364c25b | |||
| c8576425a4 |
@@ -24,4 +24,8 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
blake3 = { version = "1.8.3", features = ["serde", "digest", "pure"] }
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
toml = "1.1.1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
use crate::config::{AppConfig, AppConfigState, ProxyKind};
|
||||||
|
use crate::dataset_meta::{load_dataset_meta, save_dataset_meta, DatasetMeta};
|
||||||
|
use tauri::State;
|
||||||
use tauri::{AppHandle, WebviewWindow};
|
use tauri::{AppHandle, WebviewWindow};
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SelectOption {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_window_title(
|
pub fn set_window_title(
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
@@ -12,3 +22,53 @@ pub fn set_window_title(
|
|||||||
.unwrap_or(app_name);
|
.unwrap_or(app_name);
|
||||||
window.set_title(&new_title).map_err(|e| e.to_string())
|
window.set_title(&new_title).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_app_config(state: State<'_, AppConfigState>) -> Result<AppConfig, String> {
|
||||||
|
state.get().map_err(|e| format!("读取应用配置失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_app_config(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppConfigState>,
|
||||||
|
config: AppConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
config
|
||||||
|
.save(&app_handle)
|
||||||
|
.map_err(|e| format!("保存应用配置失败: {e}"))?;
|
||||||
|
state
|
||||||
|
.set(config)
|
||||||
|
.map_err(|e| format!("更新应用配置状态失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_proxy_kind_options() -> Vec<SelectOption> {
|
||||||
|
vec![
|
||||||
|
SelectOption {
|
||||||
|
label: "HTTP".to_string(),
|
||||||
|
value: proxy_kind_value(&ProxyKind::Http).to_string(),
|
||||||
|
},
|
||||||
|
SelectOption {
|
||||||
|
label: "SOCKS5".to_string(),
|
||||||
|
value: proxy_kind_value(&ProxyKind::Socks5).to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_dataset_meta_by_dir(dataset_dir: String) -> Result<DatasetMeta, String> {
|
||||||
|
load_dataset_meta(&dataset_dir).map_err(|e| format!("读取数据集元数据失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_dataset_meta_by_dir(dataset_dir: String, meta: DatasetMeta) -> Result<(), String> {
|
||||||
|
save_dataset_meta(&dataset_dir, &meta).map_err(|e| format!("保存数据集元数据失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_kind_value(kind: &ProxyKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
ProxyKind::Http => "Http",
|
||||||
|
ProxyKind::Socks5 => "Socks5",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{fs, path::PathBuf, sync::RwLock};
|
||||||
|
use tauri::{path::BaseDirectory, AppHandle, Manager};
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub enum ProxyKind {
|
||||||
|
Http,
|
||||||
|
Socks5,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyKind {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Http
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProxyConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub kind: ProxyKind,
|
||||||
|
pub host: Option<String>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub proxy: Option<ProxyConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppConfigState {
|
||||||
|
inner: RwLock<AppConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfigState {
|
||||||
|
pub fn new(config: AppConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: RwLock::new(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self) -> anyhow::Result<AppConfig> {
|
||||||
|
let guard = self
|
||||||
|
.inner
|
||||||
|
.read()
|
||||||
|
.map_err(|_| anyhow::anyhow!("读取应用配置状态失败"))?;
|
||||||
|
Ok(guard.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, config: AppConfig) -> anyhow::Result<()> {
|
||||||
|
let mut guard = self
|
||||||
|
.inner
|
||||||
|
.write()
|
||||||
|
.map_err(|_| anyhow::anyhow!("写入应用配置状态失败"))?;
|
||||||
|
*guard = config;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
fn config_path(app_handle: &AppHandle) -> anyhow::Result<PathBuf> {
|
||||||
|
Ok(app_handle
|
||||||
|
.path()
|
||||||
|
.resolve("config.json", BaseDirectory::AppData)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, app_handle: &AppHandle) -> anyhow::Result<()> {
|
||||||
|
let config_path = Self::config_path(app_handle)?;
|
||||||
|
if let Some(parent_dir) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(config_path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(app_handle: &AppHandle) -> anyhow::Result<Self> {
|
||||||
|
let config_path = Self::config_path(app_handle)?;
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Ok(Self::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(config_path)?;
|
||||||
|
Ok(serde_json::from_str(&content)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_init(app_handle: &AppHandle) -> anyhow::Result<Self> {
|
||||||
|
let config_path = Self::config_path(app_handle)?;
|
||||||
|
if !config_path.exists() {
|
||||||
|
let default_config = Self::default();
|
||||||
|
default_config.save(app_handle)?;
|
||||||
|
return Ok(default_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::load(app_handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
|
const DATASET_META_FILE: &str = "meta.toml";
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DatasetMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub target_model: String,
|
||||||
|
pub lora_type: String,
|
||||||
|
pub unified_image_size: bool,
|
||||||
|
pub unified_image_ratio: bool,
|
||||||
|
pub image_size: [u32; 2],
|
||||||
|
pub trigger_words: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatasetMeta {
|
||||||
|
pub fn validate(&self) -> anyhow::Result<()> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
anyhow::bail!("数据集名称不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.target_model.trim().is_empty() {
|
||||||
|
anyhow::bail!("目标模型不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.image_size[0] == 0 || self.image_size[1] == 0 {
|
||||||
|
anyhow::bail!("imageSize 必须为正整数")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dataset_meta_path(dataset_dir: &str) -> PathBuf {
|
||||||
|
PathBuf::from(dataset_dir).join(DATASET_META_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_dataset_meta(dataset_dir: &str, meta: &DatasetMeta) -> anyhow::Result<()> {
|
||||||
|
meta.validate()?;
|
||||||
|
|
||||||
|
let meta_path = dataset_meta_path(dataset_dir);
|
||||||
|
if let Some(parent_dir) = meta_path.parent() {
|
||||||
|
fs::create_dir_all(parent_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = toml::to_string_pretty(meta)?;
|
||||||
|
fs::write(meta_path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_dataset_meta(dataset_dir: &str) -> anyhow::Result<DatasetMeta> {
|
||||||
|
let meta_path = dataset_meta_path(dataset_dir);
|
||||||
|
if !meta_path.exists() {
|
||||||
|
anyhow::bail!("缺少 meta.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(meta_path)?;
|
||||||
|
let meta: DatasetMeta = toml::from_str(&content)?;
|
||||||
|
meta.validate()?;
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
+18
-1
@@ -1,4 +1,8 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
|
mod config;
|
||||||
|
mod dataset_meta;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -6,7 +10,20 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![commands::set_window_title])
|
.setup(|app| {
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
let config = config::AppConfig::load_or_init(&app_handle)?;
|
||||||
|
app.manage(config::AppConfigState::new(config));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::set_window_title,
|
||||||
|
commands::load_app_config,
|
||||||
|
commands::save_app_config,
|
||||||
|
commands::list_proxy_kind_options,
|
||||||
|
commands::load_dataset_meta_by_dir,
|
||||||
|
commands::save_dataset_meta_by_dir
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,11 @@
|
|||||||
html, body {
|
html, body {
|
||||||
@apply size-full;
|
@apply size-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
&[type='number']::-webkit-outer-spin-button,
|
||||||
|
&[type='number']::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...$$props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m9.55 15.88l8.802-8.801q.146-.146.344-.156t.363.156t.166.357t-.165.356l-8.944 8.95q-.243.243-.566.243t-.566-.243l-4.05-4.05q-.146-.146-.152-.347t.158-.366t.357-.165t.357.165z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 299 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...$$props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M11.643 13.933q-.143-.143-.143-.356v-7q0-.212.144-.356t.357-.144t.356.144t.143.356v7q0 .213-.144.356t-.357.144t-.356-.144m0 3.846q-.143-.144-.143-.357t.144-.356t.357-.143t.356.144t.143.357t-.144.356t-.357.143t-.356-.144" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...$$props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10.91 15.242q-.168 0-.289-.11q-.121-.112-.121-.293V9.162q0-.182.124-.293t.288-.111q.042 0 .284.13l2.677 2.678q.093.092.143.199t.05.235t-.05.235t-.143.2l-2.677 2.677q-.055.055-.129.093q-.073.037-.157.037" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...$$props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4.126 20q-.234 0-.414-.111t-.28-.293q-.108-.179-.12-.387q-.01-.209.118-.421L11.3 5.212q.128-.212.308-.308T12 4.808t.391.096t.308.308l7.871 13.576q.128.212.115.417t-.118.391t-.282.295t-.41.109zm.324-1h15.1L12 6zm7.984-1.566q.182-.182.182-.434t-.182-.434t-.434-.181t-.434.181t-.182.434t.182.434t.434.181t.434-.181m-.077-2.193q.143-.144.143-.356v-4q0-.213-.144-.357t-.357-.143t-.356.143t-.143.357v4q0 .212.144.356t.357.144t.356-.144M12 12.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 563 B |
@@ -1,6 +1,5 @@
|
|||||||
import type { DatasetMeta, ImageMeta } from "$lib/types/meta";
|
import type { DatasetMeta, ImageMeta } from "$lib/types/meta";
|
||||||
import { join } from "@tauri-apps/api/path";
|
import { saveDatasetMetaByDir } from "$lib/utils/dataset-meta";
|
||||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
|
||||||
import { get, writable } from "svelte/store";
|
import { get, writable } from "svelte/store";
|
||||||
|
|
||||||
export const openedDatasetDir = writable<string | null>(null);
|
export const openedDatasetDir = writable<string | null>(null);
|
||||||
@@ -25,8 +24,7 @@ export async function updateActiveDatasetMeta(updater: DatasetMetaUpdater): Prom
|
|||||||
const nextMeta =
|
const nextMeta =
|
||||||
typeof updater === "function" ? updater(currentMeta) : { ...currentMeta, ...updater };
|
typeof updater === "function" ? updater(currentMeta) : { ...currentMeta, ...updater };
|
||||||
|
|
||||||
const metaPath = await join(datasetDir, "meta.json");
|
await saveDatasetMetaByDir(datasetDir, nextMeta);
|
||||||
await writeTextFile(metaPath, JSON.stringify(nextMeta, null, 2));
|
|
||||||
activeDatasetMeta.set(nextMeta);
|
activeDatasetMeta.set(nextMeta);
|
||||||
|
|
||||||
return nextMeta;
|
return nextMeta;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { TargetModels } from "$lib/types/models";
|
||||||
|
|
||||||
export type DatasetMeta = {
|
export type DatasetMeta = {
|
||||||
name: string;
|
name: string;
|
||||||
targetModel: string;
|
targetModel: TargetModels;
|
||||||
loraType: string;
|
loraType: string;
|
||||||
unifiedImageSize: boolean;
|
unifiedImageSize: boolean;
|
||||||
unifiedImageRatio: boolean;
|
unifiedImageRatio: boolean;
|
||||||
|
|||||||
@@ -12,3 +12,14 @@ export const ModelChoices: Option<TargetModels>[] = [
|
|||||||
{ label: 'Flux', value: 'flux' },
|
{ label: 'Flux', value: 'flux' },
|
||||||
{ label: 'Flux 2', value: 'flux2' },
|
{ label: 'Flux 2', value: 'flux2' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type LoRATypes = 'char' | 'clothing' | 'style' | 'concept' | 'scene' | 'other';
|
||||||
|
|
||||||
|
export const LoRATypeChoices: Option<LoRATypes>[] = [
|
||||||
|
{ label: 'Character', value: 'char' },
|
||||||
|
{ label: 'Clothing', value: 'clothing' },
|
||||||
|
{ label: 'Style', value: 'style' },
|
||||||
|
{ label: 'Concept', value: 'concept' },
|
||||||
|
{ label: 'Scene', value: 'scene' },
|
||||||
|
{ label: 'Other', value: 'other' },
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export type ProxyKind = 'Http' | 'Socks5';
|
||||||
|
|
||||||
|
export type ProxyConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
kind: ProxyKind;
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
username: string | null;
|
||||||
|
password: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
proxy?: Partial<ProxyConfig> | null;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export async function loadAppConfig() {
|
||||||
|
return invoke<AppConfig>('load_app_config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAppConfig(config: AppConfig) {
|
||||||
|
await invoke('save_app_config', { config });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProxyConfig(config: AppConfig): ProxyConfig {
|
||||||
|
const proxy = config.proxy ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: proxy.enabled === true,
|
||||||
|
kind: proxy.kind === 'Socks5' ? 'Socks5' : 'Http',
|
||||||
|
host: typeof proxy.host === 'string' ? proxy.host : null,
|
||||||
|
port: typeof proxy.port === 'number' && Number.isInteger(proxy.port) ? proxy.port : null,
|
||||||
|
username: typeof proxy.username === 'string' ? proxy.username : null,
|
||||||
|
password: typeof proxy.password === 'string' ? proxy.password : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProxyConfig(updater: (current: ProxyConfig) => ProxyConfig) {
|
||||||
|
const currentConfig = await loadAppConfig();
|
||||||
|
const nextProxyConfig = updater(getProxyConfig(currentConfig));
|
||||||
|
await saveAppConfig({
|
||||||
|
...currentConfig,
|
||||||
|
proxy: nextProxyConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextProxyConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { DatasetMeta } from '$lib/types/meta';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export async function loadDatasetMetaByDir(datasetDir: string) {
|
||||||
|
return invoke<DatasetMeta>('load_dataset_meta_by_dir', { datasetDir });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDatasetMetaByDir(datasetDir: string, meta: DatasetMeta) {
|
||||||
|
await invoke('save_dataset_meta_by_dir', { datasetDir, meta });
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type SaveFeedbackState = 'idle' | 'updated' | 'not-updated';
|
||||||
|
|
||||||
|
export function createSaveFeedbackController(
|
||||||
|
setState: (state: SaveFeedbackState) => void,
|
||||||
|
resetDelayMs = 2000,
|
||||||
|
) {
|
||||||
|
let feedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const resetLater = () => {
|
||||||
|
if (feedbackTimer) {
|
||||||
|
clearTimeout(feedbackTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
feedbackTimer = setTimeout(() => {
|
||||||
|
setState('idle');
|
||||||
|
}, resetDelayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
markUpdated() {
|
||||||
|
setState('updated');
|
||||||
|
resetLater();
|
||||||
|
},
|
||||||
|
markNotUpdated() {
|
||||||
|
setState('not-updated');
|
||||||
|
resetLater();
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (feedbackTimer) {
|
||||||
|
clearTimeout(feedbackTimer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDebouncedTrigger(callback: () => void, delayMs: number) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trigger() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
callback();
|
||||||
|
}, delayMs);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,15 +4,56 @@ import NewFolder from '$lib/components/icons/NewFolder.svelte';
|
|||||||
import OpenFolder from '$lib/components/icons/OpenFolder.svelte';
|
import OpenFolder from '$lib/components/icons/OpenFolder.svelte';
|
||||||
import { openedDatasetDir } from '$lib/stores/dataset';
|
import { openedDatasetDir } from '$lib/stores/dataset';
|
||||||
import { currentActivate } from '$lib/stores/navigate';
|
import { currentActivate } from '$lib/stores/navigate';
|
||||||
import { isDatasetMeta } from '$lib/types/meta';
|
import { loadDatasetMetaByDir } from '$lib/utils/dataset-meta';
|
||||||
import { join } from '@tauri-apps/api/path';
|
|
||||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
import { message, open } from '@tauri-apps/plugin-dialog';
|
||||||
import { readDir, readTextFile } from '@tauri-apps/plugin-fs';
|
|
||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
currentActivate.set('start');
|
currentActivate.set('start');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizeErrorMessage(error: unknown): string {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDatasetLoadPrompt(error: unknown): { title: string; text: string } {
|
||||||
|
const message = normalizeErrorMessage(error);
|
||||||
|
const detail = message.replace('读取数据集元数据失败:', '').trim();
|
||||||
|
|
||||||
|
if (detail.includes('缺少 meta.toml')) {
|
||||||
|
return {
|
||||||
|
title: 'Missing meta.toml',
|
||||||
|
text: 'The selected folder does not contain meta.toml. Please choose a valid dataset folder.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.includes('TOML parse error')) {
|
||||||
|
return {
|
||||||
|
title: 'Invalid meta.toml',
|
||||||
|
text: 'meta.toml has invalid TOML syntax. Please fix the file and try again.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.includes('不能为空') || detail.includes('imageSize 必须为正整数')) {
|
||||||
|
return {
|
||||||
|
title: 'Invalid Dataset Metadata',
|
||||||
|
text: `meta.toml content is invalid: ${detail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'Invalid Dataset Folder',
|
||||||
|
text: `Failed to read dataset metadata: ${detail || 'Unknown error'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDataset() {
|
async function loadDataset() {
|
||||||
try {
|
try {
|
||||||
const selectResult = await open({
|
const selectResult = await open({
|
||||||
@@ -34,25 +75,13 @@ async function loadDataset() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = await readDir(selectedPath);
|
try {
|
||||||
const hasMetaFile = entries.some((entry) => entry.name === 'meta.json');
|
await loadDatasetMetaByDir(selectedPath);
|
||||||
|
} catch (error) {
|
||||||
if (!hasMetaFile) {
|
const prompt = resolveDatasetLoadPrompt(error);
|
||||||
await message('The selected folder is not a valid dataset folder.', {
|
await message(prompt.text, {
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Invalid Dataset Folder',
|
title: prompt.title,
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaPath = await join(selectedPath, 'meta.json');
|
|
||||||
const metaText = await readTextFile(metaPath);
|
|
||||||
const parsedMeta: unknown = JSON.parse(metaText);
|
|
||||||
|
|
||||||
if (!isDatasetMeta(parsedMeta)) {
|
|
||||||
await message('Dataset meta information is invalid.', {
|
|
||||||
kind: 'warning',
|
|
||||||
title: 'Invalid meta.json',
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Close from '$lib/components/icons/Close.svelte';
|
|||||||
import { activeDatasetMeta, openedDatasetDir, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
import { activeDatasetMeta, openedDatasetDir, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
import { currentActivate } from '$lib/stores/navigate';
|
import { currentActivate } from '$lib/stores/navigate';
|
||||||
import type { DatasetMeta } from '$lib/types/meta';
|
import type { DatasetMeta } from '$lib/types/meta';
|
||||||
|
import { ModelChoices } from '$lib/types/models';
|
||||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
import { message, open } from '@tauri-apps/plugin-dialog';
|
||||||
import { readDir } from '@tauri-apps/plugin-fs';
|
import { readDir } from '@tauri-apps/plugin-fs';
|
||||||
import { isNil } from 'es-toolkit';
|
import { isNil } from 'es-toolkit';
|
||||||
@@ -14,6 +15,7 @@ afterNavigate(() => {
|
|||||||
|
|
||||||
let storePath = $state('');
|
let storePath = $state('');
|
||||||
let datasetName = $state('');
|
let datasetName = $state('');
|
||||||
|
let targetModel = $state(ModelChoices[0].value);
|
||||||
|
|
||||||
async function backToBoot() {
|
async function backToBoot() {
|
||||||
await goto('/boot');
|
await goto('/boot');
|
||||||
@@ -58,7 +60,7 @@ async function createDataset() {
|
|||||||
if (!isNil(storePath) && !isNil(datasetName) && storePath.trim() && datasetName.trim()) {
|
if (!isNil(storePath) && !isNil(datasetName) && storePath.trim() && datasetName.trim()) {
|
||||||
const datasetMeta: DatasetMeta = {
|
const datasetMeta: DatasetMeta = {
|
||||||
name: datasetName.trim(),
|
name: datasetName.trim(),
|
||||||
targetModel: '',
|
targetModel: targetModel,
|
||||||
loraType: '',
|
loraType: '',
|
||||||
unifiedImageSize: true,
|
unifiedImageSize: true,
|
||||||
unifiedImageRatio: true,
|
unifiedImageRatio: true,
|
||||||
@@ -109,5 +111,13 @@ async function createDataset() {
|
|||||||
<span class="label min-w-[10em]">Dataset Name</span>
|
<span class="label min-w-[10em]">Dataset Name</span>
|
||||||
<input type="text" class="min-w-[20em]" bind:value={datasetName} />
|
<input type="text" class="min-w-[20em]" bind:value={datasetName} />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="input input-md focus-within:outline-0 w-fit">
|
||||||
|
<span class="label min-w-[10em]">Target Model</span>
|
||||||
|
<select class="min-w-[20em] focus:outline-0" bind:value={targetModel}>
|
||||||
|
{#each ModelChoices as model}
|
||||||
|
<option value={model.value}>{model.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button class="btn btn-primary btn-md w-fit self-center" onclick={createDataset}>Create</button>
|
<button class="btn btn-primary btn-md w-fit self-center" onclick={createDataset}>Create</button>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,18 +3,37 @@ import { afterNavigate } from '$app/navigation';
|
|||||||
import { activeDatasetMeta } from '$lib/stores/dataset';
|
import { activeDatasetMeta } from '$lib/stores/dataset';
|
||||||
import { currentActivate } from '$lib/stores/navigate';
|
import { currentActivate } from '$lib/stores/navigate';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { get } from 'svelte/store';
|
import { onDestroy } from 'svelte';
|
||||||
|
import { type Unsubscriber } from 'svelte/store';
|
||||||
|
import DatasetInfoForm from './DatasetInfoForm.svelte';
|
||||||
|
import ModelCheckList from './ModelCheckList.svelte';
|
||||||
|
|
||||||
|
let unsubscribeMeta: Unsubscriber | null = null;
|
||||||
|
|
||||||
|
function bindWindowTitleToMeta() {
|
||||||
|
unsubscribeMeta = activeDatasetMeta.subscribe((meta) => {
|
||||||
|
void invoke('set_window_title', { title: meta?.name ?? '' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindWindowTitleToMeta();
|
||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(() => {
|
||||||
currentActivate.set('dataset');
|
currentActivate.set('dataset');
|
||||||
|
});
|
||||||
|
|
||||||
invoke('set_window_title', { title: get(activeDatasetMeta)?.name });
|
onDestroy(() => {
|
||||||
|
if (unsubscribeMeta) {
|
||||||
|
unsubscribeMeta();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="px-4 py-4 size-full flex flex-col items-stretch gap-1 overflow-hidden">
|
<main class="px-4 py-4 size-full flex flex-col items-stretch gap-1 overflow-hidden">
|
||||||
<h2>Dataset Settings</h2>
|
<h2>Dataset Settings</h2>
|
||||||
<hr class="border-zinc-500" />
|
<hr class="border-zinc-500" />
|
||||||
|
<DatasetInfoForm />
|
||||||
<h2>Environment Check</h2>
|
<h2>Environment Check</h2>
|
||||||
<hr class="border-zinc-500" />
|
<hr class="border-zinc-500" />
|
||||||
|
<ModelCheckList />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { activeDatasetImageMetas, activeDatasetMeta, openedDatasetDir } from '$lib/stores/dataset';
|
import { activeDatasetImageMetas, activeDatasetMeta, openedDatasetDir } from '$lib/stores/dataset';
|
||||||
import { isDatasetMeta, type ImageMeta } from '$lib/types/meta';
|
import type { ImageMeta } from '$lib/types/meta';
|
||||||
|
import { loadDatasetMetaByDir } from '$lib/utils/dataset-meta';
|
||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { readDir, readTextFile } from '@tauri-apps/plugin-fs';
|
import { readDir, readTextFile } from '@tauri-apps/plugin-fs';
|
||||||
import { isNil } from 'es-toolkit';
|
import { isNil } from 'es-toolkit';
|
||||||
@@ -16,18 +17,8 @@ export const load = (async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metaPath = await join(activeDataset, 'meta.json');
|
const meta = await loadDatasetMetaByDir(activeDataset);
|
||||||
const metaText = await readTextFile(metaPath);
|
activeDatasetMeta.set(meta);
|
||||||
const parsedMeta: unknown = JSON.parse(metaText);
|
|
||||||
|
|
||||||
if (!isDatasetMeta(parsedMeta)) {
|
|
||||||
activeDatasetMeta.set(null);
|
|
||||||
activeDatasetImageMetas.set([]);
|
|
||||||
await goto('/boot');
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
activeDatasetMeta.set(parsedMeta);
|
|
||||||
|
|
||||||
const entries = await readDir(activeDataset);
|
const entries = await readDir(activeDataset);
|
||||||
const hasImagesMeta = entries.some((entry) => entry.name === 'images.json');
|
const hasImagesMeta = entries.some((entry) => entry.name === 'images.json');
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DatasetName from './meta-form/DatasetName.svelte';
|
||||||
|
import SelectLoraType from './meta-form/SelectLoraType.svelte';
|
||||||
|
import SelectModel from './meta-form/SelectModel.svelte';
|
||||||
|
import TargetSize from './meta-form/TargetSize.svelte';
|
||||||
|
import TriggerWord from './meta-form/TriggerWord.svelte';
|
||||||
|
import UnifiedSizeChoice from './meta-form/UnifiedSizeChoice.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grow flex flex-col gap-2 px-4 py-4">
|
||||||
|
<DatasetName />
|
||||||
|
<SelectModel />
|
||||||
|
<SelectLoraType />
|
||||||
|
<UnifiedSizeChoice />
|
||||||
|
<TargetSize />
|
||||||
|
<TriggerWord />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script>
|
||||||
|
import FlorenceModel from './model-check/FlorenceModel.svelte';
|
||||||
|
import TagFile from './model-check/TagFile.svelte';
|
||||||
|
import TaggerModel from './model-check/TaggerModel.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-[8em] px-4 py-2 shrink-0 flex flex-row gap-6">
|
||||||
|
<TaggerModel />
|
||||||
|
<FlorenceModel />
|
||||||
|
<TagFile />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { activeDatasetMeta, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
name = get(activeDatasetMeta)?.name ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveDatasetName(value: string) {
|
||||||
|
const nextName = value.trim();
|
||||||
|
|
||||||
|
if (!nextName) {
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ name: nextName });
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save dataset name:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void saveDatasetName(name);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
function saveNameDelayed() {
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Dataset Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
oninput={saveNameDelayed}
|
||||||
|
class="min-w-[20em] focus:outline-none" />
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { activeDatasetMeta, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
|
import { LoRATypeChoices, type LoRATypes } from '$lib/types/models';
|
||||||
|
import { createSaveFeedbackController, type SaveFeedbackState } from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let selected: LoRATypes | null = $state(null);
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const currentType = get(activeDatasetMeta)?.loraType;
|
||||||
|
const match = LoRATypeChoices.find((item) => item.value === currentType);
|
||||||
|
selected = match?.value ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveLoraType() {
|
||||||
|
if (!selected) {
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ loraType: selected });
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save lora type:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">LoRA Type</span>
|
||||||
|
<select bind:value={selected} onchange={saveLoraType} class="min-w-[20em] focus:outline-0">
|
||||||
|
{#each LoRATypeChoices as loraType}
|
||||||
|
<option value={loraType.value}>{loraType.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { activeDatasetMeta, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
|
import { ModelChoices, type TargetModels } from '$lib/types/models';
|
||||||
|
import { createSaveFeedbackController, type SaveFeedbackState } from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let selected: TargetModels | null = $state(null);
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
selected = get(activeDatasetMeta)?.targetModel ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveTargetModel() {
|
||||||
|
if (!selected) {
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ targetModel: selected });
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save target model:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Target Model</span>
|
||||||
|
<select bind:value={selected} onchange={saveTargetModel} class="min-w-[20em] focus:outline-0">
|
||||||
|
{#each ModelChoices as model}
|
||||||
|
<option value={model.value}>{model.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { activeDatasetMeta, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let width = $state<number>(512);
|
||||||
|
let height = $state<number>(512);
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const meta = get(activeDatasetMeta);
|
||||||
|
if (meta?.imageSize) {
|
||||||
|
width = meta.imageSize?.[0] ?? 512;
|
||||||
|
height = meta.imageSize?.[1] ?? 512;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveTargetSize(nextWidth: number, nextHeight: number) {
|
||||||
|
const normalizedWidth = Number.isFinite(nextWidth) ? Math.floor(nextWidth) : 0;
|
||||||
|
const normalizedHeight = Number.isFinite(nextHeight) ? Math.floor(nextHeight) : 0;
|
||||||
|
|
||||||
|
if (normalizedWidth <= 0 || normalizedHeight <= 0) {
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ imageSize: [normalizedWidth, normalizedHeight] });
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save target size:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void saveTargetSize(width, height);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
function saveTargetSizeDelayed() {
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Scale To</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="text-right max-w-[5em]"
|
||||||
|
disabled={!($activeDatasetMeta?.unifiedImageSize ?? true)}
|
||||||
|
bind:value={width}
|
||||||
|
oninput={saveTargetSizeDelayed} />
|
||||||
|
<span>×</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="text-right max-w-[5em]"
|
||||||
|
disabled={!($activeDatasetMeta?.unifiedImageSize ?? true)}
|
||||||
|
bind:value={height}
|
||||||
|
oninput={saveTargetSizeDelayed} />
|
||||||
|
<span>px</span>
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TriangleRight from '$lib/components/icons/TriangleRight.svelte';
|
||||||
|
import { activeDatasetMeta, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let triggerWords = $state<string[]>([]);
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
let rowFeedback = $state<Record<number, SaveFeedbackState>>({});
|
||||||
|
let editingRowIndex: number | null = null;
|
||||||
|
|
||||||
|
const inputRefs: Array<HTMLInputElement | null> = [];
|
||||||
|
const rowFeedbackTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
triggerWords = get(activeDatasetMeta)?.triggerWords ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
|
||||||
|
for (const timer of rowFeedbackTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
rowFeedbackTimers.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function markRowFeedback(index: number, state: SaveFeedbackState) {
|
||||||
|
const previous = rowFeedbackTimers.get(index);
|
||||||
|
if (previous) {
|
||||||
|
clearTimeout(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
rowFeedback = { ...rowFeedback, [index]: state };
|
||||||
|
|
||||||
|
if (state !== 'idle') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
rowFeedback = { ...rowFeedback, [index]: 'idle' };
|
||||||
|
rowFeedbackTimers.delete(index);
|
||||||
|
}, 2000);
|
||||||
|
rowFeedbackTimers.set(index, timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllRowFeedback() {
|
||||||
|
for (const timer of rowFeedbackTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
rowFeedbackTimers.clear();
|
||||||
|
rowFeedback = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistTriggerWords() {
|
||||||
|
const nextTriggerWords = triggerWords.filter((word) => word.trim() !== '');
|
||||||
|
const removedEmptyRows = nextTriggerWords.length !== triggerWords.length;
|
||||||
|
|
||||||
|
if (removedEmptyRows) {
|
||||||
|
triggerWords = nextTriggerWords;
|
||||||
|
clearAllRowFeedback();
|
||||||
|
editingRowIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ triggerWords: [...nextTriggerWords] });
|
||||||
|
feedback.markUpdated();
|
||||||
|
if (editingRowIndex !== null) {
|
||||||
|
markRowFeedback(editingRowIndex, 'updated');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save trigger words:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
if (editingRowIndex !== null) {
|
||||||
|
markRowFeedback(editingRowIndex, 'not-updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void persistTriggerWords();
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
function scheduleSave(index: number | null) {
|
||||||
|
editingRowIndex = index;
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWordInput(index: number, event: Event) {
|
||||||
|
const target = event.currentTarget as HTMLInputElement;
|
||||||
|
triggerWords = triggerWords.map((word, i) => (i === index ? target.value : word));
|
||||||
|
scheduleSave(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTriggerWord() {
|
||||||
|
const nextIndex = triggerWords.length;
|
||||||
|
triggerWords = [...triggerWords, ''];
|
||||||
|
scheduleSave(nextIndex);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
inputRefs[nextIndex]?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
class={[
|
||||||
|
'fieldset transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'text-green-600',
|
||||||
|
saveFeedback === 'not-updated' && 'text-red-600',
|
||||||
|
]}>
|
||||||
|
<legend class="fieldset-legend">Trigger Word</legend>
|
||||||
|
<ul class="list max-h-[12em] overflow-y-auto">
|
||||||
|
{#each triggerWords as word, index}
|
||||||
|
<li class="list-row py-1 gap-0">
|
||||||
|
<div class="flex justify-center"><TriangleRight width="20" /></div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class={[
|
||||||
|
'input input-ghost input-sm list-col-grow w-full focus:outline-none transition-colors duration-300 ease-out',
|
||||||
|
rowFeedback[index] === 'updated' && 'text-green-600',
|
||||||
|
rowFeedback[index] === 'not-updated' && 'text-red-600',
|
||||||
|
]}
|
||||||
|
bind:value={triggerWords[index]}
|
||||||
|
bind:this={inputRefs[index]}
|
||||||
|
oninput={(event) => onWordInput(index, event)} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<button type="button" class="btn btn-sm self-auto" onclick={addTriggerWord}
|
||||||
|
>Add Trigger Word</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { activeDatasetMeta, updateActiveDatasetMeta } from '$lib/stores/dataset';
|
||||||
|
import { createSaveFeedbackController, type SaveFeedbackState } from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
let unifiedSize = $state<boolean>(false);
|
||||||
|
let unifiedRatio = $state<boolean>(false);
|
||||||
|
let sizeFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
let ratioFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const sizeSaveFeedback = createSaveFeedbackController((state) => {
|
||||||
|
sizeFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratioSaveFeedback = createSaveFeedbackController((state) => {
|
||||||
|
ratioFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const meta = get(activeDatasetMeta);
|
||||||
|
unifiedSize = meta?.unifiedImageSize ?? false;
|
||||||
|
unifiedRatio = meta?.unifiedImageRatio ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
sizeSaveFeedback.dispose();
|
||||||
|
ratioSaveFeedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveUnifiedSize() {
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ unifiedImageSize: unifiedSize });
|
||||||
|
sizeSaveFeedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save unified size option:', error);
|
||||||
|
sizeSaveFeedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUnifiedRatio() {
|
||||||
|
try {
|
||||||
|
await updateActiveDatasetMeta({ unifiedImageRatio: unifiedRatio });
|
||||||
|
ratioSaveFeedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save unified ratio option:', error);
|
||||||
|
ratioSaveFeedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-3 h-(--size) py-2">
|
||||||
|
<div class="min-w-[10em]"> </div>
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'label transition-colors duration-300 ease-out',
|
||||||
|
sizeFeedback === 'updated' && 'text-green-600',
|
||||||
|
sizeFeedback === 'not-updated' && 'text-red-600',
|
||||||
|
]}>
|
||||||
|
<input type="checkbox" bind:checked={unifiedSize} onchange={saveUnifiedSize} class="toggle" />
|
||||||
|
Unified Size
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'label transition-colors duration-300 ease-out',
|
||||||
|
ratioFeedback === 'updated' && 'text-green-600',
|
||||||
|
ratioFeedback === 'not-updated' && 'text-red-600',
|
||||||
|
]}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={unifiedRatio}
|
||||||
|
onchange={saveUnifiedRatio}
|
||||||
|
disabled={!unifiedSize}
|
||||||
|
class="toggle" />
|
||||||
|
Unified Ratio
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import ModelItem from './ModelItem.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModelItem status="ok">Florence 2</ModelItem>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Check from '$lib/components/icons/Check.svelte';
|
||||||
|
import Close from '$lib/components/icons/Close.svelte';
|
||||||
|
import Warning from '$lib/components/icons/Warning.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type ModelItemProps = {
|
||||||
|
status: 'ok' | 'unexists' | 'downloading' | 'error';
|
||||||
|
progress?: number;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { status, progress, children }: ModelItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center w-32 gap-3">
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'radial-progress',
|
||||||
|
status === 'ok' && 'text-green-500',
|
||||||
|
status === 'unexists' && 'text-orange-500',
|
||||||
|
status === 'downloading' && 'text-blue-500',
|
||||||
|
status === 'error' && 'text-red-500',
|
||||||
|
]}
|
||||||
|
style:--value={status === 'downloading' ? (progress ?? 0) : 100}
|
||||||
|
style:--size="4rem"
|
||||||
|
role="progressbar">
|
||||||
|
{#if status === 'downloading' && progress !== undefined}
|
||||||
|
{progress}%
|
||||||
|
{:else if status === 'ok'}
|
||||||
|
<Check width="30" />
|
||||||
|
{:else if status === 'unexists'}
|
||||||
|
<Warning width="30" />
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<Close width="30" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="text-center">{@render children?.()}</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import ModelItem from './ModelItem.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModelItem status="ok">Tag File</ModelItem>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import ModelItem from './ModelItem.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModelItem status="ok">WD ViT Tagger</ModelItem>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
|
import { currentActivate } from '$lib/stores/navigate';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { children, data }: { data: { activePath: string }; children: Snippet } = $props();
|
||||||
|
afterNavigate(() => {
|
||||||
|
currentActivate.set('settings');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="px-4 py-4 size-full flex flex-row items-stretch gap-0 overflow-hidden">
|
||||||
|
<nav class="max-w-[12em] grow flex flex-col items-stretch gap-2">
|
||||||
|
<a
|
||||||
|
href="/settings/proxy"
|
||||||
|
class={[
|
||||||
|
'inline-block px-4 py-2 rounded-md hover:bg-cyan-700',
|
||||||
|
data.activePath === '/settings/proxy' && 'bg-cyan-900',
|
||||||
|
]}>Proxy</a>
|
||||||
|
</nav>
|
||||||
|
<div class="divider divider-horizontal mx-2"></div>
|
||||||
|
<div class="grow">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ url }) => {
|
||||||
|
const path = url.pathname;
|
||||||
|
return { activePath: path };
|
||||||
|
}) satisfies LayoutLoad;
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { afterNavigate } from '$app/navigation';
|
|
||||||
import { currentActivate } from '$lib/stores/navigate';
|
|
||||||
|
|
||||||
afterNavigate(() => {
|
|
||||||
currentActivate.set('settings');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
|
throw redirect(307, "/settings/proxy");
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EnableProxy from './EnableProxy.svelte';
|
||||||
|
import ProtocolSelect from './ProtocolSelect.svelte';
|
||||||
|
import ProxyHost from './ProxyHost.svelte';
|
||||||
|
import ProxyPassword from './ProxyPassword.svelte';
|
||||||
|
import ProxyPort from './ProxyPort.svelte';
|
||||||
|
import ProxyUsername from './ProxyUsername.svelte';
|
||||||
|
|
||||||
|
let enabledProxy = $state<boolean>(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="px-2 py-4 flex flex-col gap-2">
|
||||||
|
<ProtocolSelect />
|
||||||
|
<ProxyHost />
|
||||||
|
<ProxyPort />
|
||||||
|
<ProxyUsername />
|
||||||
|
<ProxyPassword />
|
||||||
|
<EnableProxy />
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getProxyConfig, loadAppConfig, updateProxyConfig } from '$lib/utils/app-config';
|
||||||
|
import { createSaveFeedbackController, type SaveFeedbackState } from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let enabledProxy = $state<boolean>(false);
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadAppConfig();
|
||||||
|
enabledProxy = getProxyConfig(config).enabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proxy enabled status:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveEnabledProxy() {
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
enabled: enabledProxy,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save proxy enabled status:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-3 h-(--size) py-2">
|
||||||
|
<div class="min-w-[10em]"> </div>
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'label transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'text-green-600',
|
||||||
|
saveFeedback === 'not-updated' && 'text-red-600',
|
||||||
|
]}>
|
||||||
|
<input type="checkbox" bind:checked={enabledProxy} onchange={saveEnabledProxy} class="toggle" />
|
||||||
|
Enable Proxy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Option } from '$lib/types/base';
|
||||||
|
import {
|
||||||
|
getProxyConfig,
|
||||||
|
loadAppConfig,
|
||||||
|
updateProxyConfig,
|
||||||
|
type ProxyKind,
|
||||||
|
} from '$lib/utils/app-config';
|
||||||
|
import { createSaveFeedbackController, type SaveFeedbackState } from '$lib/utils/form-save';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let selectedProtocol = $state<string>('Http');
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
const protocolChoicesPromise = invoke<Option<string>[]>('list_proxy_kind_options');
|
||||||
|
|
||||||
|
function isProxyKind(value: string): value is ProxyKind {
|
||||||
|
return value === 'Http' || value === 'Socks5';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadAppConfig();
|
||||||
|
selectedProtocol = getProxyConfig(config).kind;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proxy protocol:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveProtocol() {
|
||||||
|
if (!isProxyKind(selectedProtocol)) {
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProtocol: ProxyKind = selectedProtocol;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
kind: nextProtocol,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save proxy protocol:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Protocol</span>
|
||||||
|
<select
|
||||||
|
bind:value={selectedProtocol}
|
||||||
|
onchange={saveProtocol}
|
||||||
|
class="min-w-[20em] focus:outline-none">
|
||||||
|
{#await protocolChoicesPromise then ProtocolChoices}
|
||||||
|
{#each ProtocolChoices as protocol}
|
||||||
|
<option value={protocol.value}>{protocol.label}</option>
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getProxyConfig, loadAppConfig, updateProxyConfig } from '$lib/utils/app-config';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let proxyHost = $state<string>('');
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadAppConfig();
|
||||||
|
proxyHost = getProxyConfig(config).host ?? '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proxy host:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveProxyHost(value: string) {
|
||||||
|
const nextHost = value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
host: nextHost || null,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save proxy host:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void saveProxyHost(proxyHost);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
function saveHostDelayed() {
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Host</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={proxyHost}
|
||||||
|
oninput={saveHostDelayed}
|
||||||
|
class="min-w-[20em] focus:outline-none" />
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getProxyConfig, loadAppConfig, updateProxyConfig } from '$lib/utils/app-config';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let proxyPassword = $state<string>('');
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadAppConfig();
|
||||||
|
proxyPassword = getProxyConfig(config).password ?? '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proxy password:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveProxyPassword(value: string) {
|
||||||
|
const nextPassword = value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
password: nextPassword || null,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save proxy password:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void saveProxyPassword(proxyPassword);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
function savePasswordDelayed() {
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={proxyPassword}
|
||||||
|
oninput={savePasswordDelayed}
|
||||||
|
class="min-w-[20em] focus:outline-none" />
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getProxyConfig, loadAppConfig, updateProxyConfig } from '$lib/utils/app-config';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let proxyPort = $state<number | null>(null);
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadAppConfig();
|
||||||
|
const port = getProxyConfig(config).port;
|
||||||
|
proxyPort = port ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proxy port:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveProxyPort(value: number | null) {
|
||||||
|
if (value === null || Number.isNaN(value)) {
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
port: null,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear proxy port:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
port: value,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save proxy port:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void saveProxyPort(proxyPort);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
function savePortDelayed() {
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Port</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={proxyPort}
|
||||||
|
oninput={savePortDelayed}
|
||||||
|
class="min-w-[10em] focus:outline-none" />
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getProxyConfig, loadAppConfig, updateProxyConfig } from '$lib/utils/app-config';
|
||||||
|
import {
|
||||||
|
createDebouncedTrigger,
|
||||||
|
createSaveFeedbackController,
|
||||||
|
type SaveFeedbackState,
|
||||||
|
} from '$lib/utils/form-save';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
let proxyUsername = $state<string>('');
|
||||||
|
let saveFeedback = $state<SaveFeedbackState>('idle');
|
||||||
|
|
||||||
|
const feedback = createSaveFeedbackController((state) => {
|
||||||
|
saveFeedback = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadAppConfig();
|
||||||
|
proxyUsername = getProxyConfig(config).username ?? '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proxy username:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
saveLater.cancel();
|
||||||
|
feedback.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveProxyUsername(value: string) {
|
||||||
|
const nextUsername = value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProxyConfig((current) => ({
|
||||||
|
...current,
|
||||||
|
username: nextUsername || null,
|
||||||
|
}));
|
||||||
|
feedback.markUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save proxy username:', error);
|
||||||
|
feedback.markNotUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveLater = createDebouncedTrigger(() => {
|
||||||
|
void saveProxyUsername(proxyUsername);
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
function saveUsernameDelayed() {
|
||||||
|
saveLater.trigger();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={[
|
||||||
|
'input w-fit in-focus-within:outline-none transition-colors duration-300 ease-out',
|
||||||
|
saveFeedback === 'updated' && 'border-green-500',
|
||||||
|
saveFeedback === 'not-updated' && 'border-red-500',
|
||||||
|
]}>
|
||||||
|
<span class="label min-w-[10em]">Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={proxyUsername}
|
||||||
|
oninput={saveUsernameDelayed}
|
||||||
|
class="min-w-[20em] focus:outline-none" />
|
||||||
|
</label>
|
||||||
Reference in New Issue
Block a user