整合 ChatGPT 到 EinkBro 中

Daniel Kao
EinkBro
Published in
8 min readMay 5, 2023
  • 找到合適的 OpenAI 函式庫
  • 包裝成 Repository 和 ViewModel
  • 可以拖拉的對話框
  • 新增設定介面

光是請 ChatpGPT 來幫忙寫 code 已經無法滿足我了,下一步是將 ChatGPT 整合進 EinkBro 中,讓使用者自己決定怎麼跟它互動。

目前的構想是:使用者可以先針對網頁選一段文字,然後再跟 ChatGPT 說你想要對這段文字做什麼事,看是要請它解釋給你聽,翻譯成其他語言,或是抓出其中的重點等等。

找到合適的 OpenAI 函式庫

這點倒是沒有花太多時間在上頭,反正 ChatGPT 的 API 沒有很複雜,它沒有提供認證的流程,使用者必須事前取得一把 key 才行。有了 key 之後,就只是單純的一支 API call。

後來我串接的是 com.aallam.open:openai-client:3.2.0 。

包裝成 Repository 和 ViewModel

因為 OpenAI 函式庫已經把 repository 要做的工作做完了,所以實際上我並沒有實作 repository layer,而是直接在 ViewModel 中生成 OpenAI instance。

class GptViewModel : ViewModel(), KoinComponent {
private val config: ConfigManager by inject()
private val openai: OpenAI by lazy { OpenAI(config.gptApiKey) }
...
}

ViewModel 中最重要的,也是唯一真正有呼叫 API 的函式是 query()。這邊寫死是使用 gpt-3.5-turbo 模型,雖然我最近終於排到了 gpt-4 model 的 API 使用權,但…太貴了,所以還是先用用 3.5 版本的就好。

    fun query(userMessage: String? = null) {
if (userMessage != null) {
_inputMessage.value = userMessage
}

val messages = mutableListOf<ChatMessage>()
if (config.gptSystemPrompt.isNotBlank()) {
messages.add(config.gptSystemPrompt.toSystemMessage())
}
messages.add("${config.gptUserPromptPrefix}${_inputMessage.value}".toUserMessage())

val chatCompletionRequest = ChatCompletionRequest(
model = ModelId("gpt-3.5-turbo"),
messages = messages
)

viewModelScope.launch(Dispatchers.IO) {
val response = openai.chatCompletion(chatCompletionRequest)
_responseMessage.value = response.choices.first().message?.content ?: ""
}
}

可拖拉移動的對話框

在網頁上選取文字後,Android 系統會跳出 ActionMode menu;這個選單是系統在控制何時出現,以及會呈現哪些選項供使用者使用。在此,我打算完全捨棄系統的 menu,並改成我實作的 Context Menu。關於實作細節,今天不在這裡展開,我們先來講講,當 Context Menu 上的 GPT 按鈕被點擊後的處理方式。

我建立了一個 GPTDialogFragment 來顯示 ChatGPT 的處理結果。在建構子 (constructor) 中塞了一個 anchorPoint 的參數。這參數是從 BrowserActivity 點擊時取得的,可以用來決定 DialogFragment 出現時能夠顯示在手指點擊的附近。

而 setupDialogPosition() 就是依照 anchorPoint 的指示,把對話框移到合適的位子。

class GPTDialogFragment(
private val gptViewModel: GptViewModel,
private val anchorPoint: Point,
) : ComposeDialogFragment() {
...
private fun setupDialogPosition(position: Point) {
val window = dialog?.window ?: return
window.setGravity(Gravity.TOP or Gravity.LEFT)

if (position.isValid()) {
val params = window.attributes.apply {
x = position.x
y = position.y
}
window.attributes = params
}
}
}

一開始,顯示的位置對是對了,但如果想要在對話框還沒關閉前,可以把它移到別的位置的話,要怎麼做呢?這個需求不光是 GPT 的對話框有需求,剛剛提到的 custom ActionMode context menu 也需要這功能。所以,我把這功能抽到了一個 base 的 DraggableComposeDialogFragment.kt

首先,如同 GPT 對話框,它要能被指定一個起始的位置,所以這裡實作了 setupDialogPosition() 。

abstract class DraggableComposeDialogFragment: ComposeDialogFragment() {
private var initialTouchX: Float = 0f
private var initialTouchY: Float = 0f
private var initialX: Int = 0
private var initialY: Int = 0

@SuppressLint("ClickableViewAccessibility")
protected fun setupDialogPosition(position: Point) {
val window = dialog?.window ?: return
window.setGravity(Gravity.TOP or Gravity.LEFT)

if (position.isValid()) {
val params = window.attributes.apply {
x = position.x
y = position.y
}
window.attributes = params
}

supportDragToMove(window)
}
private fun Point.isValid() = x != 0 && y != 0
...
}

再來是處理使用者手指點在對話框上移動時的邏輯。老實說,這整個 Class 都是 ChatGPT 提供給我的。

private fun supportDragToMove(window: Window) {
val windowManager =
requireContext().getSystemService(Context.WINDOW_SERVICE) as WindowManager
window.decorView.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// Get the initial touch position and dialog window position
initialTouchX = event.rawX
initialTouchY = event.rawY
initialX = window.attributes.x
initialY = window.attributes.y
true
}

MotionEvent.ACTION_MOVE -> {
// Calculate the new position of the dialog window
val newX = initialX + (event.rawX - initialTouchX).toInt()
val newY = initialY + (event.rawY - initialTouchY).toInt()

// Update the position of the dialog window
window.attributes.x = newX
window.attributes.y = newY
windowManager.updateViewLayout(window.decorView, window.attributes)
true
}

else -> false
}
}
}

新增設定選項

這部分就不細講了。自從設定介面改用 Jetpack Compose 實作後,要新增設定的內容就單純多了。

目前的作法是,如果使用者有在設定中輸入 ChatGPT API key 的話,當選取文字後,就會看到 ChatGPT 的按鈕;反之則無。

圖片

--

--

Daniel Kao
EinkBro

2023 年新書出版! Android 開源專案「真」實戰啟航:瀏覽器 App EinkBro 開發者帶你逐步從 UI 設計、UX 提升到多功能實現秘技全解析