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

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