diff --git a/PartnerCtl/pom.xml b/PartnerCtl/pom.xml index e2d8369d..5d0f9688 100644 --- a/PartnerCtl/pom.xml +++ b/PartnerCtl/pom.xml @@ -41,6 +41,12 @@ kotlinx-serialization-json 1.9.0 + + work.slhaf.partner + partner-interaction-api + 0.5.0 + compile + diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ChatCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ChatCommand.kt index b7224c88..302a3e67 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ChatCommand.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/ChatCommand.kt @@ -1,11 +1,9 @@ package work.slhaf.partner.ctl.commands -import org.jline.reader.EndOfFileException -import org.jline.reader.LineReaderBuilder -import org.jline.reader.UserInterruptException -import org.jline.terminal.Terminal -import org.jline.terminal.TerminalBuilder import picocli.CommandLine +import work.slhaf.partner.api.InputData +import work.slhaf.partner.ctl.commands.chat.ChatScreen +import work.slhaf.partner.ctl.commands.chat.WebSocketClient @CommandLine.Command( name = "chat", @@ -17,43 +15,33 @@ class ChatCommand : Runnable { @CommandLine.Mixin lateinit var helpOptions: HelpOptions + @CommandLine.Option( + names = ["--url"], + description = ["WebSocket gateway URL."], + defaultValue = DEFAULT_URL, + ) + lateinit var url: String + + @CommandLine.Option( + names = ["--source"], + description = ["Input source identity used by Partner runtime."], + defaultValue = DEFAULT_SOURCE, + ) + lateinit var source: String + override fun run() { - val terminal = createTerminal() - val reader = LineReaderBuilder.builder() - .terminal(terminal) - .build() - - terminal.writer().println("Partner chat demo. Type /exit to quit.") - terminal.writer().flush() - - while (true) { - val line = try { - reader.readLine("partner> ") - } catch (_: UserInterruptException) { - terminal.writer().println() - terminal.writer().flush() - continue - } catch (_: EndOfFileException) { - terminal.writer().println() - terminal.writer().flush() - break - } - - when { - line == "/exit" -> break - line.isBlank() -> continue - else -> { - terminal.writer().println("echo: $line") - terminal.writer().flush() - } + val screen = ChatScreen() + WebSocketClient(url) { event -> + screen.postInteractionEvent(event) + }.use { client -> + screen.run { line -> + client.send(InputData(source, line)) } } } - private fun createTerminal(): Terminal { - return TerminalBuilder.builder() - .system(true) - .dumb(true) - .build() + private companion object { + private const val DEFAULT_URL = "ws://127.0.0.1:29600" + private const val DEFAULT_SOURCE = "partnerctl" } -} \ No newline at end of file +} diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt new file mode 100644 index 00000000..83c724cb --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt @@ -0,0 +1,28 @@ +package work.slhaf.partner.ctl.commands.chat + +import work.slhaf.partner.api.InteractionEvent +import work.slhaf.partner.api.ModuleEvent +import work.slhaf.partner.api.ReplyEvent +import work.slhaf.partner.api.SystemEvent + +internal class ChatEventRenderer { + fun renderCommittedUserInput(content: String): String = "you: $content" + + fun renderActiveReply(content: String): String { + return if (content.isBlank()) { + "assistant:" + } else { + "assistant: $content" + } + } + + fun renderEventMessage(event: InteractionEvent): String? { + return when (event) { + is ReplyEvent -> null + is SystemEvent -> "system: ${event.title}: ${event.content}" + is ModuleEvent -> "module:${event.data.module}: ${event.data.content}" + } + } + + fun renderSendFailure(message: String): String = "send failed: $message" +} diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatInputBuffer.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatInputBuffer.kt new file mode 100644 index 00000000..8872bb0d --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatInputBuffer.kt @@ -0,0 +1,30 @@ +package work.slhaf.partner.ctl.commands.chat + +internal class ChatInputBuffer { + private val buffer = StringBuilder() + + val isEmpty: Boolean + get() = buffer.isEmpty() + + fun append(ch: Char) { + buffer.append(ch) + } + + fun backspace() { + if (buffer.isNotEmpty()) { + buffer.deleteCharAt(buffer.length - 1) + } + } + + fun clear() { + buffer.setLength(0) + } + + fun consume(): String { + val value = buffer.toString() + clear() + return value + } + + override fun toString(): String = buffer.toString() +} diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt new file mode 100644 index 00000000..a9a6cbf1 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt @@ -0,0 +1,270 @@ +package work.slhaf.partner.ctl.commands.chat + +import org.jline.terminal.Terminal +import org.jline.terminal.TerminalBuilder +import work.slhaf.partner.api.InteractionEvent +import work.slhaf.partner.api.InteractionEvent.EventStatus +import work.slhaf.partner.api.ReplyEvent +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import kotlin.math.ceil + +internal class ChatScreen( + private val terminal: Terminal = TerminalBuilder.builder() + .system(true) + .dumb(true) + .build(), + private val renderer: ChatEventRenderer = ChatEventRenderer(), +) : AutoCloseable { + + private val input = ChatInputBuffer() + private val events: BlockingQueue = LinkedBlockingQueue() + private val activeReply = StringBuilder() + private var dynamicRows = 0 + private var closed = false + + fun postInteractionEvent(event: InteractionEvent) { + events.offer(ChatScreenEvent.Interaction(event)) + } + + fun postSystemMessage(message: String) { + events.offer(ChatScreenEvent.SystemMessage(message)) + } + + fun run(onInput: (String) -> Unit) { + terminal.writer().println("Partner chat. Type /exit to quit.") + terminal.writer().println() + terminal.writer().flush() + + val oldAttributes = terminal.enterRawMode() + try { + repaintDynamicArea() + while (!closed) { + drainEvents() + val key = terminal.reader().read(50L) + if (key != -1) { + handleKey(key, onInput) + } + } + } finally { + terminal.attributes = oldAttributes + clearDynamicArea() + terminal.writer().println() + terminal.writer().flush() + } + } + + override fun close() { + closed = true + } + + private fun handleKey(key: Int, onInput: (String) -> Unit) { + when (key) { + CTRL_C, CTRL_D -> close() + ENTER, CARRIAGE_RETURN -> submitInput(onInput) + BACKSPACE, DELETE -> { + input.backspace() + repaintDynamicArea() + } + + ESCAPE -> consumeEscapeSequence() + else -> { + if (key >= PRINTABLE_START) { + input.append(key.toChar()) + repaintDynamicArea() + } + } + } + } + + private fun submitInput(onInput: (String) -> Unit) { + val line = input.consume() + if (line.isBlank()) { + repaintDynamicArea() + return + } + if (line == "/exit") { + close() + return + } + + commitDynamicArea() + printCommitted(renderer.renderCommittedUserInput(line)) + activeReply.setLength(0) + repaintDynamicArea() + + runCatching { onInput(line) } + .onFailure { error -> showSendFailure(error.message ?: error::class.java.simpleName) } + } + + private fun drainEvents() { + var changed = false + while (true) { + val event = events.poll() ?: break + when (event) { + is ChatScreenEvent.Interaction -> handleInteractionEvent(event.event) + is ChatScreenEvent.SystemMessage -> { + commitDynamicArea() + printCommitted(event.message) + } + } + changed = true + } + if (changed) { + repaintDynamicArea() + } + } + + private fun handleInteractionEvent(event: InteractionEvent) { + if (event is ReplyEvent) { + when (event.mode) { + ReplyEvent.ContentMode.APPEND -> activeReply.append(event.content) + ReplyEvent.ContentMode.REPLACE -> { + activeReply.setLength(0) + activeReply.append(event.content) + } + } + + if (event.status == EventStatus.DONE || event.status == EventStatus.ERROR) { + commitDynamicArea() + if (activeReply.isNotBlank()) { + printCommitted(renderer.renderActiveReply(activeReply.toString())) + activeReply.setLength(0) + } + if (event.status == EventStatus.ERROR) { + printCommitted("assistant: [error]") + } + } + return + } + + renderer.renderEventMessage(event)?.let { message -> + commitDynamicArea() + printCommitted(message) + } + } + + private fun showSendFailure(message: String) { + commitDynamicArea() + printCommitted(renderer.renderSendFailure(message)) + repaintDynamicArea() + } + + private fun commitDynamicArea() { + clearDynamicArea() + } + + private fun printCommitted(message: String) { + message.split('\n').forEach(terminal.writer()::println) + terminal.writer().flush() + } + + private fun repaintDynamicArea() { + clearDynamicArea() + + val output = dynamicOutput() + terminal.writer().print(output) + terminal.writer().flush() + + dynamicRows = measureDisplayRows(output) + } + + private fun clearDynamicArea() { + if (dynamicRows <= 0) { + return + } + + terminal.writer().print("\r") + if (dynamicRows > 1) { + terminal.writer().print("\u001B[${dynamicRows - 1}A") + } + terminal.writer().print("\u001B[J") + dynamicRows = 0 + } + + private fun dynamicOutput(): String { + return buildString { + if (activeReply.isNotBlank()) { + append(renderer.renderActiveReply(activeReply.toString())) + append('\n') + } + append(inputPrompt()) + } + } + + private fun inputPrompt(): String = "partner> ${input}" + + private fun measureDisplayRows(text: String): Int { + val width = terminal.width.takeIf { it > 0 } ?: DEFAULT_TERMINAL_WIDTH + return text.split('\n').sumOf { line -> + ceil(displayWidth(line).coerceAtLeast(1).toDouble() / width.toDouble()) + .toInt() + .coerceAtLeast(1) + } + } + + private fun displayWidth(text: String): Int { + var width = 0 + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + width += codePointWidth(codePoint) + index += Character.charCount(codePoint) + } + return width + } + + private fun codePointWidth(codePoint: Int): Int { + val type = Character.getType(codePoint) + if (type == Character.NON_SPACING_MARK.toInt() || + type == Character.ENCLOSING_MARK.toInt() || + type == Character.FORMAT.toInt() + ) { + return 0 + } + + return when { + codePoint == 0 -> 0 + codePoint < 32 || codePoint in 0x7F..0x9F -> 0 + isWideCodePoint(codePoint) -> 2 + else -> 1 + } + } + + private fun isWideCodePoint(codePoint: Int): Boolean { + return codePoint in 0x1100..0x115F || + codePoint in 0x2329..0x232A || + codePoint in 0x2E80..0xA4CF || + codePoint in 0xAC00..0xD7A3 || + codePoint in 0xF900..0xFAFF || + codePoint in 0xFE10..0xFE19 || + codePoint in 0xFE30..0xFE6F || + codePoint in 0xFF00..0xFF60 || + codePoint in 0xFFE0..0xFFE6 || + codePoint in 0x1F300..0x1FAFF || + codePoint in 0x20000..0x3FFFD + } + + private fun consumeEscapeSequence() { + while (terminal.reader().read(1L) != -1) { + // Drop the rest of the currently available escape sequence. + } + } + + private sealed interface ChatScreenEvent { + data class Interaction(val event: InteractionEvent) : ChatScreenEvent + data class SystemMessage(val message: String) : ChatScreenEvent + } + + private companion object { + private const val CTRL_C = 3 + private const val CTRL_D = 4 + private const val BACKSPACE = 8 + private const val DELETE = 127 + private const val ENTER = 10 + private const val CARRIAGE_RETURN = 13 + private const val ESCAPE = 27 + private const val PRINTABLE_START = 32 + private const val DEFAULT_TERMINAL_WIDTH = 80 + } +} diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/WebSocketClient.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/WebSocketClient.kt new file mode 100644 index 00000000..dc6b297b --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/WebSocketClient.kt @@ -0,0 +1,133 @@ +package work.slhaf.partner.ctl.commands.chat + +import kotlinx.serialization.json.* +import work.slhaf.partner.api.* +import work.slhaf.partner.api.InteractionEvent.EventStatus +import java.net.URI +import java.net.http.HttpClient +import java.net.http.WebSocket +import java.time.Duration +import java.util.concurrent.CompletionStage + +class WebSocketClient( + val url: String, + val onResponse: (event: InteractionEvent) -> Unit +) : AutoCloseable { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + private val httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build() + + private val listener = Listener(::handleMessage) + + private val webSocket = httpClient.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .buildAsync(URI.create(url), listener) + + fun send(inputData: InputData) { + val socket = webSocket.join() + socket.sendText(inputData.toJson(), true).join() + } + + override fun close() { + if (!webSocket.isDone) { + webSocket.cancel(true) + return + } + + runCatching { + webSocket.join().sendClose(WebSocket.NORMAL_CLOSURE, "bye").join() + } + } + + private fun handleMessage(text: String) { + val event = parseInteractionEvent(text) ?: return + onResponse(event) + } + + private fun parseInteractionEvent(text: String): InteractionEvent? { + val obj = runCatching { json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return null + val status = obj.string("status")?.let { runCatching { EventStatus.valueOf(it) }.getOrNull() } ?: return null + val target = obj.string("target") ?: return null + + return when (obj.string("event")) { + "REPLY" -> ReplyEvent( + status = status, + target = target, + content = obj.string("content") ?: "", + mode = obj.string("mode") + ?.let { runCatching { ReplyEvent.ContentMode.valueOf(it) }.getOrNull() } + ?: ReplyEvent.ContentMode.REPLACE, + seq = obj.long("seq") + ).withMetaFrom(obj) + + "SYSTEM" -> SystemEvent( + status = status, + target = target, + title = obj.string("title") ?: "", + content = obj.string("content") ?: "", + urgency = obj.string("urgency") + ?.let { runCatching { SystemEvent.Urgency.valueOf(it) }.getOrNull() } + ?: SystemEvent.Urgency.NORMAL + ).withMetaFrom(obj) + + "MODULE" -> ModuleEvent( + status = status, + target = target, + data = obj["data"]?.jsonObject?.let { data -> + ModuleEvent.Data( + module = data.string("module") ?: "", + content = data.string("content") ?: "" + ) + } ?: ModuleEvent.Data(module = "", content = "") + ).withMetaFrom(obj) + + else -> null + } + } + + private fun InputData.toJson(): String = buildJsonObject( + "source" to JsonPrimitive(source), + "content" to JsonPrimitive(content), + "meta" to JsonObject(meta.mapValues { JsonPrimitive(it.value) }) + ).toString() + + private fun T.withMetaFrom(obj: JsonObject): T { + obj["meta"]?.jsonObject?.forEach { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { addMeta(key, it) } + } + return this + } + + private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull + + private fun JsonObject.long(key: String): Long? = this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull() + + private fun buildJsonObject(vararg values: Pair): JsonObject = JsonObject(values.toMap()) + + private class Listener( + private val onMessage: (String) -> Unit + ) : WebSocket.Listener { + private val buffer = StringBuilder() + + override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*>? { + buffer.append(data) + if (last) { + val text = buffer.toString() + buffer.setLength(0) + onMessage(text) + } + webSocket.request(1) + return null + } + + override fun onOpen(webSocket: WebSocket) { + webSocket.request(1) + } + } +} diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt index 0b34e1a9..269f1b95 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt @@ -12,6 +12,22 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +data class PromptPart( + val text: String, + val style: PromptStyle = PromptStyle.PLAIN, +) + +enum class PromptStyle { + PLAIN, + DIM, + BOLD, + CYAN, + GREEN, + YELLOW, + RED, + BLUE, +} + class Prompt private constructor( private val terminal: Terminal, private val reader: LineReader, @@ -56,8 +72,31 @@ class Prompt private constructor( private fun questionPrefix() = cyan("?") private fun promptLabel(label: String, defaultValue: String? = null): String { - val suffix = if (defaultValue != null) " ${dim("[$defaultValue]")}" else "" - return "${questionPrefix()} $label$suffix: " + return renderPrompt( + buildList { + add(PromptPart("?", PromptStyle.CYAN)) + add(PromptPart(" $label", PromptStyle.PLAIN)) + if (defaultValue != null) { + add(PromptPart(" [$defaultValue]", PromptStyle.DIM)) + } + add(PromptPart(": ", PromptStyle.PLAIN)) + } + ) + } + + private fun renderPrompt(parts: List): String { + return parts.joinToString(separator = "") { part -> + when (part.style) { + PromptStyle.PLAIN -> part.text + PromptStyle.DIM -> dim(part.text) + PromptStyle.BOLD -> bold(part.text) + PromptStyle.CYAN -> cyan(part.text) + PromptStyle.GREEN -> green(part.text) + PromptStyle.YELLOW -> yellow(part.text) + PromptStyle.RED -> red(part.text) + PromptStyle.BLUE -> blue(part.text) + } + } } fun print(message: String) { @@ -471,6 +510,10 @@ class Prompt private constructor( terminal.writer().flush() } + fun readLine(parts: List): String { + return readLine(renderPrompt(parts)) + } + private fun readLine(prompt: String): String { return readLine(reader, prompt) } diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties index a8e496cd..40043c70 100644 --- a/PartnerCtl/src/main/resources/i18n/messages.properties +++ b/PartnerCtl/src/main/resources/i18n/messages.properties @@ -8,7 +8,7 @@ cli.shutdown.option.force.description=Forcefully kill matching Partner process i cli.log.description=Show Partner logs. cli.log.option.tail.description=Number of log lines to show before exiting or following. cli.log.option.follow.description=Follow appended log output. -cli.chat.description=Start an interactive chat demo. +cli.chat.description=Start an interactive chat client. cli.config.description=Manage Partner configuration. cli.module.description=Manage Partner modules. diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties index 7f4db2b7..04c876bc 100644 --- a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties +++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties @@ -8,7 +8,7 @@ cli.shutdown.option.force.description=如果匹配的 Partner 进程没有在超 cli.log.description=查看 Partner 日志。 cli.log.option.tail.description=退出或 follow 前显示的日志行数。 cli.log.option.follow.description=持续跟随新增日志输出。 -cli.chat.description=启动交互式聊天 demo。 +cli.chat.description=启动交互式聊天客户端。 cli.config.description=管理 Partner 配置。 cli.module.description=管理 Partner 模块。