feat: scaffold AI image desktop MVP

This commit is contained in:
2026-04-24 17:58:59 +08:00
commit 6064b1c809
33 changed files with 8278 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
package-lock.json
src-tauri/target/
src-tauri/Cargo.lock
.DS_Store
**/.DS_Store
.env
.env.*

92
README.md Normal file
View File

@@ -0,0 +1,92 @@
# Image Draw AI
基于 Tauri 2 + Rust + React + SQLite 的跨平台图形 AI 工具骨架,目标兼容 Windows 和 macOS并预留 OpenAI 以及 OpenAI-compatible 中转站接入。
## 当前能力
- Tauri 2 桌面应用骨架
- React + Vite 前端界面
- Rust 后端 AppState
- SQLite 本地数据库初始化
- Provider 配置表,支持 `base_url` / 模型名 / 中转站配置
- 生成任务表,先保存任务历史
- OpenAI-compatible Provider 抽象,预留 `/images/generations` 调用
## 开发命令
```bash
pnpm install
pnpm tauri:dev
```
生成当前系统安装包:
```bash
pnpm build
```
macOS 打包:
```bash
pnpm build:mac
```
macOS Universal 打包:
```bash
rustup target add aarch64-apple-darwin x86_64-apple-darwin
pnpm build:mac:universal
```
Windows 打包:
```bash
pnpm build:win
```
只构建前端产物:
```bash
pnpm web:build
```
只检查 Rust 后端:
```bash
cd src-tauri
cargo check
```
## 跨平台打包
Tauri 打包通常需要在目标系统上构建:
- macOS 安装包:在 macOS 上运行 `pnpm build`,输出 `.app` / `.dmg`
- Windows 安装包:在 Windows 上运行 `pnpm build`,输出 `.msi` / `.exe`
后续可以用 GitHub Actions 分别跑 `macos-latest``windows-latest`,自动产出两个平台的安装包。
## 数据位置
SQLite 数据库会创建在 Tauri 的 `app_data_dir` 下:
```txt
image_draw_ai.sqlite
```
图片文件后续建议保存到同一目录下的 `images/` 子目录,数据库只保存图片路径和元数据。
## 中转站配置
Provider 设计支持 OpenAI-compatible 中转站:
```json
{
"kind": "openai-compatible",
"base_url": "https://api.openai.com/v1",
"text_model": "gpt-5",
"image_model": "gpt-image-2"
}
```
后续可以在 UI 中把 `base_url``api_key`、模型名做成可编辑配置。

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Draw AI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "image-draw-ai",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.0.0",
"scripts": {
"dev": "vite",
"web:build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"build": "tauri build",
"build:mac": "tauri build --bundles app,dmg",
"build:mac:universal": "tauri build --target universal-apple-darwin --bundles app,dmg",
"build:win": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0",
"typescript": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@tauri-apps/cli": "^2.0.0"
}
}

1259
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "image-draw-ai"
version = "0.1.0"
description = "Cross-platform desktop AI image tool"
authors = ["Image Draw AI"]
edition = "2021"
[lib]
name = "image_draw_ai_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
thiserror = "2"
async-trait = "0.1"
base64 = "0.22"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop permissions",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"opener:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","opener:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

View File

