feat: scaffold AI image desktop MVP
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
92
README.md
Normal 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
12
index.html
Normal 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
32
package.json
Normal 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
1259
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
src-tauri/Cargo.toml
Normal file
32
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default desktop permissions","local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open","opener:default"]}}
|
||||
2543
src-tauri/gen/schemas/desktop-schema.json
Normal file
2543
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2543
src-tauri/gen/schemas/macOS-schema.json
Normal file
2543
src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 800 B |
68
src-tauri/migrations/001_init.sql
Normal file
68
src-tauri/migrations/001_init.sql
Normal 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
2
src-tauri/src/ai/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod openai_compatible;
|
||||
pub mod provider;
|
||||
158
src-tauri/src/ai/openai_compatible.rs
Normal file
158
src-tauri/src/ai/openai_compatible.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
39
src-tauri/src/ai/provider.rs
Normal file
39
src-tauri/src/ai/provider.rs
Normal 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>;
|
||||
}
|
||||
18
src-tauri/src/commands/dialog.rs
Normal file
18
src-tauri/src/commands/dialog.rs
Normal 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)
|
||||
}
|
||||
129
src-tauri/src/commands/generation.rs
Normal file
129
src-tauri/src/commands/generation.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
3
src-tauri/src/commands/mod.rs
Normal file
3
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod dialog;
|
||||
pub mod generation;
|
||||
pub mod provider;
|
||||
22
src-tauri/src/commands/provider.rs
Normal file
22
src-tauri/src/commands/provider.rs
Normal 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
29
src-tauri/src/db/mod.rs
Normal 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)
|
||||
}
|
||||
87
src-tauri/src/db/models.rs
Normal file
87
src-tauri/src/db/models.rs
Normal 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,
|
||||
}
|
||||
227
src-tauri/src/db/repository.rs
Normal file
227
src-tauri/src/db/repository.rs
Normal 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
62
src-tauri/src/lib.rs
Normal 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
3
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
image_draw_ai_lib::run();
|
||||
}
|
||||
6
src-tauri/src/state.rs
Normal file
6
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
}
|
||||
39
src-tauri/src/storage/mod.rs
Normal file
39
src-tauri/src/storage/mod.rs
Normal 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
35
src-tauri/tauri.conf.json
Normal 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
423
src/main.tsx
Normal 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
357
src/styles.css
Normal 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
21
tsconfig.json
Normal 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
12
vite.config.ts
Normal 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_'],
|
||||
});
|
||||
Reference in New Issue
Block a user