diff --git a/PartnerCtl/pom.xml b/PartnerCtl/pom.xml index c45cb564..b1d905e4 100644 --- a/PartnerCtl/pom.xml +++ b/PartnerCtl/pom.xml @@ -10,7 +10,7 @@ partnerctl - 1.0.0 + 1.0.1 21 diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt index 91969dd0..60b70bae 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt @@ -2,13 +2,12 @@ package work.slhaf.partner.ctl.commands import kotlinx.serialization.json.* import picocli.CommandLine +import work.slhaf.partner.ctl.commands.InitCommand.InstallChoice.BUILD_FROM_SOURCE +import work.slhaf.partner.ctl.commands.InitCommand.InstallChoice.DOWNLOAD_JAR import work.slhaf.partner.ctl.commands.data.GatewayConfig import work.slhaf.partner.ctl.commands.data.OpenAiCompatible import work.slhaf.partner.ctl.commands.data.ProviderConfig -import work.slhaf.partner.ctl.commands.init.buildFromSource -import work.slhaf.partner.ctl.commands.init.configureExternalGateway -import work.slhaf.partner.ctl.commands.init.configureOpenAiCompatible -import work.slhaf.partner.ctl.commands.init.configureWebSocketGateway +import work.slhaf.partner.ctl.commands.init.* import work.slhaf.partner.ctl.i18n.I18n.text import work.slhaf.partner.ctl.support.CommandInterrupted import work.slhaf.partner.ctl.support.inheritCommand @@ -176,11 +175,15 @@ class InitCommand : Runnable { val installChoice = prompt.select( label = text("init.install.method.label"), - choices = listOf(Choice(text("init.install.method.buildFromSource"), InstallChoice.BUILD_FROM_SOURCE)) + choices = listOf( + Choice(text("init.install.method.buildFromSource"), BUILD_FROM_SOURCE), + Choice(text("init.install.method.downloadFromRelease"), DOWNLOAD_JAR) + ) ) when (installChoice) { - InstallChoice.BUILD_FROM_SOURCE -> buildFromSource(home, prompt) + BUILD_FROM_SOURCE -> buildFromSource(home, prompt) + DOWNLOAD_JAR -> downloadFromRelease(home, prompt) } } @@ -348,7 +351,8 @@ class InitCommand : Runnable { } private enum class InstallChoice { - BUILD_FROM_SOURCE + BUILD_FROM_SOURCE, + DOWNLOAD_JAR } private enum class ModelProviderChoice(val display: String) { diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt index cf799c91..a5654f69 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt @@ -1,11 +1,15 @@ package work.slhaf.partner.ctl.commands.init +import work.slhaf.partner.ctl.i18n.I18n.text import work.slhaf.partner.ctl.support.SourceBuildInstallSpec import work.slhaf.partner.ctl.support.buildAndInstallFromSource +import work.slhaf.partner.ctl.support.downloadTo +import work.slhaf.partner.ctl.support.registryIndex import work.slhaf.partner.ctl.ui.Prompt import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import kotlin.io.path.exists import kotlin.io.path.isDirectory import kotlin.io.path.name @@ -40,3 +44,41 @@ private fun findLargestJar(directory: Path): Path? { .orElse(null) } } + +fun downloadFromRelease(home: Path, prompt: Prompt) { + prompt.info(text("init.install.method.downloadFromRelease.startDownloading")) + val path = home.resolve("resources/partner-core.jar").toAbsolutePath().normalize() + downloadTo(registryIndex.partner.latestRelease.url, path) { downloaded, total -> + if (total != null && total > 0) { + val percent = downloaded * 100 / total + updateLine( + text( + "init.install.method.downloadFromRelease.progress.percent", + percent + ) + ) + } else { + updateLine( + text( + "init.install.method.downloadFromRelease.progress.size", + downloaded / 1024 + ) + ) + } + } + finishLine(text("init.install.method.downloadFromRelease.done")) + if (!path.exists()) { + throw IllegalStateException("Unable to find downloaded partner release at $path") + } + prompt.success(text("init.install.method.downloadFromRelease.success")) +} + +fun updateLine(text: String) { + print("\r\u001B[2K$text") + System.out.flush() +} + +fun finishLine(text: String) { + updateLine(text) + println() +} \ No newline at end of file diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt index 4ebed90b..2e191d3a 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.json.Json private const val registryUrl = "https://raw.githubusercontent.com/slhaf/Partner/refs/heads/master/registry" private const val indexUrl = "$registryUrl/index.json" -private val registryIndex = run { +val registryIndex = run { Json.decodeFromString(fetchText(indexUrl)) } diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt index a482c6c2..96ae5a4b 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt @@ -5,11 +5,15 @@ import java.net.InetSocketAddress import java.net.ProxySelector import java.net.URI import java.net.http.* +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.time.Duration +import kotlin.io.path.isDirectory private val httpClient: HttpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(20)) - .followRedirects(HttpClient.Redirect.NEVER) + .followRedirects(HttpClient.Redirect.NORMAL) .apply { proxySelectorFromEnv()?.let(::proxy) } @@ -72,3 +76,60 @@ fun fetchText(url: String): String { throw IOException("Failed to fetch $url after retries", lastError) } + +fun downloadTo( + url: String, + targetPath: Path, + onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> } +) { + if (targetPath.isDirectory()) { + throw IllegalArgumentException("Target path must be a file") + } + val targetPath = targetPath.toAbsolutePath().normalize() + val targetFile = targetPath.toFile() + val temp = Files.createTempFile( + "${targetFile.name}-${System.currentTimeMillis()}", ".${targetFile.extension}.download" + ) + + try { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + + val response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofInputStream() + ) + + if (response.statusCode() !in 200..299) { + throw IllegalStateException("Failed to download from $url: HTTP ${response.statusCode()}") + } + + val totalBytes = response.headers() + .firstValue("Content-Length") + .orElse(null) + ?.toLongOrNull() + + response.body().use { input -> + Files.newOutputStream(temp).use { output -> + val buffer = ByteArray(8192) + var downloaded = 0L + + while (true) { + val read = input.read(buffer) + if (read < 0) break + + output.write(buffer, 0, read) + downloaded += read + onProgress(downloaded, totalBytes) + } + } + } + + Files.move(temp, targetPath, StandardCopyOption.REPLACE_EXISTING) + } catch (e: Exception) { + Files.deleteIfExists(temp) + throw e + } +} diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties index 0da3c321..d68204d0 100644 --- a/PartnerCtl/src/main/resources/i18n/messages.properties +++ b/PartnerCtl/src/main/resources/i18n/messages.properties @@ -31,6 +31,12 @@ init.home.overwrite.refuseBroadDirectory=Refuse to overwrite suspiciously broad init.install.section=Install Partner init.install.method.label=Choose an installation method init.install.method.buildFromSource=Build Partner from source +init.install.method.downloadFromRelease=Download Partner release +init.install.method.downloadFromRelease.startDownloading=Downloading Partner release... +init.install.method.downloadFromRelease.success=Partner release downloaded successfully. +init.install.method.downloadFromRelease.progress.percent=Downloading Partner release... {0}% +init.install.method.downloadFromRelease.progress.size=Downloading Partner release... {0} KB +init.install.method.downloadFromRelease.done=Downloading Partner release... Done init.gateway.section=Configure Gateway init.gateway.select.label=Select gateway init.gateway.websocket.choice=WebSocket Gateway diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties index 4c98e739..246a9b0e 100644 --- a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties +++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties @@ -31,6 +31,12 @@ init.home.overwrite.refuseBroadDirectory=拒绝覆盖范围过大的目录:{0} init.install.section=安装 Partner init.install.method.label=选择安装方式 init.install.method.buildFromSource=从源码构建 Partner +init.install.method.downloadFromRelease=下载 Partner 发布包 +init.install.method.downloadFromRelease.startDownloading=正在下载 Partner 发布包... +init.install.method.downloadFromRelease.success=Partner 发布包下载完成。 +init.install.method.downloadFromRelease.progress.percent=正在下载 Partner 发布包... {0}% +init.install.method.downloadFromRelease.progress.size=正在下载 Partner 发布包... {0} KB +init.install.method.downloadFromRelease.done=正在下载 Partner 发布包... 完成 init.gateway.section=配置网关 init.gateway.select.label=选择网关 init.gateway.websocket.choice=WebSocket Gateway