@@ -0,0 +1,68 @@
CREATE TABLE IF NOT EXISTS providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key_encrypted TEXT,
text_model TEXT,
image_model TEXT,
capabilities TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS generation_tasks (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
task_type TEXT NOT NULL,
prompt TEXT NOT NULL,
negative_prompt TEXT,
model TEXT NOT NULL,
size TEXT,
quality TEXT,
status TEXT NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
finished_at TEXT,
FOREIGN KEY(provider_id) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS image_assets (
id TEXT PRIMARY KEY,
task_id TEXT,
file_path TEXT NOT NULL,
thumbnail_path TEXT,
mime_type TEXT,
width INTEGER,
height INTEGER,
file_size INTEGER,
source_type TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES generation_tasks(id)
);
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT,
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(provider_id) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS ai_request_logs (
id TEXT PRIMARY KEY,
task_id TEXT,
provider_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_summary TEXT,
response_summary TEXT,
status_code INTEGER,
latency_ms INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES generation_tasks(id),
FOREIGN KEY(provider_id) REFERENCES providers(id)
);

2
src-tauri/src/ai/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod openai_compatible;
pub mod provider;

View File

@@ -0,0 +1,158 @@
use async_trait::async_trait;
use std::{fs, path::Path};
use reqwest::{multipart, Client};
use serde::{Deserialize, Serialize};
use super::provider::{AiProvider, ImageData, ImageEditRequest, ImageGenerateRequest, ImageResult};
use crate::AppError;
pub struct OpenAiCompatibleProvider {
client: Client,
base_url: String,
api_key: String,
}
impl OpenAiCompatibleProvider {
pub fn new(base_url: String, api_key: String) -> Self {
Self {
client: Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
}
}
}
fn parse_image_response(response_text: &str) -> Result<ImageResult, AppError> {
if response_text.trim_start().starts_with("<!doctype html")
|| response_text.trim_start().starts_with("<html")
{
return Err(AppError::Provider(
"provider returned an HTML page, not an API JSON response. Please use the API base URL, usually ending with /v1, not the gateway website URL.".to_string(),
));
}
let response_body: ImageResponseBody = serde_json::from_str(response_text).map_err(|error| {
AppError::Provider(format!(
"failed to decode image response: {error}; response body: {response_text}"
))
})?;
let image_data = response_body
.data
.into_iter()
.find_map(|item| {
item.b64_json
.map(ImageData::Base64)
.or_else(|| item.url.map(ImageData::Url))
})
.ok_or_else(|| AppError::Provider("image response did not include b64_json or url".to_string()))?;
Ok(ImageResult {
mime_type: "image/png".to_string(),
data: image_data,
})
}
#[derive(Debug, Serialize)]
struct ImageRequestBody<'a> {
model: &'a str,
prompt: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
size: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
quality: Option<&'a str>,
response_format: &'a str,
}
#[derive(Debug, Deserialize)]
struct ImageResponseBody {
data: Vec<ImageResponseItem>,
}
#[derive(Debug, Deserialize)]
struct ImageResponseItem {
b64_json: Option<String>,
url: Option<String>,
}
#[async_trait]
impl AiProvider for OpenAiCompatibleProvider {
async fn generate_image(&self, request: ImageGenerateRequest) -> Result<ImageResult, AppError> {
let body = ImageRequestBody {
model: &request.model,
prompt: &request.prompt,
size: request.size.as_deref(),
quality: request.quality.as_deref(),
response_format: "b64_json",
};
let response = self
.client
.post(format!("{}/images/generations", self.base_url))
.bearer_auth(&self.api_key)
.json(&body)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let message = response.text().await.unwrap_or_else(|_| "request failed".to_string());
return Err(AppError::Provider(format!("image generation failed ({status}): {message}")));
}
let response_text = response.text().await?;
parse_image_response(&response_text)
}
async fn edit_image(&self, request: ImageEditRequest) -> Result<ImageResult, AppError> {
let mut form = multipart::Form::new()
.text("model", request.model)
.text("prompt", request.prompt)
.text("response_format", "b64_json");
if let Some(size) = request.size {
form = form.text("size", size);
}
if let Some(quality) = request.quality {
form = form.text("quality", quality);
}
for image_path in request.image_paths {
let bytes = fs::read(&image_path)?;
let file_name = Path::new(&image_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("image.png")
.to_string();
let mime = match Path::new(&image_path)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
let part = multipart::Part::bytes(bytes).file_name(file_name).mime_str(mime)?;
form = form.part("image[]", part);
}
let response = self
.client
.post(format!("{}/images/edits", self.base_url))
.bearer_auth(&self.api_key)
.multipart(form)
.send()
.await?;
let status = response.status();
if !status.is_success() {
let message = response.text().await.unwrap_or_else(|_| "request failed".to_string());
return Err(AppError::Provider(format!("image edit failed ({status}): {message}")));
}
let response_text = response.text().await?;
parse_image_response(&response_text)
}
}

View File

