Compare commits

..

6 Commits

20 changed files with 663 additions and 47 deletions

View File

@@ -27,4 +27,5 @@ 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"

View File

@@ -1,4 +1,5 @@
use crate::config::{AppConfig, AppConfigState, ProxyKind};
use crate::dataset_meta::{load_dataset_meta, save_dataset_meta, DatasetMeta};
use tauri::State;
use tauri::{AppHandle, WebviewWindow};
@@ -55,6 +56,16 @@ pub fn list_proxy_kind_options() -> Vec<SelectOption> {
]
}
#[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",

View File

@@ -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)
}

View File

@@ -1,5 +1,6 @@
mod commands;
mod config;
mod dataset_meta;
use tauri::Manager;
@@ -19,7 +20,9 @@ pub fn run() {
commands::set_window_title,
commands::load_app_config,
commands::save_app_config,
commands::list_proxy_kind_options
commands::list_proxy_kind_options,
commands::load_dataset_meta_by_dir,
commands::save_dataset_meta_by_dir
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,6 +1,5 @@
import type { DatasetMeta, ImageMeta } from "$lib/types/meta";
import { join } from "@tauri-apps/api/path";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { saveDatasetMetaByDir } from "$lib/utils/dataset-meta";
import { get, writable } from "svelte/store";
export const openedDatasetDir = writable<string | null>(null);
@@ -25,8 +24,7 @@ export async function updateActiveDatasetMeta(updater: DatasetMetaUpdater): Prom
const nextMeta =
typeof updater === "function" ? updater(currentMeta) : { ...currentMeta, ...updater };
const metaPath = await join(datasetDir, "meta.json");
await writeTextFile(metaPath, JSON.stringify(nextMeta, null, 2));
await saveDatasetMetaByDir(datasetDir, nextMeta);
activeDatasetMeta.set(nextMeta);
return nextMeta;

View File

@@ -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;
}

View File

@@ -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 });
}

View File

@@ -4,15 +4,56 @@ import NewFolder from '$lib/components/icons/NewFolder.svelte';
import OpenFolder from '$lib/components/icons/OpenFolder.svelte';
import { openedDatasetDir } from '$lib/stores/dataset';
import { currentActivate } from '$lib/stores/navigate';
import { isDatasetMeta } from '$lib/types/meta';
import { join } from '@tauri-apps/api/path';
import { loadDatasetMetaByDir } from '$lib/utils/dataset-meta';
import { message, open } from '@tauri-apps/plugin-dialog';
import { readDir, readTextFile } from '@tauri-apps/plugin-fs';
afterNavigate(() => {
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() {
try {
const selectResult = await open({
@@ -34,25 +75,13 @@ async function loadDataset() {
return;
}
const entries = await readDir(selectedPath);
const hasMetaFile = entries.some((entry) => entry.name === 'meta.json');
if (!hasMetaFile) {
await message('The selected folder is not a valid dataset folder.', {
try {
await loadDatasetMetaByDir(selectedPath);
} catch (error) {
const prompt = resolveDatasetLoadPrompt(error);
await message(prompt.text, {
kind: 'warning',
title: 'Invalid Dataset Folder',
});
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',
title: prompt.title,
});
return;
}

View File

@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
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 { readDir, readTextFile } from '@tauri-apps/plugin-fs';
import { isNil } from 'es-toolkit';
@@ -16,18 +17,8 @@ export const load = (async () => {
}
try {
const metaPath = await join(activeDataset, 'meta.json');
const metaText = await readTextFile(metaPath);
const parsedMeta: unknown = JSON.parse(metaText);
if (!isDatasetMeta(parsedMeta)) {
activeDatasetMeta.set(null);
activeDatasetImageMetas.set([]);
await goto('/boot');
return {};
}
activeDatasetMeta.set(parsedMeta);
const meta = await loadDatasetMetaByDir(activeDataset);
activeDatasetMeta.set(meta);
const entries = await readDir(activeDataset);
const hasImagesMeta = entries.some((entry) => entry.name === 'images.json');

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => {
const path = url.pathname;
return { activePath: path };
}) satisfies LayoutLoad;

View File

@@ -1,8 +0,0 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { currentActivate } from '$lib/stores/navigate';
afterNavigate(() => {
currentActivate.set('settings');
});
</script>

View File

@@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => {
throw redirect(307, "/settings/proxy");
};

View File

@@ -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>

View File

@@ -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]">&nbsp;</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>