diff --git a/PartnerCtl/pom.xml b/PartnerCtl/pom.xml
index 03aac9a0..e2d8369d 100644
--- a/PartnerCtl/pom.xml
+++ b/PartnerCtl/pom.xml
@@ -115,6 +115,7 @@
--no-fallback
-H:+ReportExceptionStackTraces
--initialize-at-build-time=kotlin.DeprecationLevel
+ -H:IncludeResourceBundles=i18n.messages
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 87fa81e9..0da6b09c 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
@@ -9,6 +9,7 @@ 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.i18n.I18n.text
import work.slhaf.partner.ctl.support.CommandInterrupted
import work.slhaf.partner.ctl.support.inheritCommand
import work.slhaf.partner.ctl.support.loadAvailableGateway
@@ -52,7 +53,7 @@ class InitCommand : Runnable {
}
private fun initHome(prompt: Prompt) {
- prompt.section("Initialize Partner Home")
+ prompt.section(text("init.home.section"))
home = choosePartnerHome(prompt)
@@ -60,7 +61,7 @@ class InitCommand : Runnable {
Files.createDirectories(home.resolve("resource"))
Files.createDirectories(home.resolve("config"))
- prompt.success("Partner Home initialized at $home")
+ prompt.success(text("init.home.success", home))
}
private fun choosePartnerHome(prompt: Prompt): Path {
@@ -68,7 +69,7 @@ class InitCommand : Runnable {
while (true) {
val selectedHome = prompt.askPath(
- label = "Partner Home",
+ label = text("init.home.label"),
defaultValue = defaultHome,
required = true,
directoryOnly = true,
@@ -78,29 +79,29 @@ class InitCommand : Runnable {
return selectedHome
}
- prompt.warn("Partner Home already contains files: $selectedHome")
+ prompt.warn(text("init.home.duplicate.warning", selectedHome))
when (prompt.select(
- label = "Partner Home already contains files. Choose how to continue",
+ label = text("init.home.duplicate.choice.label"),
choices = listOf(
Choice(
- "Use another Partner Home",
+ text("init.home.duplicate.choice.another"),
HomeDuplicateChoice.ANOTHER,
- "Choose a different directory",
+ text("init.home.duplicate.choice.another.description"),
),
Choice(
- "Overwrite current Partner Home",
+ text("init.home.duplicate.choice.overwrite"),
HomeDuplicateChoice.OVERWRITE,
- "Delete existing contents and continue",
+ text("init.home.duplicate.choice.overwrite.description"),
),
- Choice("Cancel init", HomeDuplicateChoice.EXIT),
+ Choice(text("init.home.duplicate.choice.exit"), HomeDuplicateChoice.EXIT),
),
defaultIndex = 0,
)) {
HomeDuplicateChoice.ANOTHER -> continue
HomeDuplicateChoice.OVERWRITE -> {
validateSafeHomeOverwrite(selectedHome)
- if (!prompt.confirm("Delete all files under $selectedHome?", false)) {
+ if (!prompt.confirm(text("init.home.duplicate.confirmDelete", selectedHome), false)) {
continue
}
clearHomeDirectory(selectedHome)
@@ -134,7 +135,7 @@ class InitCommand : Runnable {
private fun clearHomeDirectory(path: Path) {
if (!Files.exists(path)) return
if (!Files.isDirectory(path)) {
- throw CommandInterrupted("Partner Home is not a directory: $path")
+ throw CommandInterrupted(text("init.home.notDirectory", path))
}
Files.walk(path).use { stream ->
@@ -150,25 +151,25 @@ class InitCommand : Runnable {
val userHome = Paths.get(System.getProperty("user.home")).toAbsolutePath().normalize()
if (normalized == normalized.root) {
- throw CommandInterrupted("Refuse to overwrite filesystem root: $normalized")
+ throw CommandInterrupted(text("init.home.overwrite.refuseRoot", normalized))
}
if (normalized == userHome) {
- throw CommandInterrupted("Refuse to overwrite user home directory: $normalized")
+ throw CommandInterrupted(text("init.home.overwrite.refuseUserHome", normalized))
}
if (normalized.nameCount < 2) {
- throw CommandInterrupted("Refuse to overwrite suspiciously broad directory: $normalized")
+ throw CommandInterrupted(text("init.home.overwrite.refuseBroadDirectory", normalized))
}
}
private fun installPartner(prompt: Prompt) {
- prompt.section("Install Partner")
+ prompt.section(text("init.install.section"))
val installChoice = prompt.select(
- label = "Choose a installation method",
- choices = listOf(Choice("Build Partner from source", InstallChoice.BUILD_FROM_SOURCE))
+ label = text("init.install.method.label"),
+ choices = listOf(Choice(text("init.install.method.buildFromSource"), InstallChoice.BUILD_FROM_SOURCE))
)
when (installChoice) {
@@ -178,12 +179,12 @@ class InitCommand : Runnable {
}
private fun configureGateway(prompt: Prompt) {
- prompt.section("Configure Gateway")
+ prompt.section(text("init.gateway.section"))
val providedGateways = loadAvailableGateway()
val selectedGateways = prompt.multiSelect(
- label = "Select gateway",
- choices = listOf(Choice("WebSocket Gateway", "websocket_channel")) +
+ label = text("init.gateway.select.label"),
+ choices = listOf(Choice(text("init.gateway.websocket.choice"), "websocket_channel")) +
providedGateways.map {
Choice(it.name, it.id)
}
@@ -198,29 +199,29 @@ class InitCommand : Runnable {
if (manifest != null) {
return@map configureExternalGateway(home, prompt, manifest)
} else {
- prompt.warn("Could not find gateway with id $gateway")
+ prompt.warn(text("init.gateway.warn.notFound", gateway))
return@map null
}
}
} catch (_: PromptCancelledException) {
- prompt.warn("Gateway: $gateway configuration skipped")
+ prompt.warn(text("init.gateway.warn.skipped", gateway))
return@map null
}
}.filterNotNull()
val defaultChannel = if (configuredChannels.isEmpty()) {
- prompt.info("Skipped gateway configuration. Partner will use WebSocket as default gateway")
+ prompt.info(text("init.gateway.info.skippedUseDefault"))
return
} else if (configuredChannels.size == 1) {
configuredChannels.first().channelName
} else {
prompt.select(
- label = "Set default channel",
+ label = text("init.gateway.defaultChannel.label"),
choices = configuredChannels.map { Choice(it.channelName, it.channelName) }
)
}
- prompt.info("The default channel will be set to $defaultChannel")
+ prompt.info(text("init.gateway.info.defaultChannel", defaultChannel))
val gatewayConfig = GatewayConfig(
defaultChannel = defaultChannel,
@@ -236,11 +237,11 @@ class InitCommand : Runnable {
val gatewayPath = home.resolve("config").resolve("gateway.json").toAbsolutePath().normalize()
Files.writeString(gatewayPath, gatewayStr)
- prompt.success("Gateway config written to $gatewayPath")
+ prompt.success(text("init.gateway.success.configWritten", gatewayPath))
}
private fun configureModel(prompt: Prompt) {
- prompt.section("Configure Model")
+ prompt.section(text("init.model.section"))
val modelChoices = ModelProviderChoice.entries.map { Choice(it.display, it) }
@@ -250,9 +251,9 @@ class InitCommand : Runnable {
while (true) {
val choice = prompt.select(
label = if (!defaultAlreadySet) {
- "Choose default model provider type"
+ text("init.model.provider.default.label")
} else {
- "Choose model provider type"
+ text("init.model.provider.label")
},
choices = modelChoices
)
@@ -264,10 +265,9 @@ class InitCommand : Runnable {
break
} else {
prompt.warn(
- "No default model provider configured. Partner may not start normally unless model.json exists " +
- "or PARTNER_DEFAULT_BASE_URL, PARTNER_DEFAULT_API_KEY, and PARTNER_DEFAULT_MODEL are provided at runtime."
+ text("init.model.warn.noDefaultProvider")
)
- if (prompt.confirm("Skip model configuration?", false)) {
+ if (prompt.confirm(text("init.model.confirm.skipConfiguration"), false)) {
break
} else {
null
@@ -280,7 +280,7 @@ class InitCommand : Runnable {
chosenModelProviders.add(it)
if (!defaultAlreadySet) {
defaultAlreadySet = true
- if (!prompt.confirm("Add additional model provider?", false)) {
+ if (!prompt.confirm(text("init.model.confirm.addAdditionalProvider"), false)) {
break
}
}
@@ -306,15 +306,15 @@ class InitCommand : Runnable {
val modelPath = home.resolve("config").resolve("model.json").toAbsolutePath().normalize()
Files.writeString(modelPath, json.encodeToString(JsonObject.serializer(), jsonObject))
- prompt.success("Model config written to $modelPath")
+ prompt.success(text("init.model.success.configWritten", modelPath))
}
}
private fun finalize(prompt: Prompt) {
- prompt.section("Finish")
+ prompt.section(text("init.finish.section"))
- if (!prompt.confirm("Start Partner now?", false)) {
- prompt.info("Initialization completed.")
+ if (!prompt.confirm(text("init.finish.confirm.startNow"), false)) {
+ prompt.info(text("init.finish.info.completed"))
return
}
@@ -323,7 +323,7 @@ class InitCommand : Runnable {
throw CommandInterrupted("Partner runtime jar does not exist: $partnerJar")
}
- prompt.info("Starting Partner...")
+ prompt.info(text("init.finish.info.starting"))
val exitCode = inheritCommand(
command = listOf("java", "-jar", partnerJar.toString()),
environment = mapOf("PARTNER_HOME" to home.toString()),
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt
index 05736ce5..cf048606 100644
--- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt
@@ -4,6 +4,7 @@ import kotlinx.serialization.json.*
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.i18n.I18n.text
import work.slhaf.partner.ctl.support.*
import work.slhaf.partner.ctl.ui.Prompt
import java.net.URI
@@ -13,26 +14,26 @@ import java.nio.file.Paths
import kotlin.io.path.isDirectory
fun configureWebSocketGateway(prompt: Prompt): GatewayConfig.ChannelConfig {
- prompt.section("Configure Gateway: WebSocket Gateway")
+ prompt.section(text("configure.gateway.websocket.section"))
- val port = prompt.ask("port", "29600") {
- val intValue = it.toIntOrNull() ?: return@ask "WebSocket port only accepts int value"
- if (intValue !in 1..65565) "WebSocket port should be between 1 and 65565" else null
+ val port = prompt.ask(text("configure.gateway.websocket.port.label"), "29600") {
+ val intValue = it.toIntOrNull() ?: return@ask text("configure.gateway.websocket.port.error.int")
+ if (intValue !in 1..65565) text("configure.gateway.websocket.port.error.range") else null
}.toInt()
- val heartbeatInterval = prompt.ask("heartbeat interval", "10000") {
- it.toLongOrNull() ?: return@ask "Heartbeat interval only accepts long value"
+ val heartbeatInterval = prompt.ask(text("configure.gateway.websocket.heartbeatInterval.label"), "10000") {
+ it.toLongOrNull() ?: return@ask text("configure.gateway.websocket.heartbeatInterval.error.long")
return@ask null
}.toLong()
- val hostname = prompt.ask("Listening hostname", "127.0.0.1") {
+ val hostname = prompt.ask(text("configure.gateway.websocket.hostname.label"), "127.0.0.1") {
val host = it.trim()
return@ask when {
- host.isEmpty() -> "Hostname is required"
- host.contains(Regex("\\s")) -> "Hostname must not contain whitespace."
- host.contains("://") -> "Do not include protocol. Use hostname only, for example: 127.0.0.1"
- host.contains("/") -> "Do not include path. Use hostname only."
- looksLikeHostWithPort(host) -> "Do not include port here. Port is configured separately."
+ host.isEmpty() -> text("configure.gateway.websocket.hostname.error.required")
+ host.contains(Regex("\\s")) -> text("configure.gateway.websocket.hostname.error.whitespace")
+ host.contains("://") -> text("configure.gateway.websocket.hostname.error.protocol")
+ host.contains("/") -> text("configure.gateway.websocket.hostname.error.path")
+ looksLikeHostWithPort(host) -> text("configure.gateway.websocket.hostname.error.port")
else -> null
}
}
@@ -53,21 +54,21 @@ private fun looksLikeHostWithPort(value: String): Boolean {
}
fun configureExternalGateway(home: Path, prompt: Prompt, manifest: ModuleManifest): GatewayConfig.ChannelConfig? {
- prompt.section("Configure Gateway: ${manifest.name}")
+ prompt.section(text("configure.gateway.external.section", manifest.name))
prompt.details(
- title = "Gateway module details",
+ title = text("configure.gateway.external.details.title"),
items = listOf(
- "Description" to manifest.description,
- "Source" to manifest.source.url,
- "Build command" to manifest.source.buildCommand.joinToString(" "),
- "Artifact" to "${manifest.source.artifactDirectory}/${manifest.source.artifactPattern}",
- "Install target" to manifest.install.target,
- "Config target" to (manifest.config?.target ?: "No config"),
+ text("configure.gateway.external.details.description") to manifest.description,
+ text("configure.gateway.external.details.source") to manifest.source.url,
+ text("configure.gateway.external.details.buildCommand") to manifest.source.buildCommand.joinToString(" "),
+ text("configure.gateway.external.details.artifact") to "${manifest.source.artifactDirectory}/${manifest.source.artifactPattern}",
+ text("configure.gateway.external.details.installTarget") to manifest.install.target,
+ text("configure.gateway.external.details.configTarget") to (manifest.config?.target ?: text("configure.gateway.external.details.noConfig")),
),
)
- if (!prompt.confirm("Continue installation?", true)) {
+ if (!prompt.confirm(text("configure.gateway.external.confirmContinue"), true)) {
return null
}
@@ -143,20 +144,20 @@ private fun validateFieldValue(field: Field, value: String): String? {
return when (field.type) {
FieldType.STRING -> null
- FieldType.INT -> value.toIntOrNull()?.let { null } ?: "${field.label} only accepts int value"
- FieldType.NUMBER -> value.toDoubleOrNull()?.let { null } ?: "${field.label} only accepts number value"
- FieldType.BOOLEAN -> value.toBooleanStrictOrNull()?.let { null } ?: "${field.label} only accepts true or false"
+ FieldType.INT -> value.toIntOrNull()?.let { null } ?: text("configure.field.error.int", field.label)
+ FieldType.NUMBER -> value.toDoubleOrNull()?.let { null } ?: text("configure.field.error.number", field.label)
+ FieldType.BOOLEAN -> value.toBooleanStrictOrNull()?.let { null } ?: text("configure.field.error.boolean", field.label)
FieldType.RAW_JSON -> runCatching { Json.parseToJsonElement(value) }
.exceptionOrNull()
- ?.let { "${field.label} only accepts valid JSON" }
+ ?.let { text("configure.field.error.rawJson", field.label) }
}
}
fun configureOpenAiCompatible(prompt: Prompt, defaultAlreadySet: Boolean): ProviderConfig {
val name = if (defaultAlreadySet) {
- prompt.ask("Provider name") {
+ prompt.ask(text("configure.model.openAiCompatible.providerName.label")) {
if (it == "default") {
- "Default provider cannot be duplicate"
+ text("configure.model.openAiCompatible.providerName.error.duplicateDefault")
} else {
null
}
@@ -165,12 +166,12 @@ fun configureOpenAiCompatible(prompt: Prompt, defaultAlreadySet: Boolean): Provi
"default"
}
- val baseUrl = prompt.ask("Base url") { value ->
+ val baseUrl = prompt.ask(text("configure.model.openAiCompatible.baseUrl.label")) { value ->
validateNetworkUrl(value)
}
- val apikey = prompt.ask("Apikey")
- val defaultModel = prompt.ask("Default model")
+ val apikey = prompt.ask(text("configure.model.openAiCompatible.apiKey.label"))
+ val defaultModel = prompt.ask(text("configure.model.openAiCompatible.defaultModel.label"))
return OpenAiCompatible(
name = name,
baseUrl = baseUrl,
@@ -182,18 +183,18 @@ fun configureOpenAiCompatible(prompt: Prompt, defaultAlreadySet: Boolean): Provi
private fun validateNetworkUrl(value: String): String? {
val trimmed = value.trim()
if (trimmed.isEmpty()) {
- return "Base url is required"
+ return text("configure.model.openAiCompatible.baseUrl.error.required")
}
val uri = runCatching { URI(trimmed) }.getOrElse {
- return "Base url must be a valid URL"
+ return text("configure.model.openAiCompatible.baseUrl.error.validUrl")
}
return when {
- uri.scheme !in setOf("http", "https") -> "Base url must start with http:// or https://"
- uri.host.isNullOrBlank() -> "Base url must include a valid host"
- uri.rawUserInfo != null -> "Base url must not include user info"
- uri.rawFragment != null -> "Base url must not include fragment"
+ uri.scheme !in setOf("http", "https") -> text("configure.model.openAiCompatible.baseUrl.error.scheme")
+ uri.host.isNullOrBlank() -> text("configure.model.openAiCompatible.baseUrl.error.host")
+ uri.rawUserInfo != null -> text("configure.model.openAiCompatible.baseUrl.error.userInfo")
+ uri.rawFragment != null -> text("configure.model.openAiCompatible.baseUrl.error.fragment")
else -> null
}
}
\ No newline at end of file
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/i18n/I18n.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/i18n/I18n.kt
new file mode 100644
index 00000000..ac33e748
--- /dev/null
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/i18n/I18n.kt
@@ -0,0 +1,20 @@
+package work.slhaf.partner.ctl.i18n
+
+import java.text.MessageFormat
+import java.util.*
+
+object I18n {
+ private const val BUNDLE = "i18n.messages"
+
+ private val locale: Locale by lazy {
+ System.getenv("PARTNER_LOCALE")
+ ?.takeIf { it.isNotBlank() }
+ ?.let { Locale.forLanguageTag(it.replace('_', '-')) }
+ ?: Locale.getDefault()
+ }
+
+ fun text(key: String, vararg args: Any?): String {
+ val pattern = ResourceBundle.getBundle(BUNDLE, locale).getString(key)
+ return MessageFormat.format(pattern, *args)
+ }
+}
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/source_build.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/source_build.kt
index 172ae09c..aac6ba8a 100644
--- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/source_build.kt
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/source_build.kt
@@ -1,5 +1,6 @@
package work.slhaf.partner.ctl.support
+import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.ui.Prompt
import java.nio.file.Files
import java.nio.file.Path
@@ -51,30 +52,30 @@ fun buildAndInstallFromSource(
val targetPath = home.resolve(spec.installRelativePath)
try {
- prompt.info("Cloning ${spec.displayName} source from ${spec.repoUrl}")
+ prompt.info(text("sourceBuild.info.cloning", spec.displayName, spec.repoUrl))
val cloneExitCode = inheritCommand(
command = listOf("git", "clone", "--depth", "1", spec.repoUrl, sourceDir.toString()),
)
if (cloneExitCode != 0) {
- throw CommandInterrupted("Failed to clone ${spec.displayName} source from ${spec.repoUrl}")
+ throw CommandInterrupted(text("sourceBuild.error.cloneFailed", spec.displayName, spec.repoUrl))
}
- prompt.info("Building ${spec.displayName}")
+ prompt.info(text("sourceBuild.info.building", spec.displayName))
val buildExitCode = inheritCommand(
command = spec.buildCommand,
workingDirectory = sourceDir,
)
if (buildExitCode != 0) {
- throw CommandInterrupted("Failed to build ${spec.displayName}.")
+ throw CommandInterrupted(text("sourceBuild.error.buildFailed", spec.displayName))
}
val artifactDir = sourceDir.resolve(spec.artifactDirectory)
val artifact = spec.artifactSelector(artifactDir)
- ?: throw CommandInterrupted("Could not find built ${spec.displayName} artifact in $artifactDir")
+ ?: throw CommandInterrupted(text("sourceBuild.error.artifactNotFound", spec.displayName, artifactDir))
Files.createDirectories(targetPath.parent)
Files.copy(artifact, targetPath, StandardCopyOption.REPLACE_EXISTING)
- prompt.success("${spec.displayName} installed at $targetPath")
+ prompt.success(text("sourceBuild.success.installed", spec.displayName, targetPath))
} finally {
runCatching { tempDir.toFile().deleteRecursively() }
}
@@ -83,31 +84,31 @@ fun buildAndInstallFromSource(
private fun checkTool() {
fun buildToolLackedMessage(lack: Map): String {
return buildString {
- appendLine("Missing required build tools:")
+ appendLine(text("sourceBuild.tool.missing.header"))
appendLine()
lack.forEach { (tool, reason) ->
- appendLine("$tool:")
- appendLine(" $reason")
+ appendLine(text("sourceBuild.tool.missing.item", tool))
+ appendLine(text("sourceBuild.tool.missing.reason", reason))
}
appendLine()
- appendLine("Install the missing tools, then rerun:")
+ appendLine(text("sourceBuild.tool.missing.footer"))
appendLine(" partnerctl init")
}.trimEnd()
}
val lack = mutableMapOf()
if (runCommand(listOf("java", "--version")).exitCode != 0) {
- lack["java"] = "Required to run Maven. Command failed: java --version"
+ lack["java"] = text("sourceBuild.tool.java.reason")
}
if (runCommand(listOf("javac", "--version")).exitCode != 0) {
lack["javac"] =
- "Required to compile Partner from source. Install a JDK, not just a JRE. Command failed: javac --version"
+ text("sourceBuild.tool.javac.reason")
}
if (runCommand(listOf("git", "--version")).exitCode != 0) {
- lack["git"] = "Required to clone Partner source. Command failed: git --version"
+ lack["git"] = text("sourceBuild.tool.git.reason")
}
if (runCommand(listOf("mvn", "--version")).exitCode != 0) {
- lack["mvn"] = "Required to build Partner from source. Command failed: mvn --version"
+ lack["mvn"] = text("sourceBuild.tool.mvn.reason")
}
if (lack.isNotEmpty()) {
throw CommandInterrupted(buildToolLackedMessage(lack))
diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties
new file mode 100644
index 00000000..19832ad8
--- /dev/null
+++ b/PartnerCtl/src/main/resources/i18n/messages.properties
@@ -0,0 +1,91 @@
+init.home.section=Initialize Partner Home
+init.home.label=Partner Home
+init.home.success=Partner Home initialized at {0}
+init.home.duplicate.warning=Partner Home already contains files: {0}
+init.home.duplicate.choice.label=Partner Home already contains files. Choose how to continue
+init.home.duplicate.choice.another=Use another Partner Home
+init.home.duplicate.choice.another.description=Choose a different directory
+init.home.duplicate.choice.overwrite=Overwrite current Partner Home
+init.home.duplicate.choice.overwrite.description=Delete existing contents and continue
+init.home.duplicate.choice.exit=Cancel init
+init.home.duplicate.confirmDelete=Delete all files under {0}?
+init.home.notDirectory=Partner Home is not a directory: {0}
+init.home.overwrite.refuseRoot=Refuse to overwrite filesystem root: {0}
+init.home.overwrite.refuseUserHome=Refuse to overwrite user home directory: {0}
+init.home.overwrite.refuseBroadDirectory=Refuse to overwrite suspiciously broad directory: {0}
+init.install.section=Install Partner
+init.install.method.label=Choose an installation method
+init.install.method.buildFromSource=Build Partner from source
+init.gateway.section=Configure Gateway
+init.gateway.select.label=Select gateway
+init.gateway.websocket.choice=WebSocket Gateway
+init.gateway.warn.notFound=Could not find gateway with id {0}
+init.gateway.warn.skipped=Gateway: {0} configuration skipped
+init.gateway.info.skippedUseDefault=Skipped gateway configuration. Partner will use WebSocket as default gateway
+init.gateway.defaultChannel.label=Set default channel
+init.gateway.info.defaultChannel=The default channel will be set to {0}
+init.gateway.success.configWritten=Gateway config written to {0}
+init.model.section=Configure Model
+init.model.provider.default.label=Choose default model provider type
+init.model.provider.label=Choose model provider type
+init.model.warn.noDefaultProvider=No default model provider configured. Partner may not start normally unless model.json exists or PARTNER_DEFAULT_BASE_URL, PARTNER_DEFAULT_API_KEY, and PARTNER_DEFAULT_MODEL are provided at runtime.
+init.model.confirm.skipConfiguration=Skip model configuration?
+init.model.confirm.addAdditionalProvider=Add additional model provider?
+init.model.success.configWritten=Model config written to {0}
+init.finish.section=Finish
+init.finish.confirm.startNow=Start Partner now?
+init.finish.info.completed=Initialization completed.
+init.finish.info.starting=Starting Partner...
+
+configure.gateway.websocket.section=Configure Gateway: WebSocket Gateway
+configure.gateway.websocket.port.label=port
+configure.gateway.websocket.port.error.int=WebSocket port only accepts int value
+configure.gateway.websocket.port.error.range=WebSocket port should be between 1 and 65565
+configure.gateway.websocket.heartbeatInterval.label=heartbeat interval
+configure.gateway.websocket.heartbeatInterval.error.long=Heartbeat interval only accepts long value
+configure.gateway.websocket.hostname.label=Listening hostname
+configure.gateway.websocket.hostname.error.required=Hostname is required
+configure.gateway.websocket.hostname.error.whitespace=Hostname must not contain whitespace.
+configure.gateway.websocket.hostname.error.protocol=Do not include protocol. Use hostname only, for example: 127.0.0.1
+configure.gateway.websocket.hostname.error.path=Do not include path. Use hostname only.
+configure.gateway.websocket.hostname.error.port=Do not include port here. Port is configured separately.
+configure.gateway.external.section=Configure Gateway: {0}
+configure.gateway.external.details.title=Gateway module details
+configure.gateway.external.details.description=Description
+configure.gateway.external.details.source=Source
+configure.gateway.external.details.buildCommand=Build command
+configure.gateway.external.details.artifact=Artifact
+configure.gateway.external.details.installTarget=Install target
+configure.gateway.external.details.configTarget=Config target
+configure.gateway.external.details.noConfig=No config
+configure.gateway.external.confirmContinue=Continue installation?
+configure.field.error.int={0} only accepts int value
+configure.field.error.number={0} only accepts number value
+configure.field.error.boolean={0} only accepts true or false
+configure.field.error.rawJson={0} only accepts valid JSON
+configure.model.openAiCompatible.providerName.label=Provider name
+configure.model.openAiCompatible.providerName.error.duplicateDefault=Default provider cannot be duplicate
+configure.model.openAiCompatible.baseUrl.label=Base url
+configure.model.openAiCompatible.apiKey.label=Apikey
+configure.model.openAiCompatible.defaultModel.label=Default model
+configure.model.openAiCompatible.baseUrl.error.required=Base url is required
+configure.model.openAiCompatible.baseUrl.error.validUrl=Base url must be a valid URL
+configure.model.openAiCompatible.baseUrl.error.scheme=Base url must start with http:// or https://
+configure.model.openAiCompatible.baseUrl.error.host=Base url must include a valid host
+configure.model.openAiCompatible.baseUrl.error.userInfo=Base url must not include user info
+configure.model.openAiCompatible.baseUrl.error.fragment=Base url must not include fragment
+
+sourceBuild.info.cloning=Cloning {0} source from {1}
+sourceBuild.error.cloneFailed=Failed to clone {0} source from {1}
+sourceBuild.info.building=Building {0}
+sourceBuild.error.buildFailed=Failed to build {0}.
+sourceBuild.error.artifactNotFound=Could not find built {0} artifact in {1}
+sourceBuild.success.installed={0} installed at {1}
+sourceBuild.tool.missing.header=Missing required build tools:
+sourceBuild.tool.missing.item={0}:
+sourceBuild.tool.missing.reason= {0}
+sourceBuild.tool.missing.footer=Install the missing tools, then rerun:
+sourceBuild.tool.java.reason=Required to run Maven. Command failed: java --version
+sourceBuild.tool.javac.reason=Required to compile Partner from source. Install a JDK, not just a JRE. Command failed: javac --version
+sourceBuild.tool.git.reason=Required to clone Partner source. Command failed: git --version
+sourceBuild.tool.mvn.reason=Required to build Partner from source. Command failed: mvn --version
diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties
new file mode 100644
index 00000000..54dad2c4
--- /dev/null
+++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties
@@ -0,0 +1,91 @@
+init.home.section=初始化 Partner Home
+init.home.label=Partner Home
+init.home.success=Partner Home 已初始化:{0}
+init.home.duplicate.warning=Partner Home 已包含文件:{0}
+init.home.duplicate.choice.label=Partner Home 已包含文件,请选择如何继续
+init.home.duplicate.choice.another=使用另一个 Partner Home
+init.home.duplicate.choice.another.description=重新选择一个目录
+init.home.duplicate.choice.overwrite=覆盖当前 Partner Home
+init.home.duplicate.choice.overwrite.description=删除现有内容并继续
+init.home.duplicate.choice.exit=取消初始化
+init.home.duplicate.confirmDelete=确定删除 {0} 下的所有文件?
+init.home.notDirectory=Partner Home 不是目录:{0}
+init.home.overwrite.refuseRoot=拒绝覆盖文件系统根目录:{0}
+init.home.overwrite.refuseUserHome=拒绝覆盖用户主目录:{0}
+init.home.overwrite.refuseBroadDirectory=拒绝覆盖范围过大的目录:{0}
+init.install.section=安装 Partner
+init.install.method.label=选择安装方式
+init.install.method.buildFromSource=从源码构建 Partner
+init.gateway.section=配置网关
+init.gateway.select.label=选择网关
+init.gateway.websocket.choice=WebSocket Gateway
+init.gateway.warn.notFound=找不到 id 为 {0} 的网关
+init.gateway.warn.skipped=网关:{0} 配置已跳过
+init.gateway.info.skippedUseDefault=已跳过网关配置。Partner 将使用 WebSocket 作为默认网关
+init.gateway.defaultChannel.label=设置默认 channel
+init.gateway.info.defaultChannel=默认 channel 将设置为 {0}
+init.gateway.success.configWritten=网关配置已写入 {0}
+init.model.section=配置模型
+init.model.provider.default.label=选择默认模型 provider 类型
+init.model.provider.label=选择模型 provider 类型
+init.model.warn.noDefaultProvider=没有配置默认模型 provider。除非 model.json 已存在,或运行时提供 PARTNER_DEFAULT_BASE_URL、PARTNER_DEFAULT_API_KEY 和 PARTNER_DEFAULT_MODEL,否则 Partner 可能无法正常启动。
+init.model.confirm.skipConfiguration=跳过模型配置?
+init.model.confirm.addAdditionalProvider=添加额外模型 provider?
+init.model.success.configWritten=模型配置已写入 {0}
+init.finish.section=完成
+init.finish.confirm.startNow=现在启动 Partner?
+init.finish.info.completed=初始化完成。
+init.finish.info.starting=正在启动 Partner...
+
+configure.gateway.websocket.section=配置网关:WebSocket Gateway
+configure.gateway.websocket.port.label=端口
+configure.gateway.websocket.port.error.int=WebSocket 端口只接受整数
+configure.gateway.websocket.port.error.range=WebSocket 端口应在 1 到 65565 之间
+configure.gateway.websocket.heartbeatInterval.label=心跳间隔
+configure.gateway.websocket.heartbeatInterval.error.long=心跳间隔只接受 long 数值
+configure.gateway.websocket.hostname.label=监听主机名
+configure.gateway.websocket.hostname.error.required=主机名不能为空
+configure.gateway.websocket.hostname.error.whitespace=主机名不能包含空白字符。
+configure.gateway.websocket.hostname.error.protocol=不要包含协议。只填写主机名,例如:127.0.0.1
+configure.gateway.websocket.hostname.error.path=不要包含路径。只填写主机名。
+configure.gateway.websocket.hostname.error.port=不要在这里填写端口。端口会单独配置。
+configure.gateway.external.section=配置网关:{0}
+configure.gateway.external.details.title=网关模块详情
+configure.gateway.external.details.description=描述
+configure.gateway.external.details.source=来源
+configure.gateway.external.details.buildCommand=构建命令
+configure.gateway.external.details.artifact=构建产物
+configure.gateway.external.details.installTarget=安装目标
+configure.gateway.external.details.configTarget=配置目标
+configure.gateway.external.details.noConfig=无配置
+configure.gateway.external.confirmContinue=继续安装?
+configure.field.error.int={0} 只接受整数
+configure.field.error.number={0} 只接受数字
+configure.field.error.boolean={0} 只接受 true 或 false
+configure.field.error.rawJson={0} 只接受合法 JSON
+configure.model.openAiCompatible.providerName.label=Provider 名称
+configure.model.openAiCompatible.providerName.error.duplicateDefault=Default provider 不能重复
+configure.model.openAiCompatible.baseUrl.label=Base url
+configure.model.openAiCompatible.apiKey.label=Apikey
+configure.model.openAiCompatible.defaultModel.label=Default model
+configure.model.openAiCompatible.baseUrl.error.required=Base url 不能为空
+configure.model.openAiCompatible.baseUrl.error.validUrl=Base url 必须是合法 URL
+configure.model.openAiCompatible.baseUrl.error.scheme=Base url 必须以 http:// 或 https:// 开头
+configure.model.openAiCompatible.baseUrl.error.host=Base url 必须包含合法 host
+configure.model.openAiCompatible.baseUrl.error.userInfo=Base url 不能包含 user info
+configure.model.openAiCompatible.baseUrl.error.fragment=Base url 不能包含 fragment
+
+sourceBuild.info.cloning=正在从 {1} 克隆 {0} 源码
+sourceBuild.error.cloneFailed=从 {1} 克隆 {0} 源码失败
+sourceBuild.info.building=正在构建 {0}
+sourceBuild.error.buildFailed=构建 {0} 失败。
+sourceBuild.error.artifactNotFound=在 {1} 中找不到已构建的 {0} 产物
+sourceBuild.success.installed={0} 已安装到 {1}
+sourceBuild.tool.missing.header=缺少必要的构建工具:
+sourceBuild.tool.missing.item={0}:
+sourceBuild.tool.missing.reason= {0}
+sourceBuild.tool.missing.footer=安装缺失工具后重新运行:
+sourceBuild.tool.java.reason=运行 Maven 需要 java。命令失败:java --version
+sourceBuild.tool.javac.reason=从源码编译 Partner 需要 javac。请安装 JDK,而不只是 JRE。命令失败:javac --version
+sourceBuild.tool.git.reason=克隆 Partner 源码需要 git。命令失败:git --version
+sourceBuild.tool.mvn.reason=从源码构建 Partner 需要 mvn。命令失败:mvn --version