@@ -0,0 +1,39 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::AppError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageGenerateRequest {
pub prompt: String,
pub model: String,
pub size: Option<String>,
pub quality: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageEditRequest {
pub prompt: String,
pub model: String,
pub size: Option<String>,
pub quality: Option<String>,
pub image_paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageResult {
pub mime_type: String,
pub data: ImageData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ImageData {
Base64(String),
Url(String),
}
#[async_trait]
pub trait AiProvider: Send + Sync {
async fn generate_image(&self, request: ImageGenerateRequest) -> Result<ImageResult, AppError>;
async fn edit_image(&self, request: ImageEditRequest) -> Result<ImageResult, AppError>;
}

View File

@@ -0,0 +1,18 @@
use tauri_plugin_dialog::DialogExt;
#[tauri::command]
pub async fn pick_material_images(app: tauri::AppHandle) -> Result<Vec<String>, String> {
let files = app
.dialog()
.file()
.add_filter("Images", &["png", "jpg", "jpeg", "webp"])
.blocking_pick_files();
let paths = files
.unwrap_or_default()
.into_iter()
.filter_map(|file_path| file_path.as_path().map(|path| path.to_string_lossy().to_string()))
.collect();
Ok(paths)
}

View File

@@ -0,0 +1,129 @@
use tauri::{AppHandle, State};
use crate::{
ai::{
openai_compatible::OpenAiCompatibleProvider,
provider::{AiProvider, ImageData, ImageEditRequest, ImageGenerateRequest},
},
db::{
models::{CreateGenerationTaskInput, GenerateImageInput, GenerateImageOutput, GenerationTask},
repository,
},
state::AppState,
storage,
AppError,
};
#[tauri::command]
pub async fn create_generation_task(
state: State<'_, AppState>,
input: CreateGenerationTaskInput,
) -> Result<GenerationTask, AppError> {
repository::create_generation_task(&state.db, input).await
}
#[tauri::command]
pub async fn generate_image(
app: AppHandle,
state: State<'_, AppState>,
input: GenerateImageInput,
) -> Result<GenerateImageOutput, AppError> {
let provider = repository::get_provider_secret(&state.db, &input.provider_id).await?;
if !provider.enabled {
return Err(AppError::Provider(format!("provider {} is disabled", provider.name)));
}
if provider.kind != "openai-compatible" {
return Err(AppError::Provider(format!(
"provider kind {} is not supported yet",
provider.kind
)));
}
if !provider.base_url.trim_end_matches('/').ends_with("/v1") {
return Err(AppError::Provider(
"Base URL 看起来不是 API 地址。OpenAI-compatible 地址通常需要以 /v1 结尾,例如 https://api.openai.com/v1 或 https://你的中转站域名/v1".to_string(),
));
}
let api_key = provider
.api_key_encrypted
.clone()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| AppError::Provider("provider api_key is empty".to_string()))?;
let model = input
.model
.clone()
.or_else(|| provider.image_model.clone())
.ok_or_else(|| AppError::Provider("image model is empty".to_string()))?;
let task = repository::create_generation_task(
&state.db,
CreateGenerationTaskInput {
provider_id: provider.id.clone(),
task_type: if input.image_paths.is_empty() {
"text_to_image".to_string()
} else {
"image_edit".to_string()
},
prompt: input.prompt.clone(),
model: model.clone(),
size: input.size.clone(),
quality: input.quality.clone(),
},
)
.await?;
let ai_provider = OpenAiCompatibleProvider::new(provider.base_url, api_key);
let image_result = match if input.image_paths.is_empty() {
ai_provider
.generate_image(ImageGenerateRequest {
prompt: input.prompt,
model,
size: input.size,
quality: input.quality,
})
.await
} else {
ai_provider
.edit_image(ImageEditRequest {
prompt: input.prompt,
model,
size: input.size,
quality: input.quality,
image_paths: input.image_paths,
})
.await
} {
Ok(result) => result,
Err(error) => {
repository::mark_generation_task_failed(&state.db, &task.id, &error.to_string()).await?;
return Err(error);
}
};
let image_bytes = match &image_result.data {
ImageData::Base64(data_base64) => storage::decode_base64_image(data_base64)?,
ImageData::Url(url) => reqwest::get(url).await?.bytes().await?.to_vec(),
};
let stored_image = storage::save_generated_image_bytes(&app, &image_bytes, &image_result.mime_type)?;
let file_path = stored_image.file_path.to_string_lossy().to_string();
let asset = repository::create_image_asset(
&state.db,
&task.id,
&file_path,
&image_result.mime_type,
stored_image.file_size,
"generated",
)
.await?;
repository::mark_generation_task_completed(&state.db, &task.id).await?;
Ok(GenerateImageOutput {
task: GenerationTask {
status: "completed".to_string(),
..task
},
asset,
})
}

View File

@@ -0,0 +1,3 @@
pub mod dialog;
pub mod generation;
pub mod provider;

View File

@@ -0,0 +1,22 @@
use tauri::State;
use crate::{
db::{models::UpsertProviderInput, repository},
state::AppState,
AppError,
};
#[tauri::command]
pub async fn list_providers(state: State<'_, AppState>) -> Result<Vec<crate::db::models::ProviderConfig>, AppError> {
repository::list_providers(&state.db).await
}
#[tauri::command]
pub async fn upsert_provider(state: State<'_, AppState>, input: UpsertProviderInput) -> Result<(), AppError> {
repository::upsert_provider(&state.db, input).await
}
#[tauri::command]
pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
repository::delete_provider(&state.db, &id).await
}

29
src-tauri/src/db/mod.rs Normal file
View File

@@ -0,0 +1,29 @@
use std::fs;
use sqlx::{sqlite::SqliteConnectOptions, SqlitePool};
use tauri::{AppHandle, Manager};
use crate::AppError;
pub mod models;
pub mod repository;
pub async fn init(app: &AppHandle) -> Result<SqlitePool, AppError> {
let app_data_dir = app.path().app_data_dir()?;
fs::create_dir_all(&app_data_dir)?;
let database_path = app_data_dir.join("image_draw_ai.sqlite");
let options = SqliteConnectOptions::new()
.filename(database_path)
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await?;
for statement in include_str!("../../migrations/001_init.sql").split(';') {
let statement = statement.trim();
if !statement.is_empty() {
sqlx::query(statement).execute(&pool).await?;
}
}
Ok(pool)
}

View File

@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ProviderConfig {
pub id: String,
pub name: String,
pub kind: String,
pub base_url: String,
pub api_key: Option<String>,
pub text_model: Option<String>,
pub image_model: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, FromRow)]
pub struct ProviderSecret {
pub id: String,
pub name: String,
pub kind: String,
pub base_url: String,
pub api_key_encrypted: Option<String>,
pub image_model: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpsertProviderInput {
pub id: String,
pub name: String,
pub kind: String,
pub base_url: String,
pub api_key: Option<String>,
pub text_model: Option<String>,
pub image_model: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct GenerationTask {
pub id: String,
pub provider_id: String,
pub task_type: String,
pub prompt: String,
pub model: String,
pub size: Option<String>,
pub quality: Option<String>,
pub status: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CreateGenerationTaskInput {
pub provider_id: String,
pub task_type: String,
pub prompt: String,
pub model: String,
pub size: Option<String>,
pub quality: Option<String>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ImageAsset {
pub id: String,
pub task_id: Option<String>,
pub file_path: String,
pub mime_type: Option<String>,
pub file_size: Option<i64>,
pub source_type: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GenerateImageInput {
pub provider_id: String,
pub prompt: String,
pub model: Option<String>,
pub size: Option<String>,
pub quality: Option<String>,
pub image_paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct GenerateImageOutput {
pub task: GenerationTask,
pub asset: ImageAsset,
}

View File

@@ -0,0 +1,227 @@
use chrono::Utc;
use sqlx::SqlitePool;
use uuid::Uuid;
use super::models::{
CreateGenerationTaskInput, GenerationTask, ImageAsset, ProviderConfig, ProviderSecret,
UpsertProviderInput,
};
use crate::AppError;
pub async fn list_providers(pool: &SqlitePool) -> Result<Vec<ProviderConfig>, AppError> {
let providers = sqlx::query_as::<_, ProviderConfig>(
r#"
SELECT
id,
name,
kind,
base_url,
api_key_encrypted AS api_key,
text_model,
image_model,
enabled != 0 AS enabled
FROM providers
ORDER BY updated_at DESC
"#,
)
.fetch_all(pool)
.await?;
Ok(providers)
}
pub async fn upsert_provider(pool: &SqlitePool, input: UpsertProviderInput) -> Result<(), AppError> {
let now = Utc::now().to_rfc3339();
let existing_api_key: Option<String> = sqlx::query_scalar(
r#"
SELECT api_key_encrypted
FROM providers
WHERE id = ?1
"#,
)
.bind(&input.id)
.fetch_optional(pool)
.await?
.flatten();
let api_key = input
.api_key
.filter(|key| !key.trim().is_empty())
.or(existing_api_key);
sqlx::query(
r#"
INSERT INTO providers (
id, name, kind, base_url, api_key_encrypted, text_model, image_model,
capabilities, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
kind = excluded.kind,
base_url = excluded.base_url,
api_key_encrypted = excluded.api_key_encrypted,
text_model = excluded.text_model,
image_model = excluded.image_model,
enabled = excluded.enabled,
updated_at = excluded.updated_at
"#,
)
.bind(input.id)
.bind(input.name)
.bind(input.kind)
.bind(input.base_url)
.bind(api_key)
.bind(input.text_model)
.bind(input.image_model)
.bind(r#"{"responses_api":true,"images_api":true,"chat_completions":true,"image_edit":true}"#)
.bind(input.enabled)
.bind(now)
.execute(pool)
.await?;
Ok(())
}
pub async fn get_provider_secret(pool: &SqlitePool, id: &str) -> Result<ProviderSecret, AppError> {
let provider = sqlx::query_as::<_, ProviderSecret>(
r#"
SELECT id, name, kind, base_url, api_key_encrypted, image_model, enabled != 0 AS enabled
FROM providers
WHERE id = ?1
"#,
)
.bind(id)
.fetch_one(pool)
.await?;
Ok(provider)
}
pub async fn delete_provider(pool: &SqlitePool, id: &str) -> Result<(), AppError> {
sqlx::query("DELETE FROM providers WHERE id = ?1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
pub async fn create_generation_task(
pool: &SqlitePool,
input: CreateGenerationTaskInput,
) -> Result<GenerationTask, AppError> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let status = "pending".to_string();
sqlx::query(
r#"
INSERT INTO generation_tasks (
id, provider_id, task_type, prompt, model, size, quality, status, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
"#,
)
.bind(&id)
.bind(&input.provider_id)
.bind(&input.task_type)
.bind(&input.prompt)
.bind(&input.model)
.bind(&input.size)
.bind(&input.quality)
.bind(&status)
.bind(&now)
.execute(pool)
.await?;
Ok(GenerationTask {
id,
provider_id: input.provider_id,
task_type: input.task_type,
prompt: input.prompt,
model: input.model,
size: input.size,
quality: input.quality,
status,
created_at: now,
})
}
pub async fn mark_generation_task_completed(pool: &SqlitePool, id: &str) -> Result<(), AppError> {
let now = Utc::now().to_rfc3339();
sqlx::query(
r#"
UPDATE generation_tasks
SET status = 'completed', updated_at = ?2, finished_at = ?2
WHERE id = ?1
"#,
)
.bind(id)
.bind(now)
.execute(pool)
.await?;
Ok(())
}
pub async fn mark_generation_task_failed(
pool: &SqlitePool,
id: &str,
error_message: &str,
) -> Result<(), AppError> {
let now = Utc::now().to_rfc3339();
sqlx::query(
r#"
UPDATE generation_tasks
SET status = 'failed', error_message = ?2, updated_at = ?3, finished_at = ?3
WHERE id = ?1
"#,
)
.bind(id)
.bind(error_message)
.bind(now)
.execute(pool)
.await?;
Ok(())
}
pub async fn create_image_asset(
pool: &SqlitePool,
task_id: &str,
file_path: &str,
mime_type: &str,
file_size: i64,
source_type: &str,
) -> Result<ImageAsset, AppError> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
r#"
INSERT INTO image_assets (
id, task_id, file_path, mime_type, file_size, source_type, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
"#,
)
.bind(&id)
.bind(task_id)
.bind(file_path)
.bind(mime_type)
.bind(file_size)
.bind(source_type)
.bind(&now)
.execute(pool)
.await?;
Ok(ImageAsset {
id,
task_id: Some(task_id.to_string()),
file_path: file_path.to_string(),
mime_type: Some(mime_type.to_string()),
file_size: Some(file_size),
source_type: source_type.to_string(),
created_at: now,
})
}

62
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,62 @@
mod ai;
mod commands;
mod db;
mod storage;
mod state;
use serde::Serialize;
use state::AppState;
use tauri::Manager;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("tauri path error: {0}")]
Path(#[from] tauri::Error),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("mime error: {0}")]
Mime(#[from] reqwest::header::InvalidHeaderValue),
#[error("base64 decode error: {0}")]
Base64(#[from] base64::DecodeError),
#[error("provider error: {0}")]
Provider(String),
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let app_handle = app.handle().clone();
tauri::async_runtime::block_on(async move {
let db = db::init(&app_handle).await?;
app_handle.manage(AppState { db });
Ok::<(), AppError>(())
})?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::provider::list_providers,
commands::provider::upsert_provider,
commands::provider::delete_provider,
commands::dialog::pick_material_images,
commands::generation::create_generation_task,
commands::generation::generate_image,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

3
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
image_draw_ai_lib::run();
}

6
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,6 @@
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
}

View File

@@ -0,0 +1,39 @@
use std::{fs, path::PathBuf};
use base64::{engine::general_purpose, Engine as _};
use tauri::{AppHandle, Manager};
use uuid::Uuid;
use crate::AppError;
pub struct StoredImage {
pub file_path: PathBuf,
pub file_size: i64,
}
pub fn save_generated_image_bytes(
app: &AppHandle,
bytes: &[u8],
mime_type: &str,
) -> Result<StoredImage, AppError> {
let images_dir = app.path().app_data_dir()?.join("images").join("generated");
fs::create_dir_all(&images_dir)?;
let extension = match mime_type {
"image/jpeg" => "jpg",
"image/webp" => "webp",
_ => "png",
};
let file_path = images_dir.join(format!("{}.{}", Uuid::new_v4(), extension));
let file_size = i64::try_from(bytes.len()).unwrap_or(i64::MAX);
fs::write(&file_path, bytes)?;
Ok(StoredImage {
file_path,
file_size,
})
}
pub fn decode_base64_image(data_base64: &str) -> Result<Vec<u8>, AppError> {
Ok(general_purpose::STANDARD.decode(data_base64)?)
}

35
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Image Draw AI",
"version": "0.1.0",
"identifier": "com.imagedraw.ai",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm web:build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Image Draw AI",
"width": 1100,
"height": 760,
"minWidth": 900,
"minHeight": 620
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/**"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": []
}
}

423
src/main.tsx Normal file
View File

@@ -0,0 +1,423 @@
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { convertFileSrc, invoke } from '@tauri-apps/api/core';
import './styles.css';
type ProviderConfig = {
id: string;
name: string;
kind: string;
base_url: string;
api_key?: string | null;
text_model?: string | null;
image_model?: string | null;
enabled: boolean;
};
type ProviderForm = {
id: string;
name: string;
kind: string;
base_url: string;
api_key: string;
text_model: string;
image_model: string;
enabled: boolean;
};
type GenerateImageOutput = {
task: {
id: string;
status: string;
};
asset: {
id: string;
file_path: string;
};
};
type SessionImage = {
id: string;
file_path: string;
prompt: string;
created_at: string;
};
type GenerationStep = {
label: string;
status: 'pending' | 'active' | 'done' | 'error';
};
function formatError(error: unknown) {
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
return JSON.stringify(error);
}
const defaultProviderForm: ProviderForm = {
id: 'default-openai',
name: 'OpenAI / 中转站',
kind: 'openai-compatible',
base_url: 'https://api.openai.com/v1',
api_key: '',
text_model: 'gpt-5',
image_model: 'gpt-image-2',
enabled: true,
};
const initialGenerationSteps: GenerationStep[] = [
{ label: '保存 Provider 配置', status: 'pending' },
{ label: '提交生成任务', status: 'pending' },
{ label: '请求并等待图像模型返回', status: 'pending' },
{ label: '保存图片到应用数据文件夹', status: 'pending' },
{ label: '更新本次打开图片列表', status: 'pending' },
];
function App() {
const [providers, setProviders] = useState<ProviderConfig[]>([]);
const [providerForm, setProviderForm] = useState<ProviderForm>(defaultProviderForm);
const [prompt, setPrompt] = useState('一只赛博朋克风格的橘猫坐在霓虹灯下');
const [status, setStatus] = useState('准备就绪');
const [isBusy, setIsBusy] = useState(false);
const [sessionImages, setSessionImages] = useState<SessionImage[]>([]);
const [materialPaths, setMaterialPaths] = useState<string[]>([]);
const [generationSteps, setGenerationSteps] = useState<GenerationStep[]>(initialGenerationSteps);
function setStep(index: number, status: GenerationStep['status']) {
setGenerationSteps((steps) =>
steps.map((step, stepIndex) => (stepIndex === index ? { ...step, status } : step)),
);
}
function startStep(index: number) {
setGenerationSteps((steps) =>
steps.map((step, stepIndex) => {
if (stepIndex < index) return { ...step, status: 'done' };
if (stepIndex === index) return { ...step, status: 'active' };
return { ...step, status: 'pending' };
}),
);
}
function updateProviderForm<K extends keyof ProviderForm>(key: K, value: ProviderForm[K]) {
setProviderForm((current) => ({ ...current, [key]: value }));
}
async function refreshProviders() {
const result = await invoke<ProviderConfig[]>('list_providers');
setProviders(result);
const current = result.find((provider) => provider.id === providerForm.id) ?? result[0];
if (current) {
setProviderForm((form) => ({
...form,
id: current.id,
name: current.name,
kind: current.kind,
base_url: current.base_url,
api_key: current.api_key ?? form.api_key,
text_model: current.text_model ?? defaultProviderForm.text_model,
image_model: current.image_model ?? defaultProviderForm.image_model,
enabled: current.enabled,
}));
}
}
async function saveProvider() {
setIsBusy(true);
setStatus('正在保存 Provider 配置...');
try {
await invoke('upsert_provider', {
input: providerForm,
});
await refreshProviders();
setStatus('Provider 已保存,可以直接生成图片');
} catch (error) {
setStatus(`保存失败:${formatError(error)}`);
} finally {
setIsBusy(false);
}
}
async function deleteProvider(id: string) {
setIsBusy(true);
setStatus('正在删除 Provider...');
try {
await invoke('delete_provider', { id });
await refreshProviders();
if (providerForm.id === id) {
setProviderForm(defaultProviderForm);
}
setStatus('Provider 已删除');
} catch (error) {
setStatus(`删除失败:${formatError(error)}`);
} finally {
setIsBusy(false);
}
}
function loadProvider(provider: ProviderConfig) {
setProviderForm((form) => ({
...form,
id: provider.id,
name: provider.name,
kind: provider.kind,
base_url: provider.base_url,
api_key: provider.api_key ?? form.api_key,
text_model: provider.text_model ?? defaultProviderForm.text_model,
image_model: provider.image_model ?? defaultProviderForm.image_model,
enabled: provider.enabled,
}));
setStatus('已载入 Provider修改后点击保存即可覆盖当前配置。');
}
async function generateImage() {
setIsBusy(true);
setGenerationSteps(initialGenerationSteps);
setStatus('正在生成图片...');
try {
startStep(0);
await invoke('upsert_provider', {
input: providerForm,
});
startStep(1);
await new Promise((resolve) => window.setTimeout(resolve, 120));
startStep(2);
const result = await invoke<GenerateImageOutput>('generate_image', {
input: {
provider_id: providerForm.id,
prompt,
model: providerForm.image_model,
size: '1024x1024',
quality: 'auto',
image_paths: materialPaths,
},
});
startStep(3);
await new Promise((resolve) => window.setTimeout(resolve, 120));
startStep(4);
await refreshProviders();
setSessionImages((images) => [
{
id: result.asset.id,
file_path: result.asset.file_path,
prompt,
created_at: new Date().toLocaleString(),
},
...images,
]);
setStep(4, 'done');
setStatus(`生成完成,已保存到应用数据文件夹:${result.asset.file_path}`);
} catch (error) {
setGenerationSteps((steps) =>
steps.map((step) => (step.status === 'active' ? { ...step, status: 'error' } : step)),
);
setStatus(`生成失败:${formatError(error)}`);
} finally {
setIsBusy(false);
}
}
async function pickMaterialImages() {
setStatus('正在打开素材图片选择器...');
try {
const paths = await invoke<string[]>('pick_material_images');
if (paths.length === 0) {
setStatus('未选择素材图片');
return;
}
setMaterialPaths((current) => Array.from(new Set([...current, ...paths])));
setStatus(`已导入 ${paths.length} 张素材图片`);
} catch (error) {
setStatus(`打开素材选择器失败:${formatError(error)}`);
}
}
function removeMaterialImage(path: string) {
setMaterialPaths((current) => current.filter((item) => item !== path));
}
useEffect(() => {
refreshProviders().catch(() => setStatus('后端未启动或数据库初始化失败'));
}, []);
return (
<main className="app">
<section className="hero">
<h1>Image Draw AI</h1>
<p></p>
</section>
<section className="panel">
<div className="panel-title">
<div>
<p className="eyebrow">Provider</p>
<h2></h2>
</div>
<button onClick={refreshProviders} disabled={isBusy}></button>
</div>
<div className="grid two">
<label>
ID
<input
value={providerForm.id}
onChange={(event) => updateProviderForm('id', event.target.value)}
placeholder="default-openai"
/>
</label>
<label>
<input
value={providerForm.name}
onChange={(event) => updateProviderForm('name', event.target.value)}
placeholder="OpenAI / 中转站"
/>
</label>
</div>
<label>
Base URL
<input
value={providerForm.base_url}
onChange={(event) => updateProviderForm('base_url', event.target.value)}
placeholder="https://api.openai.com/v1"
/>
<small> API /v1 </small>
</label>
<label>
API Key
<input
value={providerForm.api_key}
onChange={(event) => updateProviderForm('api_key', event.target.value)}
placeholder="sk-... 或中转站 key"
type="password"
/>
</label>
<div className="grid two">
<label>
<input
value={providerForm.text_model}
onChange={(event) => updateProviderForm('text_model', event.target.value)}
placeholder="gpt-5"
/>
</label>
<label>
<input
value={providerForm.image_model}
onChange={(event) => updateProviderForm('image_model', event.target.value)}
placeholder="gpt-image-2"
/>
</label>
</div>
<div className="row">
<button onClick={saveProvider} disabled={isBusy}> Provider</button>
</div>
</section>
<section className="panel">
<div className="panel-title">
<div>
<p className="eyebrow">Generate</p>
<h2></h2>
</div>
</div>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} />
<div className="material-toolbar">
<button onClick={pickMaterialImages} disabled={isBusy}></button>
{materialPaths.length > 0 && (
<button onClick={() => setMaterialPaths([])} disabled={isBusy}></button>
)}
<span>{materialPaths.length > 0 ? `已选择 ${materialPaths.length} 张素材,将使用图像编辑模式` : '未选择素材,将使用文生图模式'}</span>
</div>
{materialPaths.length > 0 && (
<div className="material-grid">
{materialPaths.map((path) => (
<article className="material-card" key={path}>
<img src={convertFileSrc(path)} alt="素材图片" />
<button onClick={() => removeMaterialImage(path)} disabled={isBusy}></button>
<span>{path}</span>
</article>
))}
</div>
)}
<button className="primary" onClick={generateImage} disabled={isBusy}>
{isBusy ? '正在生成...' : '生成图片'}
</button>
<div className={`progress-card ${isBusy ? 'is-loading' : ''}`}>
<div className="spinner" aria-hidden="true" />
<div className="progress-content">
<strong>{isBusy ? '生成流程进行中' : '生成流程'}</strong>
<ol className="step-list">
{generationSteps.map((step) => (
<li className={`step ${step.status}`} key={step.label}>
<span />
{step.label}
</li>
))}
</ol>
</div>
</div>
<p className="status">{status}</p>
</section>
<section className="panel">
<div className="panel-title">
<div>
<p className="eyebrow">Session</p>
<h2></h2>
</div>
<span className="count">{sessionImages.length} </span>
</div>
{sessionImages.length === 0 ? (
<p></p>
) : (
<div className="image-grid">
{sessionImages.map((image) => (
<article className="image-card" key={image.id}>
<img src={convertFileSrc(image.file_path)} alt={image.prompt} />
<div>
<strong>{image.created_at}</strong>
<p>{image.prompt}</p>
<span>{image.file_path}</span>
</div>
</article>
))}
</div>
)}
</section>
<section className="panel">
<h2> Provider</h2>
{providers.length === 0 ? (
<p> Provider</p>
) : (
<ul className="provider-list">
{providers.map((provider) => (
<li key={provider.id}>
<div className="provider-item">
<button className="link-button" onClick={() => loadProvider(provider)} disabled={isBusy}>
<strong>{provider.name}</strong>
<span>{provider.base_url}</span>
<span>{provider.image_model}</span>
</button>
<button className="danger" onClick={() => deleteProvider(provider.id)} disabled={isBusy}>
</button>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />);

357
src/styles.css Normal file
View File

@@ -0,0 +1,357 @@
:root {
color: #f8fafc;
background: radial-gradient(circle at top left, #1e3a8a 0, #0f172a 34%, #020617 100%);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
}
button, textarea, input {
font: inherit;
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.app {
max-width: 1040px;
margin: 0 auto;
padding: 48px 24px;
}
.hero {
margin-bottom: 24px;
}
.eyebrow {
color: #38bdf8;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
margin: 0 0 8px;
text-transform: uppercase;
}
h1 {
font-size: 48px;
margin: 0 0 12px;
}
h2 {
margin: 0 0 16px;
}
.panel {
background: rgba(15, 23, 42, 0.86);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 20px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
}
.panel-title {
align-items: flex-start;
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.row {
display: flex;
gap: 12px;
margin-top: 16px;
}
.grid {
display: grid;
gap: 16px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
color: #cbd5e1;
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
}
small {
color: #94a3b8;
font-weight: 500;
}
button {
border: 0;
border-radius: 12px;
padding: 10px 16px;
color: #e2e8f0;
background: #334155;
cursor: pointer;
}
button.primary {
background: linear-gradient(135deg, #2563eb, #7c3aed);
font-weight: 800;
}
input, textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #475569;
border-radius: 12px;
padding: 12px;
color: #f8fafc;
background: #020617;
outline: none;
}
input:focus, textarea:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.16);
}
textarea {
min-height: 140px;
margin-bottom: 16px;
resize: vertical;
}
.status {
color: #93c5fd;
overflow-wrap: anywhere;
}
.provider-list {
display: grid;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
}
.provider-item {
align-items: stretch;
display: grid;
gap: 10px;
grid-template-columns: 1fr auto;
}
.link-button {
align-items: flex-start;
background: rgba(30, 41, 59, 0.72);
border: 1px solid rgba(148, 163, 184, 0.2);
display: grid;
gap: 6px;
text-align: left;
width: 100%;
}
.danger {
background: rgba(185, 28, 28, 0.9);
color: #fee2e2;
}
.link-button span {
color: #94a3b8;
overflow-wrap: anywhere;
}
@media (max-width: 720px) {
.grid.two {
grid-template-columns: 1fr;
}
h1 {
font-size: 36px;
}
.provider-item {
grid-template-columns: 1fr;
}
}
.count {
color: #93c5fd;
font-weight: 800;
}
.image-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.image-card {
background: rgba(2, 6, 23, 0.72);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px;
overflow: hidden;
}
.image-card img {
aspect-ratio: 1;
background: #020617;
display: block;
object-fit: cover;
width: 100%;
}
.image-card div {
display: grid;
gap: 8px;
padding: 12px;
}
.image-card p {
color: #cbd5e1;
margin: 0;
}
.image-card span {
color: #94a3b8;
font-size: 12px;
overflow-wrap: anywhere;
}
.progress-card {
align-items: flex-start;
background: rgba(2, 6, 23, 0.5);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 16px;
display: flex;
gap: 14px;
margin-top: 16px;
padding: 16px;
}
.spinner {
border: 3px solid rgba(148, 163, 184, 0.28);
border-top-color: #38bdf8;
border-radius: 999px;
flex: 0 0 auto;
height: 22px;
width: 22px;
}
.progress-card.is-loading .spinner {
animation: spin 0.8s linear infinite;
}
.progress-content {
display: grid;
gap: 10px;
}
.step-list {
display: grid;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.step {
align-items: center;
color: #94a3b8;
display: flex;
gap: 8px;
}
.step span {
background: #475569;
border-radius: 999px;
height: 9px;
width: 9px;
}
.step.active {
color: #93c5fd;
font-weight: 800;
}
.step.active span {
background: #38bdf8;
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.16);
}
.step.done {
color: #86efac;
}
.step.done span {
background: #22c55e;
}
.step.error {
color: #fca5a5;
font-weight: 800;
}
.step.error span {
background: #ef4444;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.material-toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.material-toolbar span {
color: #94a3b8;
}
.material-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
margin-bottom: 16px;
}
.material-card {
background: rgba(2, 6, 23, 0.72);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 14px;
display: grid;
gap: 8px;
overflow: hidden;
padding-bottom: 10px;
}
.material-card img {
aspect-ratio: 1;
background: #020617;
object-fit: cover;
width: 100%;
}
.material-card button {
justify-self: center;
padding: 6px 10px;
}
.material-card span {
color: #94a3b8;
font-size: 11px;
overflow-wrap: anywhere;
padding: 0 10px;
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
envPrefix: ['VITE_', 'TAURI_'],
});