Compare commits

..

8 Commits

Author SHA1 Message Date
anran e5a779e48a chore: move package
CI / android (push) Failing after 2m14s
2026-01-31 02:51:56 +08:00
anran 4e868d9f7f chore(ui): remove subtitle from ServerScreen header 2026-01-31 02:39:22 +08:00
anran e4ca414721 fix(ui): improve workflow card layout and add refresh
- Change card aspect ratio from fixed 0.85 to dynamic with 4:3 cover
- Remove weight-based layout for more flexible sizing
- Add refresh() method to reload workflows on screen entry
- Use LaunchedEffect to trigger refresh when screen appears
2026-01-31 02:39:12 +08:00
anran 48405ca2ee refactor(ui): move set-cover button to image preview screen
- Remove onSetCoverClick from ImageGallery component
- Add onSetCover callback to ImagePreviewScreen
- Display set-cover and save buttons in preview bottom row
2026-01-31 02:38:55 +08:00
anran 305fc48093 feat(ui): add EmptyLatentImage node editor
- Add EMPTY_LATENT_IMAGE to EditableNodeType enum
- Detect empty latent image nodes by class type
- Add EmptyLatentImageEditor with width/height/batch_size inputs
2026-01-31 02:38:39 +08:00
anran 90e5d029ce style(theme): update color scheme to Tailwind Blue/Slate palette
- Replace cyan/indigo primary colors with Blue 600/400
- Use Slate gray scale for backgrounds and surfaces
- Update extended colors (card, shimmer, node status)
- Maintain consistent color naming with Tailwind CSS
2026-01-31 02:38:28 +08:00
anran 4ec0bf8a85 feat(ui): display WebSocket connection status in WorkflowRunScreen
- Observe WsConnectionState and update status bar accordingly
- Show reconnection attempts count during reconnecting
- Display error message when max retries exceeded
- Add disconnect() method and proper cleanup in onDispose()
- Initialize randomSeedNodes for KSampler nodes in init block
2026-01-31 02:38:16 +08:00
anran bf9bc51084 feat(network): add WebSocket auto-reconnect with exponential backoff
- Add ReconnectPolicy with configurable max attempts, delays and jitter
- Add WsConnectionStatus enum and WsConnectionState for tracking
- Implement exponential backoff retry logic with max 5 attempts
- Add connectionState StateFlow for UI observation
- Add disconnectAsync() for non-blocking disconnect
2026-01-31 02:38:00 +08:00
50 changed files with 634 additions and 361 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -66,11 +65,11 @@ kotlin {
}
android {
namespace = "moe.uni.comfy_kmp"
namespace = "moe.uni.comfyKmp"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.uni.comfy_kmp"
applicationId = "moe.uni.comfyKmp"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import android.content.Context
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import android.content.Context
import android.net.Uri
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import android.os.Build
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import android.app.Activity
import androidx.compose.runtime.Composable
@@ -1,10 +1,10 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
import kotlin.math.max
import moe.uni.comfy_kmp.AndroidAppContext
import moe.uni.comfyKmp.AndroidAppContext
actual fun saveCoverImage(bytes: ByteArray, filenameHint: String): String {
val safeName = filenameHint.replace(Regex("[^A-Za-z0-9._-]"), "_")
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.network
package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">comfy_kmp</string>
<string name="app_name">comfyKmp</string>
</resources>
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
@@ -6,10 +6,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import cafe.adriel.voyager.navigator.Navigator
import moe.uni.comfy_kmp.di.AppContainer
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.ui.screens.ServerScreen
import moe.uni.comfy_kmp.ui.theme.ComfyTheme
import moe.uni.comfyKmp.di.AppContainer
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.ui.screens.ServerScreen
import moe.uni.comfyKmp.ui.theme.ComfyTheme
@Composable
@Preview
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
class Greeting {
private val platform = getPlatform()
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
interface Platform {
val name: String
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import okio.FileSystem
import okio.Path.Companion.toPath
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import okio.FileSystem
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@@ -1,3 +1,3 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
expect suspend fun saveImageToGallery(bytes: ByteArray, filenameHint: String): String
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.di
package moe.uni.comfyKmp.di
import androidx.compose.runtime.compositionLocalOf
@@ -1,11 +1,11 @@
package moe.uni.comfy_kmp.di
package moe.uni.comfyKmp.di
import moe.uni.comfy_kmp.network.ComfyApiClient
import moe.uni.comfy_kmp.network.ComfyWebSocketClient
import moe.uni.comfy_kmp.network.createHttpClient
import moe.uni.comfy_kmp.storage.ServerRepository
import moe.uni.comfy_kmp.storage.SettingsStorage
import moe.uni.comfy_kmp.storage.WorkflowRepository
import moe.uni.comfyKmp.network.ComfyApiClient
import moe.uni.comfyKmp.network.ComfyWebSocketClient
import moe.uni.comfyKmp.network.createHttpClient
import moe.uni.comfyKmp.storage.ServerRepository
import moe.uni.comfyKmp.storage.SettingsStorage
import moe.uni.comfyKmp.storage.WorkflowRepository
class AppContainer(
private val storage: SettingsStorage = SettingsStorage()
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.network
package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@@ -10,8 +10,8 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import moe.uni.comfy_kmp.data.PromptRequest
import moe.uni.comfy_kmp.data.PromptResponse
import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.PromptResponse
class ComfyApiClient(
private val httpClient: HttpClient
@@ -0,0 +1,197 @@
package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.utils.io.errors.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfyKmp.data.ComfyJson
import moe.uni.comfyKmp.data.WsMessage
import kotlin.math.min
import kotlin.random.Random
data class ReconnectPolicy(
val maxAttempts: Int,
val initialDelayMs: Long,
val maxDelayMs: Long,
val jitterMs: Long
)
enum class WsConnectionStatus {
IDLE,
CONNECTING,
CONNECTED,
RECONNECTING,
FAILED,
DISCONNECTED
}
data class WsConnectionState(
val status: WsConnectionStatus,
val attempt: Int = 0,
val maxAttempts: Int = 0
)
class ComfyWebSocketClient(
private val httpClient: HttpClient
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _events = MutableSharedFlow<WsMessage>(extraBufferCapacity = 64)
val events: SharedFlow<WsMessage> = _events
private val _connectionState = MutableStateFlow(WsConnectionState(WsConnectionStatus.IDLE))
val connectionState: StateFlow<WsConnectionState> = _connectionState.asStateFlow()
private var session: WebSocketSession? = null
private var connectJob: Job? = null
private var shouldReconnect = false
fun connect(
baseUrl: String,
clientId: String,
policy: ReconnectPolicy = DEFAULT_RECONNECT_POLICY
) {
if (connectJob?.isActive == true) return
shouldReconnect = true
val wsUrl = buildWsUrl(baseUrl, clientId)
connectJob = scope.launch {
val maxAttempts = policy.maxAttempts
var retryCount = 0
try {
while (isActive && shouldReconnect) {
val status = if (retryCount == 0) {
WsConnectionStatus.CONNECTING
} else {
WsConnectionStatus.RECONNECTING
}
_connectionState.value = WsConnectionState(
status = status,
attempt = retryCount,
maxAttempts = maxAttempts
)
try {
httpClient.webSocket(wsUrl) {
session = this
_connectionState.value = WsConnectionState(WsConnectionStatus.CONNECTED)
retryCount = 0
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val element = ComfyJson.parseToJsonElement(frame.readText())
val obj = element.jsonObject
val type = obj["type"]?.jsonPrimitive?.content ?: "unknown"
val data = obj["data"] as? JsonObject
_events.tryEmit(WsMessage(type = type, data = data))
}
}
} catch (_: IOException) {
// 后台/断网时底层 socket 关闭会抛异常,这里忽略即可
} finally {
session = null
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
session = null
}
if (!shouldReconnect || !isActive) break
retryCount += 1
if (retryCount > maxAttempts) {
_connectionState.value = WsConnectionState(
status = WsConnectionStatus.FAILED,
attempt = maxAttempts,
maxAttempts = maxAttempts
)
shouldReconnect = false
break
}
_connectionState.value = WsConnectionState(
status = WsConnectionStatus.RECONNECTING,
attempt = retryCount,
maxAttempts = maxAttempts
)
delay(calculateBackoffDelayMs(policy, retryCount))
}
} finally {
if (!shouldReconnect && _connectionState.value.status != WsConnectionStatus.FAILED) {
_connectionState.value = WsConnectionState(WsConnectionStatus.DISCONNECTED)
}
connectJob = null
}
}
}
suspend fun disconnect() {
shouldReconnect = false
connectJob?.cancelAndJoin()
connectJob = null
try {
session?.close()
} catch (_: IOException) {
// Ignore close errors
} finally {
session = null
}
_connectionState.value = WsConnectionState(WsConnectionStatus.DISCONNECTED)
}
fun disconnectAsync() {
scope.launch {
disconnect()
}
}
fun close() {
shouldReconnect = false
connectJob?.cancel()
scope.cancel()
}
private fun buildWsUrl(baseUrl: String, clientId: String): String {
val trimmed = baseUrl.trimEnd('/')
val scheme = if (trimmed.startsWith("https://")) "wss://" else "ws://"
val host = trimmed.removePrefix("https://").removePrefix("http://")
return "${scheme}${host}/ws?clientId=$clientId"
}
private fun calculateBackoffDelayMs(policy: ReconnectPolicy, attempt: Int): Long {
val exponent = (attempt - 1).coerceAtLeast(0).coerceAtMost(30)
val baseDelay = policy.initialDelayMs * (1L shl exponent)
val cappedDelay = min(policy.maxDelayMs, baseDelay)
val jitter = if (policy.jitterMs > 0) {
Random.nextLong(0, policy.jitterMs + 1)
} else {
0L
}
return cappedDelay + jitter
}
}
private val DEFAULT_RECONNECT_POLICY = ReconnectPolicy(
maxAttempts = 5,
initialDelayMs = 1_000,
maxDelayMs = 30_000,
jitterMs = 250
)
@@ -1,11 +1,11 @@
package moe.uni.comfy_kmp.network
package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.serialization.kotlinx.json.json
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfyKmp.data.ComfyJson
internal fun HttpClientConfig<*>.installComfyPlugins() {
install(ContentNegotiation) {
@@ -1,7 +1,7 @@
package moe.uni.comfy_kmp.storage
package moe.uni.comfyKmp.storage
import moe.uni.comfy_kmp.data.ServerConfig
import moe.uni.comfy_kmp.data.WorkflowEntity
import moe.uni.comfyKmp.data.ServerConfig
import moe.uni.comfyKmp.data.WorkflowEntity
import kotlin.random.Random
import kotlin.time.Clock
@@ -1,13 +1,12 @@
package moe.uni.comfy_kmp.storage
package moe.uni.comfyKmp.storage
import com.russhwolf.settings.Settings
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfy_kmp.data.ServerConfig
import moe.uni.comfy_kmp.data.WorkflowEntity
import moe.uni.comfy_kmp.data.saveCoverImage
import moe.uni.comfyKmp.data.ComfyJson
import moe.uni.comfyKmp.data.ServerConfig
import moe.uni.comfyKmp.data.WorkflowEntity
import moe.uni.comfyKmp.data.saveCoverImage
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import okio.FileSystem
import okio.Path.Companion.toPath
import kotlin.io.encoding.Base64
@@ -1,8 +1,6 @@
package moe.uni.comfy_kmp.ui.components
package moe.uni.comfyKmp.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
@@ -14,7 +12,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -25,8 +22,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
@Composable
fun ComfyDialog(
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.components
package moe.uni.comfyKmp.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
@@ -23,8 +23,8 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
@Composable
fun GlassCard(
@@ -1,20 +1,19 @@
package moe.uni.comfy_kmp.ui.components
package moe.uni.comfyKmp.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
@@ -23,9 +22,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -38,8 +35,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
data class GalleryImage(
val filename: String,
@@ -52,26 +49,30 @@ fun ImageGallery(
images: List<GalleryImage>,
modifier: Modifier = Modifier,
onImageClick: ((Int) -> Unit)? = null,
onSaveClick: ((Int) -> Unit)? = null,
onSetCoverClick: ((Int) -> Unit)? = null
onSaveClick: ((Int) -> Unit)? = null
) {
Box(modifier = modifier.fillMaxSize()) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val availableWidth = maxWidth
val availableHeight = maxHeight
if (images.isEmpty()) {
EmptyGalleryState(modifier = Modifier.align(Alignment.Center))
} else {
LazyColumn(
contentPadding = PaddingValues(ComfySpacing.lg),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.md),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(images) { index, image ->
GalleryImageItem(
image = image,
availableWidth = availableWidth,
availableHeight = availableHeight,
onClick = {
onImageClick?.invoke(index)
},
onSave = onSaveClick?.let { { it(index) } },
onSetCover = onSetCoverClick?.let { { it(index) } }
onSave = onSaveClick?.let { { it(index) } }
)
}
}
@@ -82,38 +83,57 @@ fun ImageGallery(
@Composable
private fun GalleryImageItem(
image: GalleryImage,
availableWidth: androidx.compose.ui.unit.Dp,
availableHeight: androidx.compose.ui.unit.Dp,
onClick: () -> Unit,
onSave: (() -> Unit)?,
onSetCover: (() -> Unit)?
onSave: (() -> Unit)?
) {
val shape = RoundedCornerShape(12.dp)
val aspectRatio = image.bitmap?.let { it.width.toFloat() / it.height.toFloat() } ?: 1f
val bitmap = image.bitmap
// Calculate max dimensions (leave padding space)
val padding = ComfySpacing.lg * 2
val maxWidth = availableWidth - padding
val maxHeight = availableHeight - padding
// Calculate fitted size maintaining aspect ratio
val (fittedWidth, fittedHeight) = if (bitmap != null) {
val imageAspect = bitmap.width.toFloat() / bitmap.height.toFloat()
val containerAspect = maxWidth / maxHeight
if (imageAspect > containerAspect) {
// Image is wider - fit to width
maxWidth to (maxWidth / imageAspect)
} else {
// Image is taller - fit to height
(maxHeight * imageAspect) to maxHeight
}
} else {
maxWidth to (maxWidth * 0.75f) // Default 4:3 for placeholder
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm)
verticalArrangement = Arrangement.spacedBy(ComfySpacing.sm),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = fittedWidth)
.heightIn(max = fittedHeight)
.shadow(4.dp, shape)
.clip(shape)
.background(MaterialTheme.comfyColors.cardBackground)
.clickable(onClick = onClick)
) {
when {
image.bitmap != null -> {
bitmap != null -> {
Image(
bitmap = image.bitmap,
bitmap = bitmap,
contentDescription = image.filename,
contentScale = ContentScale.FillWidth,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.then(
if (aspectRatio.isFinite() && aspectRatio > 0f) {
Modifier.aspectRatio(aspectRatio)
} else Modifier
)
.widthIn(max = fittedWidth)
.heightIn(max = fittedHeight)
)
// Filename badge at bottom
@@ -141,26 +161,7 @@ private fun GalleryImageItem(
}
}
// Action buttons
if (image.bitmap != null && onSetCover != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
FilledTonalButton(
onClick = onSetCover,
contentPadding = PaddingValues(horizontal = ComfySpacing.md, vertical = ComfySpacing.sm)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(ComfySpacing.xs))
Text("设为封面")
}
}
}
// Action buttons (reserved)
}
}
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.components
package moe.uni.comfyKmp.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
@@ -14,19 +14,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Casino
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
@@ -57,9 +54,9 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.longOrNull
import moe.uni.comfy_kmp.data.NodeStatus
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import kotlin.math.roundToInt
@Composable
@@ -235,6 +232,7 @@ enum class EditableNodeType {
VAE_LOADER,
LORA_LOADER,
CLIP_TEXT_ENCODE,
EMPTY_LATENT_IMAGE,
OTHER
}
@@ -254,6 +252,9 @@ fun detectNodeType(classType: String): EditableNodeType {
normalized.contains("cliptextencode") ||
normalized.contains("clip_text_encode") ||
normalized == "clip text encode" -> EditableNodeType.CLIP_TEXT_ENCODE
normalized.contains("emptylatentimage") ||
normalized.contains("empty_latent_image") ||
normalized == "empty latent image" -> EditableNodeType.EMPTY_LATENT_IMAGE
else -> EditableNodeType.OTHER
}
}
@@ -352,6 +353,7 @@ fun EditableNodeCard(
EditableNodeType.VAE_LOADER -> "VAE"
EditableNodeType.LORA_LOADER -> "LoRA"
EditableNodeType.CLIP_TEXT_ENCODE -> "提示词"
EditableNodeType.EMPTY_LATENT_IMAGE -> "空潜图"
else -> ""
},
style = MaterialTheme.typography.labelSmall,
@@ -425,6 +427,12 @@ fun EditableNodeCard(
onInputChange = onInputChange
)
}
EditableNodeType.EMPTY_LATENT_IMAGE -> {
EmptyLatentImageEditor(
inputs = inputs,
onInputChange = onInputChange
)
}
EditableNodeType.OTHER -> {
// Show read-only inputs for other node types
val previewInputs = inputs.entries
@@ -762,6 +770,83 @@ private fun formatTwoDecimals(value: Float): String {
return "$whole.${fraction.toString().padStart(2, '0')}"
}
@Composable
private fun EmptyLatentImageEditor(
inputs: Map<String, JsonElement>,
onInputChange: (String, JsonElement) -> Unit
) {
val widthValue = inputs["width"]?.let { (it as? JsonPrimitive)?.contentOrNull } ?: ""
val heightValue = inputs["height"]?.let { (it as? JsonPrimitive)?.contentOrNull } ?: ""
val batchValue = inputs["batch_size"]?.let { (it as? JsonPrimitive)?.contentOrNull } ?: ""
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.md)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(ComfySpacing.md)
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)
) {
Text(
text = "宽度",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = widthValue,
onValueChange = { newValue ->
newValue.toIntOrNull()?.let { onInputChange("width", JsonPrimitive(it)) }
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text("如 512") }
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)
) {
Text(
text = "高度",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = heightValue,
onValueChange = { newValue ->
newValue.toIntOrNull()?.let { onInputChange("height", JsonPrimitive(it)) }
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text("如 512") }
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(ComfySpacing.xs)) {
Text(
text = "批量",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = batchValue,
onValueChange = { newValue ->
newValue.toIntOrNull()?.let { onInputChange("batch_size", JsonPrimitive(it)) }
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
placeholder = { Text("如 1") }
)
}
}
}
@Composable
private fun PromptEditor(
inputs: Map<String, JsonElement>,
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.components
package moe.uni.comfyKmp.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
@@ -35,8 +34,8 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
enum class ExecutionStatus {
IDLE,
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.screens
package moe.uni.comfyKmp.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -7,14 +7,14 @@ import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -24,7 +24,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
@@ -33,14 +32,15 @@ import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfy_kmp.SystemBarsEffect
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.SystemBarsEffect
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import org.jetbrains.compose.resources.decodeToImageBitmap
data class ImagePreviewScreen(
val filename: String,
val bytes: ByteArray,
val onSave: (() -> Unit)? = null
val onSave: (() -> Unit)? = null,
val onSetCover: (() -> Unit)? = null
) : Screen {
@Composable
override fun Content() {
@@ -99,22 +99,44 @@ data class ImagePreviewScreen(
)
}
if (onSave != null) {
FilledTonalIconButton(
onClick = onSave,
if (onSave != null || onSetCover != null) {
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(ComfySpacing.lg)
.size(48.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
.padding(ComfySpacing.lg),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(ComfySpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = "保存"
)
if (onSetCover != null) {
FilledTonalIconButton(
onClick = onSetCover,
modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Icon(
imageVector = Icons.Default.PhotoLibrary,
contentDescription = "设为封面"
)
}
}
if (onSave != null) {
FilledTonalIconButton(
onClick = onSave,
modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = "保存"
)
}
}
}
}
}
@@ -1,6 +1,6 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package moe.uni.comfy_kmp.ui.screens
package moe.uni.comfyKmp.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -30,7 +30,6 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -40,7 +39,6 @@ import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -65,13 +63,13 @@ import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfy_kmp.data.ServerConfig
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.storage.generateId
import moe.uni.comfy_kmp.ui.components.ComfyDialog
import moe.uni.comfy_kmp.ui.components.GlassCard
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.data.ServerConfig
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.ComfyDialog
import moe.uni.comfyKmp.ui.components.GlassCard
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
class ServerScreen : Screen {
@Composable
@@ -106,11 +104,6 @@ class ServerScreen : Screen {
"Comfy KMP",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "ComfyUI 移动客户端",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
actions = {
@@ -228,7 +221,7 @@ class ServerScreen : Screen {
}
private class ServerScreenModel(
private val repository: moe.uni.comfy_kmp.storage.ServerRepository
private val repository: moe.uni.comfyKmp.storage.ServerRepository
) : ScreenModel {
var servers by mutableStateOf(repository.getServers())
private set
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.screens
package moe.uni.comfyKmp.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -31,9 +31,7 @@ import androidx.compose.material.icons.automirrored.filled.Assignment
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
@@ -45,10 +43,10 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -58,7 +56,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -67,15 +64,15 @@ import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import moe.uni.comfy_kmp.data.WorkflowEntity
import moe.uni.comfy_kmp.data.loadCoverImageBytes
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.isFilePickerSupported
import moe.uni.comfy_kmp.rememberFilePickerLauncher
import moe.uni.comfy_kmp.storage.generateId
import moe.uni.comfy_kmp.ui.components.ComfyDialog
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.data.WorkflowEntity
import moe.uni.comfyKmp.data.loadCoverImageBytes
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.isFilePickerSupported
import moe.uni.comfyKmp.rememberFilePickerLauncher
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.ComfyDialog
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.time.Clock
@@ -87,6 +84,10 @@ data class WorkflowListScreen(val serverId: String) : Screen {
val container = LocalAppContainer.current
val model = rememberScreenModel { WorkflowListScreenModel(serverId, container.workflowRepository) }
LaunchedEffect(Unit) {
model.refresh()
}
var showImport by remember { mutableStateOf(false) }
if (showImport) {
@@ -194,11 +195,15 @@ data class WorkflowListScreen(val serverId: String) : Screen {
private class WorkflowListScreenModel(
private val serverId: String,
private val repository: moe.uni.comfy_kmp.storage.WorkflowRepository
private val repository: moe.uni.comfyKmp.storage.WorkflowRepository
) : ScreenModel {
var workflows by mutableStateOf(repository.getWorkflows(serverId))
private set
fun refresh() {
workflows = repository.getWorkflows(serverId)
}
fun addWorkflow(name: String, json: String) {
val workflow = WorkflowEntity(
id = generateId("workflow"),
@@ -208,12 +213,12 @@ private class WorkflowListScreenModel(
updatedAt = Clock.System.now().epochSeconds
)
repository.upsertWorkflow(workflow)
workflows = repository.getWorkflows(serverId)
refresh()
}
fun deleteWorkflow(id: String) {
repository.deleteWorkflow(id)
workflows = repository.getWorkflows(serverId)
refresh()
}
}
@@ -325,18 +330,17 @@ private fun WorkflowCard(
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.85f)
.shadow(4.dp, shape)
.clip(shape)
.background(comfyColors.cardBackground)
.clickable(onClick = onRun)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Cover image area (3/4)
Column(modifier = Modifier.fillMaxWidth()) {
// Cover image area (4:3)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(4f)
.aspectRatio(4f / 3f)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
if (coverBitmap != null) {
@@ -372,11 +376,10 @@ private fun WorkflowCard(
}
}
// Info and actions area (1/4)
// Info and actions area
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(comfyColors.cardBackground)
.padding(ComfySpacing.md),
verticalArrangement = Arrangement.SpaceBetween,
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.screens
package moe.uni.comfyKmp.ui.screens
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
@@ -68,34 +68,35 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfy_kmp.data.ImageRef
import moe.uni.comfy_kmp.data.NodeExecutionState
import moe.uni.comfy_kmp.data.NodeStatus
import moe.uni.comfy_kmp.data.PromptRequest
import moe.uni.comfy_kmp.data.extractImagesFromHistory
import moe.uni.comfy_kmp.data.extractPromptObject
import moe.uni.comfy_kmp.data.parsePromptNodes
import moe.uni.comfy_kmp.data.saveCoverImage
import moe.uni.comfy_kmp.data.saveImageToGallery
import moe.uni.comfy_kmp.data.updateNodeInput
import moe.uni.comfy_kmp.di.LocalAppContainer
import moe.uni.comfy_kmp.network.ComfyApiClient
import moe.uni.comfy_kmp.network.ComfyWebSocketClient
import moe.uni.comfy_kmp.storage.ServerRepository
import moe.uni.comfy_kmp.storage.WorkflowRepository
import moe.uni.comfy_kmp.storage.generateId
import moe.uni.comfy_kmp.ui.components.EditableNodeCard
import moe.uni.comfy_kmp.ui.components.ExecutionStatus
import moe.uni.comfy_kmp.ui.components.ExecutionStatusBar
import moe.uni.comfy_kmp.ui.components.GalleryImage
import moe.uni.comfy_kmp.ui.components.ImageGallery
import moe.uni.comfy_kmp.ui.components.detectNodeType
import moe.uni.comfy_kmp.ui.components.getModelFolder
import moe.uni.comfy_kmp.ui.theme.ComfySpacing
import moe.uni.comfy_kmp.ui.theme.comfyColors
import moe.uni.comfyKmp.data.ComfyJson
import moe.uni.comfyKmp.data.ImageRef
import moe.uni.comfyKmp.data.NodeExecutionState
import moe.uni.comfyKmp.data.NodeStatus
import moe.uni.comfyKmp.data.PromptRequest
import moe.uni.comfyKmp.data.extractImagesFromHistory
import moe.uni.comfyKmp.data.extractPromptObject
import moe.uni.comfyKmp.data.parsePromptNodes
import moe.uni.comfyKmp.data.saveCoverImage
import moe.uni.comfyKmp.data.saveImageToGallery
import moe.uni.comfyKmp.data.updateNodeInput
import moe.uni.comfyKmp.di.LocalAppContainer
import moe.uni.comfyKmp.network.ComfyApiClient
import moe.uni.comfyKmp.network.ComfyWebSocketClient
import moe.uni.comfyKmp.network.WsConnectionStatus
import moe.uni.comfyKmp.storage.ServerRepository
import moe.uni.comfyKmp.storage.WorkflowRepository
import moe.uni.comfyKmp.storage.generateId
import moe.uni.comfyKmp.ui.components.EditableNodeType
import moe.uni.comfyKmp.ui.components.EditableNodeCard
import moe.uni.comfyKmp.ui.components.ExecutionStatus
import moe.uni.comfyKmp.ui.components.ExecutionStatusBar
import moe.uni.comfyKmp.ui.components.GalleryImage
import moe.uni.comfyKmp.ui.components.ImageGallery
import moe.uni.comfyKmp.ui.components.detectNodeType
import moe.uni.comfyKmp.ui.components.getModelFolder
import moe.uni.comfyKmp.ui.theme.ComfySpacing
import moe.uni.comfyKmp.ui.theme.comfyColors
import org.jetbrains.compose.resources.decodeToImageBitmap
import kotlin.random.Random
import kotlin.time.Clock
@@ -222,13 +223,13 @@ data class WorkflowRunScreen(val workflowId: String) : Screen {
ImagePreviewScreen(
preview.filename,
preview.bytes,
onSave = { model.saveImage(index) }
onSave = { model.saveImage(index) },
onSetCover = { model.setCoverImage(index) }
)
)
}
},
onSaveClick = { index -> model.saveImage(index) },
onSetCoverClick = { index -> model.setCoverImage(index) },
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
@@ -424,6 +425,8 @@ private class WorkflowRunScreenModel(
private var imageBytes by mutableStateOf<Map<String, ByteArray>>(emptyMap())
private var isLoadingImages by mutableStateOf(false)
private var saveJob: Job? = null
private var wsEventsJob: Job? = null
private var wsStateJob: Job? = null
val galleryImages: List<GalleryImage>
get() = imageRefs.map { ref ->
@@ -445,6 +448,10 @@ private class WorkflowRunScreenModel(
}
init {
randomSeedNodes = nodeStates
.filter { detectNodeType(it.classType) == EditableNodeType.KSAMPLER }
.map { it.nodeId }
.toSet()
preloadModelLists()
}
@@ -555,13 +562,63 @@ private class WorkflowRunScreenModel(
executionStatus = ExecutionStatus.CONNECTING
statusText = "正在连接..."
screenModelScope.launch {
wsClient.disconnect()
wsClient.connect(baseUrl, clientId)
wsEventsJob?.cancel()
wsStateJob?.cancel()
wsEventsJob = screenModelScope.launch {
wsClient.events.collectLatest { msg ->
handleWebSocketMessage(msg.type, msg.data)
}
}
wsStateJob = screenModelScope.launch {
wsClient.connectionState.collectLatest { state ->
when (state.status) {
WsConnectionStatus.CONNECTING -> {
executionStatus = ExecutionStatus.CONNECTING
statusText = "正在连接..."
}
WsConnectionStatus.CONNECTED -> {
if (!running) {
executionStatus = ExecutionStatus.IDLE
statusText = "已连接"
}
}
WsConnectionStatus.RECONNECTING -> {
executionStatus = ExecutionStatus.CONNECTING
statusText = "连接断开,正在重连(${state.attempt}/${state.maxAttempts})"
}
WsConnectionStatus.FAILED -> {
executionStatus = ExecutionStatus.ERROR
statusText = "重连失败(已重试${state.maxAttempts}次)"
}
WsConnectionStatus.DISCONNECTED -> {
if (!running) {
executionStatus = ExecutionStatus.IDLE
statusText = "已断开"
}
}
WsConnectionStatus.IDLE -> Unit
}
}
}
screenModelScope.launch {
wsClient.disconnect()
wsClient.connect(baseUrl, clientId)
}
}
fun disconnect() {
wsEventsJob?.cancel()
wsEventsJob = null
wsStateJob?.cancel()
wsStateJob = null
wsClient.disconnectAsync()
}
override fun onDispose() {
disconnect()
}
private fun handleWebSocketMessage(type: String, data: JsonObject?) {
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.ui.theme
package moe.uni.comfyKmp.ui.theme
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -11,61 +11,59 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
// Light Mode Colors
private val LightPrimary = Color(0xFF0EA5E9) // 电光蓝
// Light Mode Colors - Blue 蓝色系
private val LightPrimary = Color(0xFF2563EB) // Blue 600
private val LightOnPrimary = Color(0xFFFFFFFF)
private val LightPrimaryContainer = Color(0xFFE0F2FE)
private val LightOnPrimaryContainer = Color(0xFF0C4A6E)
private val LightSecondary = Color(0xFF6366F1) // 靛蓝
private val LightPrimaryContainer = Color(0xFFDBEAFE) // Blue 100
private val LightOnPrimaryContainer = Color(0xFF1E3A8A) // Blue 900
private val LightSecondary = Color(0xFF3B82F6) // Blue 500
private val LightOnSecondary = Color.White
private val LightSecondaryContainer = Color(0xFFE0E7FF)
private val LightOnSecondaryContainer = Color(0xFF3730A3)
private val LightTertiary = Color(0xFF10B981) // 翡翠绿
private val LightSecondaryContainer = Color(0xFFBFDBFE) // Blue 200
private val LightOnSecondaryContainer = Color(0xFF1E40AF) // Blue 800
private val LightTertiary = Color(0xFF8B5CF6) // Violet 500
private val LightOnTertiary = Color.White
private val LightTertiaryContainer = Color(0xFFD1FAE5)
private val LightOnTertiaryContainer = Color(0xFF065F46)
private val LightBackground = Color(0xFFF5F5F7)
private val LightOnBackground = Color(0xFF1A1A1A)
private val LightTertiaryContainer = Color(0xFFEDE9FE) // Violet 100
private val LightOnTertiaryContainer = Color(0xFF5B21B6) // Violet 800
private val LightBackground = Color(0xFFF8FAFC) // Slate 50
private val LightOnBackground = Color(0xFF1E293B) // Slate 800
private val LightSurface = Color(0xFFFFFFFF)
private val LightOnSurface = Color(0xFF1A1A1A)
private val LightSurfaceVariant = Color(0xFFE8E8ED)
private val LightOnSurfaceVariant = Color(0xFF4A4A52)
private val LightOutline = Color(0xFFD1D5DB)
private val LightOutlineVariant = Color(0xFFE5E7EB)
private val LightError = Color(0xFFEF4444)
private val LightOnSurface = Color(0xFF1E293B) // Slate 800
private val LightSurfaceVariant = Color(0xFFF1F5F9) // Slate 100
private val LightOnSurfaceVariant = Color(0xFF475569) // Slate 600
private val LightOutline = Color(0xFFCBD5E1) // Slate 300
private val LightOutlineVariant = Color(0xFFE2E8F0) // Slate 200
private val LightError = Color(0xFFDC2626) // Red 600
private val LightOnError = Color.White
// Dark Mode Colors
private val DarkPrimary = Color(0xFF38BDF8) // 电光蓝
private val DarkOnPrimary = Color(0xFF0C1929)
private val DarkPrimaryContainer = Color(0xFF0369A1)
private val DarkOnPrimaryContainer = Color(0xFFBAE6FD)
private val DarkSecondary = Color(0xFFA78BFA) // 紫罗兰
private val DarkOnSecondary = Color(0xFF1E1B4B)
private val DarkSecondaryContainer = Color(0xFF4C1D95)
private val DarkOnSecondaryContainer = Color(0xFFEDE9FE)
private val DarkTertiary = Color(0xFF34D399) // 翡翠绿
private val DarkOnTertiary = Color(0xFF052E16)
private val DarkTertiaryContainer = Color(0xFF065F46)
private val DarkOnTertiaryContainer = Color(0xFFA7F3D0)
private val DarkBackground = Color(0xFF0D0D12)
private val DarkOnBackground = Color(0xFFE8E8ED)
private val DarkSurface = Color(0xFF1A1A24)
private val DarkOnSurface = Color(0xFFE8E8ED)
private val DarkSurfaceVariant = Color(0xFF2A2A36)
private val DarkOnSurfaceVariant = Color(0xFFA1A1AA)
private val DarkOutline = Color(0xFF3F3F46)
private val DarkOutlineVariant = Color(0xFF27272A)
private val DarkError = Color(0xFFF87171)
private val DarkOnError = Color(0xFF1A0A0A)
// Dark Mode Colors - Blue 蓝色系
private val DarkPrimary = Color(0xFF60A5FA) // Blue 400
private val DarkOnPrimary = Color(0xFF1E3A8A) // Blue 900
private val DarkPrimaryContainer = Color(0xFF1E40AF) // Blue 800
private val DarkOnPrimaryContainer = Color(0xFFDBEAFE) // Blue 100
private val DarkSecondary = Color(0xFF93C5FD) // Blue 300
private val DarkOnSecondary = Color(0xFF1E3A8A) // Blue 900
private val DarkSecondaryContainer = Color(0xFF1D4ED8) // Blue 700
private val DarkOnSecondaryContainer = Color(0xFFBFDBFE) // Blue 200
private val DarkTertiary = Color(0xFFA78BFA) // Violet 400
private val DarkOnTertiary = Color(0xFF4C1D95) // Violet 900
private val DarkTertiaryContainer = Color(0xFF6D28D9) // Violet 700
private val DarkOnTertiaryContainer = Color(0xFFEDE9FE) // Violet 100
private val DarkBackground = Color(0xFF0F172A) // Slate 900
private val DarkOnBackground = Color(0xFFE2E8F0) // Slate 200
private val DarkSurface = Color(0xFF1E293B) // Slate 800
private val DarkOnSurface = Color(0xFFE2E8F0) // Slate 200
private val DarkSurfaceVariant = Color(0xFF334155) // Slate 700
private val DarkOnSurfaceVariant = Color(0xFF94A3B8) // Slate 400
private val DarkOutline = Color(0xFF475569) // Slate 600
private val DarkOutlineVariant = Color(0xFF334155) // Slate 700
private val DarkError = Color(0xFFF87171) // Red 400
private val DarkOnError = Color(0xFF7F1D1D) // Red 900
private val LightColors = lightColorScheme(
primary = LightPrimary,
@@ -240,14 +238,14 @@ private val LightExtendedColors = ComfyExtendedColors(
onWarning = Color(0xFF1A1A1A),
warningContainer = Color(0xFFFEF3C7),
cardBackground = Color.White,
cardBorder = Color(0xFFE5E7EB),
shimmerBase = Color(0xFFE5E7EB),
shimmerHighlight = Color(0xFFF3F4F6),
gradientStart = Color(0xFFF5F5F7),
gradientEnd = Color(0xFFE8E8ED),
nodeRunning = Color(0xFF0EA5E9),
cardBorder = Color(0xFFE2E8F0), // Slate 200
shimmerBase = Color(0xFFE2E8F0), // Slate 200
shimmerHighlight = Color(0xFFF1F5F9), // Slate 100
gradientStart = Color(0xFFF8FAFC), // Slate 50
gradientEnd = Color(0xFFF1F5F9), // Slate 100
nodeRunning = Color(0xFF2563EB), // Blue 600
nodeCompleted = Color(0xFF10B981),
nodePending = Color(0xFF9CA3AF)
nodePending = Color(0xFF94A3B8) // Slate 400
)
private val DarkExtendedColors = ComfyExtendedColors(
@@ -257,15 +255,15 @@ private val DarkExtendedColors = ComfyExtendedColors(
warning = Color(0xFFFBBF24),
onWarning = Color(0xFF1A1A1A),
warningContainer = Color(0xFF78350F),
cardBackground = Color(0xFF1F1F2A),
cardBorder = Color(0xFF3F3F46),
shimmerBase = Color(0xFF27272A),
shimmerHighlight = Color(0xFF3F3F46),
gradientStart = Color(0xFF0D0D12),
gradientEnd = Color(0xFF1A1A24),
nodeRunning = Color(0xFF38BDF8),
cardBackground = Color(0xFF1E293B), // Slate 800
cardBorder = Color(0xFF475569), // Slate 600
shimmerBase = Color(0xFF334155), // Slate 700
shimmerHighlight = Color(0xFF475569), // Slate 600
gradientStart = Color(0xFF0F172A), // Slate 900
gradientEnd = Color(0xFF1E293B), // Slate 800
nodeRunning = Color(0xFF60A5FA), // Blue 400
nodeCompleted = Color(0xFF34D399),
nodePending = Color(0xFF6B7280)
nodePending = Color(0xFF64748B) // Slate 500
)
val LocalComfyColors = staticCompositionLocalOf { LightExtendedColors }
@@ -1,76 +0,0 @@
package moe.uni.comfy_kmp.network
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.utils.io.errors.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.uni.comfy_kmp.data.ComfyJson
import moe.uni.comfy_kmp.data.WsMessage
class ComfyWebSocketClient(
private val httpClient: HttpClient
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _events = MutableSharedFlow<WsMessage>(extraBufferCapacity = 64)
val events: SharedFlow<WsMessage> = _events
private var session: WebSocketSession? = null
fun connect(baseUrl: String, clientId: String) {
if (session != null) return
val wsUrl = buildWsUrl(baseUrl, clientId)
scope.launch {
try {
httpClient.webSocket(wsUrl) {
session = this
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val element = ComfyJson.parseToJsonElement(frame.readText())
val obj = element.jsonObject
val type = obj["type"]?.jsonPrimitive?.content ?: "unknown"
val data = obj["data"] as? JsonObject
_events.tryEmit(WsMessage(type = type, data = data))
}
}
} catch (_: IOException) {
// 后台/断网时底层 socket 关闭会抛异常,这里忽略即可
} finally {
session = null
}
}
} catch (_: IOException) {
session = null
}
}
}
suspend fun disconnect() {
session?.close()
session = null
}
fun close() {
scope.cancel()
}
private fun buildWsUrl(baseUrl: String, clientId: String): String {
val trimmed = baseUrl.trimEnd('/')
val scheme = if (trimmed.startsWith("https://")) "wss://" else "ws://"
val host = trimmed.removePrefix("https://").removePrefix("http://")
return "${scheme}${host}/ws?clientId=$clientId"
}
}
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import androidx.compose.ui.window.ComposeUIViewController
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import platform.UIKit.UIDevice
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp
package moe.uni.comfyKmp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -1,6 +1,6 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
@@ -11,7 +11,6 @@ import platform.Foundation.NSCachesDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
import platform.Foundation.URLByAppendingPathComponent
import platform.Foundation.dataWithBytes
import platform.UIKit.UIImage
import platform.UIKit.UIImageJPEGRepresentation
@@ -1,6 +1,6 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package moe.uni.comfy_kmp.data
package moe.uni.comfyKmp.data
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
@@ -1,4 +1,4 @@
package moe.uni.comfy_kmp.network
package moe.uni.comfyKmp.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
+2 -2
View File
@@ -1,7 +1,7 @@
TEAM_ID=
PRODUCT_NAME=comfy_kmp
PRODUCT_BUNDLE_IDENTIFIER=moe.uni.comfy_kmp.comfy_kmp$(TEAM_ID)
PRODUCT_NAME=comfyKmp
PRODUCT_BUNDLE_IDENTIFIER=moe.uni.comfyKmp.comfyKmp$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
+3 -3
View File
@@ -7,7 +7,7 @@
objects = {
/* Begin PBXFileReference section */
338A2ACE269EA4A9809609C8 /* comfy_kmp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = comfy_kmp.app; sourceTree = BUILT_PRODUCTS_DIR; };
338A2ACE269EA4A9809609C8 /* comfyKmp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = comfyKmp.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -59,7 +59,7 @@
657B04DAB4CB0FD124755A63 /* Products */ = {
isa = PBXGroup;
children = (
338A2ACE269EA4A9809609C8 /* comfy_kmp.app */,
338A2ACE269EA4A9809609C8 /* comfyKmp.app */,
);
name = Products;
sourceTree = "<group>";
@@ -87,7 +87,7 @@
packageProductDependencies = (
);
productName = iosApp;
productReference = 338A2ACE269EA4A9809609C8 /* comfy_kmp.app */;
productReference = 338A2ACE269EA4A9809609C8 /* comfyKmp.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
+1 -1
View File
@@ -1,4 +1,4 @@
rootProject.name = "comfy_kmp"
rootProject.name = "comfyKmp"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {