完善请求
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
@@ -11,7 +12,8 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.MyApplication">
|
android:theme="@style/Theme.MyApplication"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
|
||||||
<!-- 启动页 Activity -->
|
<!-- 启动页 Activity -->
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -20,16 +20,23 @@ import android.view.WindowManager
|
|||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.text.Editable
|
||||||
|
|
||||||
class GuideActivity : AppCompatActivity() {
|
class GuideActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var scrollView: NestedScrollView
|
private lateinit var scrollView: NestedScrollView
|
||||||
private lateinit var listLayout: LinearLayout
|
private lateinit var listLayout: LinearLayout
|
||||||
private lateinit var inputMessage: EditText
|
private lateinit var inputMessage: EditText
|
||||||
private lateinit var btnSend: Button
|
private lateinit var btnSend: ImageView
|
||||||
private lateinit var itemAnim: Animation
|
private lateinit var itemAnim: Animation
|
||||||
private lateinit var bottomPanel: LinearLayout
|
private lateinit var bottomPanel: LinearLayout
|
||||||
|
private lateinit var hintLayout: LinearLayout
|
||||||
|
private lateinit var titleTextView: TextView
|
||||||
|
|
||||||
|
|
||||||
// 我方的预设回复
|
// 我方的预设回复
|
||||||
private val replyData = listOf(
|
private val replyData = listOf(
|
||||||
"你好",
|
"你好",
|
||||||
@@ -51,7 +58,24 @@ class GuideActivity : AppCompatActivity() {
|
|||||||
inputMessage = findViewById(R.id.input_message)
|
inputMessage = findViewById(R.id.input_message)
|
||||||
btnSend = findViewById(R.id.btn_send)
|
btnSend = findViewById(R.id.btn_send)
|
||||||
bottomPanel = findViewById(R.id.bottom_panel)
|
bottomPanel = findViewById(R.id.bottom_panel)
|
||||||
|
hintLayout = findViewById(R.id.hintLayout)
|
||||||
|
titleTextView = findViewById(R.id.title)
|
||||||
val rootView = findViewById<View>(R.id.rootCoordinator)
|
val rootView = findViewById<View>(R.id.rootCoordinator)
|
||||||
|
|
||||||
|
inputMessage.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||||
|
// 不需要实现
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||||
|
// 不需要实现
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
hintLayout.visibility =
|
||||||
|
if (s.isNullOrEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
})
|
||||||
// 动画
|
// 动画
|
||||||
itemAnim = AnimationUtils.loadAnimation(this, R.anim.item_slide_in_up)
|
itemAnim = AnimationUtils.loadAnimation(this, R.anim.item_slide_in_up)
|
||||||
//自动聚焦
|
//自动聚焦
|
||||||
@@ -139,6 +163,10 @@ class GuideActivity : AppCompatActivity() {
|
|||||||
sendMessage()
|
sendMessage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dp2px(dp: Int): Int {
|
||||||
|
return (dp * resources.displayMetrics.density).toInt()
|
||||||
|
}
|
||||||
// 发送消息
|
// 发送消息
|
||||||
private fun sendMessage() {
|
private fun sendMessage() {
|
||||||
val text = inputMessage.text.toString().trim()
|
val text = inputMessage.text.toString().trim()
|
||||||
@@ -155,12 +183,21 @@ class GuideActivity : AppCompatActivity() {
|
|||||||
inputMessage.setText("")
|
inputMessage.setText("")
|
||||||
|
|
||||||
val replyText = replyData.random()
|
val replyText = replyData.random()
|
||||||
|
|
||||||
|
// 保存原来的标题文本
|
||||||
|
val originalTitle = titleTextView.text.toString()
|
||||||
|
|
||||||
// 延迟执行我方回复
|
// 延迟执行我方回复
|
||||||
scrollView.postDelayed({
|
scrollView.postDelayed({
|
||||||
|
// 先恢复标题文本
|
||||||
|
titleTextView.text = originalTitle
|
||||||
|
// 然后添加我方回复
|
||||||
addOurMessage(replyText)
|
addOurMessage(replyText)
|
||||||
}, 500)
|
}, 1500)
|
||||||
|
|
||||||
|
scrollView.postDelayed({
|
||||||
|
titleTextView.text = "The other party is typing..."
|
||||||
|
}, 500)
|
||||||
inputMessage.isFocusable = true
|
inputMessage.isFocusable = true
|
||||||
inputMessage.isFocusableInTouchMode = true
|
inputMessage.isFocusableInTouchMode = true
|
||||||
|
|
||||||
|
|||||||
@@ -483,19 +483,35 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
|
|||||||
// 发送(标准 SEND + 回车 fallback)
|
// 发送(标准 SEND + 回车 fallback)
|
||||||
override fun performSendAction() {
|
override fun performSendAction() {
|
||||||
val ic = currentInputConnection ?: return
|
val ic = currentInputConnection ?: return
|
||||||
|
val info = currentInputEditorInfo
|
||||||
// 1. 尝试执行标准发送动作(IME_ACTION_SEND)
|
|
||||||
val handled = ic.performEditorAction(EditorInfo.IME_ACTION_SEND)
|
var handled = false
|
||||||
|
|
||||||
if (!handled) {
|
if (info != null) {
|
||||||
// 2. 如果输入框不支持 SEND,则退回到插入换行
|
// 取出当前 EditText 声明的 action
|
||||||
ic.commitText("\n", 1)
|
val actionId = info.imeOptions and EditorInfo.IME_MASK_ACTION
|
||||||
|
|
||||||
|
// 只有当它明确是 IME_ACTION_SEND 时,才当“发送”用
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||||
|
handled = ic.performEditorAction(actionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果当前输入框不支持 SEND 或者 performEditorAction 返回了 false
|
||||||
|
// 就降级为“标准回车”
|
||||||
|
if (!handled) {
|
||||||
|
sendEnterKey(ic)
|
||||||
|
}
|
||||||
|
|
||||||
playKeyClick()
|
playKeyClick()
|
||||||
clearEditorState()
|
clearEditorState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sendEnterKey(ic: InputConnection) {
|
||||||
|
// 按下+抬起 KEYCODE_ENTER
|
||||||
|
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
|
||||||
|
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
|
||||||
|
}
|
||||||
|
|
||||||
// 按键音效
|
// 按键音效
|
||||||
override fun playKeyClick() {
|
override fun playKeyClick() {
|
||||||
|
|||||||
@@ -13,11 +13,173 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.example.myapplication.MainActivity
|
import com.example.myapplication.MainActivity
|
||||||
import com.example.myapplication.theme.ThemeManager
|
import com.example.myapplication.theme.ThemeManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import com.example.myapplication.network.NetworkClient
|
||||||
|
import com.example.myapplication.network.LlmStreamCallback
|
||||||
|
import okhttp3.Call
|
||||||
|
|
||||||
class AiKeyboard(
|
class AiKeyboard(
|
||||||
env: KeyboardEnvironment
|
env: KeyboardEnvironment
|
||||||
) : BaseKeyboard(env) {
|
) : BaseKeyboard(env) {
|
||||||
|
|
||||||
|
private var currentStreamCall: Call? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private val messagesContainer: LinearLayout by lazy {
|
||||||
|
val res = env.ctx.resources
|
||||||
|
val id = res.getIdentifier("container_messages", "id", env.ctx.packageName)
|
||||||
|
rootView.findViewById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val messagesScrollView: ScrollView by lazy {
|
||||||
|
val res = env.ctx.resources
|
||||||
|
val id = res.getIdentifier("scroll_messages", "id", env.ctx.packageName)
|
||||||
|
rootView.findViewById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前正在流式更新的那一个 AI 文本
|
||||||
|
private var currentAssistantTextView: TextView? = null
|
||||||
|
|
||||||
|
// 用来处理 <SPLIT> 的缓冲
|
||||||
|
private val streamBuffer = StringBuilder()
|
||||||
|
|
||||||
|
|
||||||
|
//新建一条 AI 消息(空内容),返回里面的 TextView 用来后续流式更新
|
||||||
|
|
||||||
|
private fun addAssistantMessage(initialText: String = ""): TextView {
|
||||||
|
val inflater = env.layoutInflater
|
||||||
|
val res = env.ctx.resources
|
||||||
|
val layoutId = res.getIdentifier("item_ai_message", "layout", env.ctx.packageName)
|
||||||
|
|
||||||
|
val itemView = inflater.inflate(layoutId, messagesContainer, false) as LinearLayout
|
||||||
|
val tv = itemView.findViewById<TextView>(
|
||||||
|
res.getIdentifier("tv_content", "id", env.ctx.packageName)
|
||||||
|
)
|
||||||
|
tv.text = initialText
|
||||||
|
messagesContainer.addView(itemView)
|
||||||
|
|
||||||
|
scrollToBottom()
|
||||||
|
return tv
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (可选)如果你也想显示用户提问
|
||||||
|
*/
|
||||||
|
private fun addUserMessage(text: String) {
|
||||||
|
// 简单写:复用同一个 item 布局
|
||||||
|
val tv = addAssistantMessage(text)
|
||||||
|
// 这里可以改成设置 gravity、背景区分用户/AI 等
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scrollToBottom() {
|
||||||
|
// 延迟一点点执行,保证 addView 完成后再滚动
|
||||||
|
messagesScrollView.post {
|
||||||
|
messagesScrollView.fullScroll(View.FOCUS_DOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//后端每来一个 llm_chunk 的 data,就调用一次这个方法
|
||||||
|
private fun onLlmChunk(data: String) {
|
||||||
|
// 丢掉 data=":\n\n" 这条
|
||||||
|
if (data == ":\n\n") return
|
||||||
|
|
||||||
|
// 确保在主线程更新 UI
|
||||||
|
mainHandler.post {
|
||||||
|
// 如果还没有正在流式的 TextView,就新建一条 AI 消息
|
||||||
|
if (currentAssistantTextView == null) {
|
||||||
|
currentAssistantTextView = addAssistantMessage("")
|
||||||
|
streamBuffer.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 累积到缓冲区
|
||||||
|
streamBuffer.append(data)
|
||||||
|
|
||||||
|
// 先整体把 ":\n\n" 删掉(以防万一有别的地方混进来)
|
||||||
|
var text = streamBuffer.toString().replace(":\n\n", "")
|
||||||
|
|
||||||
|
// 处理 <SPLIT>:代表下一句/下一条消息
|
||||||
|
val splitTag = "<SPLIT>"
|
||||||
|
var index = text.indexOf(splitTag)
|
||||||
|
|
||||||
|
while (index != -1) {
|
||||||
|
// split 前面这一段是上一条消息的最终内容
|
||||||
|
val before = text.substring(0, index)
|
||||||
|
currentAssistantTextView?.text = before
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
// 开启下一条 AI 消息
|
||||||
|
currentAssistantTextView = addAssistantMessage("")
|
||||||
|
|
||||||
|
// 剩下的留给下一轮
|
||||||
|
text = text.substring(index + splitTag.length)
|
||||||
|
index = text.indexOf(splitTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环结束后 text 就是「当前这条消息的未完成尾巴」
|
||||||
|
currentAssistantTextView?.text = text
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
// 缓冲区只保留尾巴(避免无限变长)
|
||||||
|
streamBuffer.clear()
|
||||||
|
streamBuffer.append(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 收到 type="done" 时调用,表示这一轮回答结束
|
||||||
|
private fun onLlmDone() {
|
||||||
|
mainHandler.post {
|
||||||
|
// 这里目前不需要做太多事,必要的话可以清掉 buffer
|
||||||
|
streamBuffer.clear()
|
||||||
|
currentAssistantTextView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 开始一次新的 AI 回答流式请求
|
||||||
|
fun startAiStream(userQuestion: String) {
|
||||||
|
// 可选:先把用户问题显示出来
|
||||||
|
addUserMessage(userQuestion)
|
||||||
|
|
||||||
|
// 如果之前有没结束的流,先取消
|
||||||
|
currentStreamCall?.cancel()
|
||||||
|
|
||||||
|
currentStreamCall = NetworkClient.startLlmStream(
|
||||||
|
question = userQuestion,
|
||||||
|
callback = object : LlmStreamCallback {
|
||||||
|
override fun onEvent(type: String, data: String?) {
|
||||||
|
when (type) {
|
||||||
|
"llm_chunk" -> {
|
||||||
|
if (data != null) {
|
||||||
|
onLlmChunk(data) // 这里就是之前写的流式 UI 更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"done" -> {
|
||||||
|
onLlmDone() // 一轮结束
|
||||||
|
}
|
||||||
|
"search_result" -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(t: Throwable) {
|
||||||
|
addAssistantMessage("出错了:${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 比如键盘关闭时可以调用一次,避免内存泄漏 / 多余请求
|
||||||
|
fun cancelAiStream() {
|
||||||
|
currentStreamCall?.cancel()
|
||||||
|
currentStreamCall = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 以下是 BaseKeyboard 的实现
|
||||||
override val rootView: View = run {
|
override val rootView: View = run {
|
||||||
val res = env.ctx.resources
|
val res = env.ctx.resources
|
||||||
val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName)
|
val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName)
|
||||||
@@ -93,6 +255,17 @@ class AiKeyboard(
|
|||||||
val res = env.ctx.resources
|
val res = env.ctx.resources
|
||||||
val pkg = env.ctx.packageName
|
val pkg = env.ctx.packageName
|
||||||
|
|
||||||
|
// 获取ai_persona和ai_output视图引用
|
||||||
|
val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
|
||||||
|
val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
|
||||||
|
|
||||||
|
val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById<View?>(aiPersonaId) else null
|
||||||
|
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
|
||||||
|
|
||||||
|
// 初始化显示状态:显示ai_persona,隐藏ai_output
|
||||||
|
aiPersonaView?.visibility = View.VISIBLE
|
||||||
|
aiOutputView?.visibility = View.GONE
|
||||||
|
|
||||||
// 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc,就绑定一下
|
// 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc,就绑定一下
|
||||||
val backId = res.getIdentifier("key_abc", "id", pkg)
|
val backId = res.getIdentifier("key_abc", "id", pkg)
|
||||||
if (backId != 0) {
|
if (backId != 0) {
|
||||||
@@ -108,6 +281,59 @@ class AiKeyboard(
|
|||||||
navigateToRechargeFragment()
|
navigateToRechargeFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//显示切换
|
||||||
|
val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
|
||||||
|
if (returnButtonId != 0) {
|
||||||
|
rootView.findViewById<View?>(returnButtonId)?.let { returnButton ->
|
||||||
|
// 确保按钮可点击且可获得焦点,防止事件穿透
|
||||||
|
returnButton.isClickable = true
|
||||||
|
returnButton.isFocusable = true
|
||||||
|
returnButton.setOnClickListener {
|
||||||
|
// 点击Return_keyboard:先隐藏ai_output,再显示ai_persona(顺序动画)
|
||||||
|
aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
|
||||||
|
aiOutputView?.visibility = View.GONE
|
||||||
|
// 等ai_output完全隐藏后再显示ai_persona
|
||||||
|
aiPersonaView?.visibility = View.VISIBLE
|
||||||
|
aiPersonaView?.alpha = 0f
|
||||||
|
aiPersonaView?.animate()?.alpha(1f)?.setDuration(150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardButtonId = res.getIdentifier("card", "id", pkg)
|
||||||
|
if (cardButtonId != 0) {
|
||||||
|
rootView.findViewById<View?>(cardButtonId)?.setOnClickListener {
|
||||||
|
// 点击card:先隐藏ai_persona,再显示ai_output(顺序动画)
|
||||||
|
aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
|
||||||
|
aiPersonaView?.visibility = View.GONE
|
||||||
|
// 等ai_persona完全隐藏后再显示ai_output
|
||||||
|
aiOutputView?.visibility = View.VISIBLE
|
||||||
|
aiOutputView?.alpha = 0f
|
||||||
|
aiOutputView?.animate()?.alpha(1f)?.setDuration(150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// // 假设 ai_keyboard.xml 里有一个发送按钮 key_send
|
||||||
|
// val sendId = res.getIdentifier("key_send", "id", pkg)
|
||||||
|
// val inputId = res.getIdentifier("et_prompt", "id", pkg) // 假设这是你的输入框 id
|
||||||
|
|
||||||
|
// if (sendId != 0 && inputId != 0) {
|
||||||
|
// val inputView = rootView.findViewById<TextView?>(inputId)
|
||||||
|
|
||||||
|
// rootView.findViewById<View?>(sendId)?.setOnClickListener {
|
||||||
|
// val question = inputView?.text?.toString()?.trim().orEmpty()
|
||||||
|
// if (question.isNotEmpty()) {
|
||||||
|
// startAiStream(question)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToRechargeFragment() {
|
private fun navigateToRechargeFragment() {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// ApiResponse.kt
|
// ApiResponse.kt
|
||||||
package com.example.network
|
package com.example.myapplication.network
|
||||||
|
|
||||||
data class ApiResponse<T>(
|
data class ApiResponse<T>(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
val msg: String,
|
val message: String,
|
||||||
val data: T?
|
val data: T?
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 请求方法
|
// 请求方法
|
||||||
package com.example.network
|
package com.example.myapplication.network
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
@@ -8,31 +8,24 @@ import retrofit2.http.*
|
|||||||
interface ApiService {
|
interface ApiService {
|
||||||
|
|
||||||
// GET 示例:/users/{id}
|
// GET 示例:/users/{id}
|
||||||
@GET("users/{id}")
|
// @GET("users/{id}")
|
||||||
suspend fun getUser(
|
// suspend fun getUser(
|
||||||
@Path("id") id: String
|
// @Path("id") id: String
|
||||||
): ApiResponse<User>
|
// ): ApiResponse<User>
|
||||||
|
|
||||||
// GET 示例:带查询参数 /users?page=1&pageSize=20
|
// GET 示例:带查询参数 /users?page=1&pageSize=20
|
||||||
@GET("users")
|
// @GET("users")
|
||||||
suspend fun getUsers(
|
// suspend fun getUsers(
|
||||||
@Query("page") page: Int,
|
// @Query("page") page: Int,
|
||||||
@Query("pageSize") pageSize: Int
|
// @Query("pageSize") pageSize: Int
|
||||||
): ApiResponse<List<User>>
|
// ): ApiResponse<List<User>>
|
||||||
|
|
||||||
// POST JSON 示例:Body 为 JSON:{"username": "...", "password": "..."}
|
// POST JSON 示例:Body 为 JSON:{"username": "...", "password": "..."}
|
||||||
@POST("auth/login")
|
@POST("user/login")
|
||||||
suspend fun login(
|
suspend fun login(
|
||||||
@Body body: LoginRequest
|
@Body body: LoginRequest
|
||||||
): ApiResponse<LoginResponse>
|
): ApiResponse<LoginResponse>
|
||||||
|
|
||||||
// POST 表单示例:x-www-form-urlencoded
|
|
||||||
@FormUrlEncoded
|
|
||||||
@POST("auth/loginForm")
|
|
||||||
suspend fun loginForm(
|
|
||||||
@Field("username") username: String,
|
|
||||||
@Field("password") password: String
|
|
||||||
): ApiResponse<LoginResponse>
|
|
||||||
|
|
||||||
// zip 文件下载(或其它大文件)——必须 @Streaming
|
// zip 文件下载(或其它大文件)——必须 @Streaming
|
||||||
@Streaming
|
@Streaming
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// zip 文件下载器
|
// zip 文件下载器
|
||||||
package com.example.network
|
package com.example.myapplication.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 定义请求 & 响应拦截器
|
// 定义请求 & 响应拦截器
|
||||||
package com.example.network
|
package com.example.myapplication.network
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
@@ -11,17 +11,56 @@ import okhttp3.ResponseBody.Companion.toResponseBody
|
|||||||
*/
|
*/
|
||||||
val requestInterceptor = Interceptor { chain ->
|
val requestInterceptor = Interceptor { chain ->
|
||||||
val original = chain.request()
|
val original = chain.request()
|
||||||
val token = "your_token" // 这里换成你自己的 token
|
|
||||||
|
val token = "" // 你的 token
|
||||||
|
|
||||||
val newRequest = original.newBuilder()
|
val newRequest = original.newBuilder()
|
||||||
.addHeader("Accept", "application/json")
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
// 这里加你自己的 token,如果没有就注释掉
|
|
||||||
.addHeader("Authorization", "Bearer $token")
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.addHeader("Accept-Language", "lang")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
chain.proceed(newRequest)
|
// ====== 打印请求信息 ======
|
||||||
|
val request = newRequest
|
||||||
|
val url = request.url
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("\n======== HTTP Request ========\n")
|
||||||
|
sb.append("Method: ${request.method}\n")
|
||||||
|
sb.append("URL: $url\n")
|
||||||
|
|
||||||
|
// 打印 Header
|
||||||
|
sb.append("Headers:\n")
|
||||||
|
for (name in request.headers.names()) {
|
||||||
|
sb.append(" $name: ${request.header(name)}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印 Query 参数
|
||||||
|
if (url.querySize > 0) {
|
||||||
|
sb.append("Query Params:\n")
|
||||||
|
for (i in 0 until url.querySize) {
|
||||||
|
sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印 Body(仅限可读类型)
|
||||||
|
val requestBody = request.body
|
||||||
|
if (requestBody != null) {
|
||||||
|
val buffer = okio.Buffer()
|
||||||
|
requestBody.writeTo(buffer)
|
||||||
|
|
||||||
|
sb.append("Body:\n")
|
||||||
|
sb.append(buffer.readUtf8())
|
||||||
|
sb.append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("================================\n")
|
||||||
|
|
||||||
|
android.util.Log.d("1314520-OkHttp-Request", sb.toString())
|
||||||
|
|
||||||
|
chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 响应拦截器:统一打印日志、做一些简单的错误处理
|
* 响应拦截器:统一打印日志、做一些简单的错误处理
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +75,7 @@ val responseInterceptor = Interceptor { chain ->
|
|||||||
val bodyString = rawBody?.string() ?: ""
|
val bodyString = rawBody?.string() ?: ""
|
||||||
|
|
||||||
Log.d(
|
Log.d(
|
||||||
"HTTP",
|
"1314520-HTTP",
|
||||||
"⬇⬇⬇\n" +
|
"⬇⬇⬇\n" +
|
||||||
"URL : ${request.url}\n" +
|
"URL : ${request.url}\n" +
|
||||||
"Method: ${request.method}\n" +
|
"Method: ${request.method}\n" +
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.myapplication.network
|
||||||
|
|
||||||
|
interface LlmStreamCallback {
|
||||||
|
fun onEvent(type: String, data: String?)
|
||||||
|
fun onError(t: Throwable)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Models.kt
|
// Models.kt
|
||||||
package com.example.network
|
package com.example.myapplication.network
|
||||||
|
|
||||||
data class User(
|
data class User(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -7,8 +7,9 @@ data class User(
|
|||||||
val age: Int
|
val age: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 登录
|
||||||
data class LoginRequest(
|
data class LoginRequest(
|
||||||
val username: String,
|
val mail: String,
|
||||||
val password: String
|
val password: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.example.myapplication.network
|
||||||
|
|
||||||
|
import okhttp3.*
|
||||||
|
import okio.Buffer
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
|
||||||
|
object NetworkClient {
|
||||||
|
|
||||||
|
// 你自己后端的 base url
|
||||||
|
private const val BASE_URL = "http://192.168.2.21:7529/api"
|
||||||
|
|
||||||
|
// 专门用于 SSE 的 OkHttpClient:readTimeout = 0 代表不超时,一直保持连接
|
||||||
|
private val sseClient: OkHttpClient by lazy {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须不能有读超时
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动一次 SSE 流式请求
|
||||||
|
* @param question 用户问题(你要传给后端的)
|
||||||
|
* @return Call,可用于取消(比如用户关闭键盘时)
|
||||||
|
*/
|
||||||
|
fun startLlmStream(
|
||||||
|
question: String,
|
||||||
|
callback: LlmStreamCallback
|
||||||
|
): Call {
|
||||||
|
// 根据你后端的接口改:是 POST 还是 GET,参数格式是什么
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("query", question) // 假设你后端字段叫 query
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestBody = json.toString()
|
||||||
|
.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BASE_URL/llm/stream") // TODO: 换成你真实的 SSE 路径
|
||||||
|
.post(requestBody)
|
||||||
|
// 有些 SSE 接口会要求 Accept
|
||||||
|
.addHeader("Accept", "text/event-stream")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = sseClient.newCall(request)
|
||||||
|
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (call.isCanceled()) return // 被主动取消就不用回调错误了
|
||||||
|
callback.onError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
callback.onError(IOException("SSE failed: ${response.code}"))
|
||||||
|
response.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body ?: run {
|
||||||
|
callback.onError(IOException("Empty body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长连接读取:一行一行读,直到服务器关闭或我们取消
|
||||||
|
body.use { b ->
|
||||||
|
val source = b.source()
|
||||||
|
try {
|
||||||
|
while (!source.exhausted() && !call.isCanceled()) {
|
||||||
|
val line = source.readUtf8Line() ?: break
|
||||||
|
if (line.isBlank()) {
|
||||||
|
// SSE 中空行代表一个 event 结束,这里可以忽略
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容两种格式:
|
||||||
|
// 1) 标准 SSE: "data: { ... }"
|
||||||
|
// 2) 服务器直接一行一个 JSON: "{ ... }"
|
||||||
|
val payload = if (line.startsWith("data:")) {
|
||||||
|
line.substringAfter("data:").trim()
|
||||||
|
} else {
|
||||||
|
line.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 你日志里是:
|
||||||
|
// {"type":"llm_chunk","data":"Her"}
|
||||||
|
// {"type":"done","data":null}
|
||||||
|
try {
|
||||||
|
val jsonObj = JSONObject(payload)
|
||||||
|
val type = jsonObj.optString("type")
|
||||||
|
val data =
|
||||||
|
if (jsonObj.has("data") && !jsonObj.isNull("data"))
|
||||||
|
jsonObj.getString("data")
|
||||||
|
else
|
||||||
|
null
|
||||||
|
|
||||||
|
callback.onEvent(type, data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 解析失败就忽略这一行(或者你可以打印下日志)
|
||||||
|
// Log.e("NetworkClient", "Bad SSE line: $payload", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ioe: IOException) {
|
||||||
|
if (!call.isCanceled()) {
|
||||||
|
callback.onError(ioe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// RetrofitClient.kt
|
// RetrofitClient.kt
|
||||||
package com.example.network
|
package com.example.myapplication.network
|
||||||
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
@@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
|
|
||||||
object RetrofitClient {
|
object RetrofitClient {
|
||||||
|
|
||||||
private const val BASE_URL = "https://api.example.com/" // 换成你的地址
|
private const val BASE_URL = "http://192.168.2.21:7529/api/" // 换成你的地址
|
||||||
|
|
||||||
// 日志拦截器(可选)
|
// 日志拦截器(可选)
|
||||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
|
|||||||
@@ -24,24 +24,12 @@ object ThemeManager {
|
|||||||
|
|
||||||
/** 主题根目录:/Android/data/<package>/files/keyboard_themes */
|
/** 主题根目录:/Android/data/<package>/files/keyboard_themes */
|
||||||
private fun getThemeRootDir(context: Context): File =
|
private fun getThemeRootDir(context: Context): File =
|
||||||
File(context.getExternalFilesDir(null), "keyboard_themes")
|
File(context.filesDir, "keyboard_themes")
|
||||||
|
|
||||||
/** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */
|
/** 某个具体主题目录:/Android/.../keyboard_themes/<themeName> */
|
||||||
private fun getThemeDir(context: Context, themeName: String): File =
|
private fun getThemeDir(context: Context, themeName: String): File =
|
||||||
File(getThemeRootDir(context), themeName)
|
File(getThemeRootDir(context), themeName)
|
||||||
|
|
||||||
// ==================== 内置主题拷贝(assets -> 外部目录) ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保 APK 自带的主题(assets/keyboard_themes/...) 已经复制到
|
|
||||||
* /Android/data/.../files/keyboard_themes 目录下。
|
|
||||||
*
|
|
||||||
* 行为:
|
|
||||||
* - 如果主题目录不存在:整套拷贝过去。
|
|
||||||
* - 如果主题目录已经存在:只复制“新增文件”,不会覆盖已有文件。
|
|
||||||
*
|
|
||||||
* 建议在 IME 的 onCreate() 里调用一次。
|
|
||||||
*/
|
|
||||||
fun ensureBuiltInThemesInstalled(context: Context) {
|
fun ensureBuiltInThemesInstalled(context: Context) {
|
||||||
val am = context.assets
|
val am = context.assets
|
||||||
val rootName = "keyboard_themes"
|
val rootName = "keyboard_themes"
|
||||||
|
|||||||
@@ -2,23 +2,31 @@ package com.example.myapplication.ui.login
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import com.example.myapplication.R
|
import com.example.myapplication.R
|
||||||
|
import com.example.myapplication.network.ApiResponse
|
||||||
|
import com.example.myapplication.network.LoginRequest
|
||||||
|
import com.example.myapplication.network.RetrofitClient
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import android.widget.Toast
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class LoginFragment : Fragment() {
|
class LoginFragment : Fragment() {
|
||||||
|
|
||||||
private lateinit var passwordEditText: EditText
|
private lateinit var passwordEditText: EditText
|
||||||
private lateinit var toggleImageView: ImageView
|
private lateinit var toggleImageView: ImageView
|
||||||
private lateinit var loginButton: MaterialButton // 如果你 XML 里有这个按钮 id: btn_login
|
private lateinit var loginButton: TextView
|
||||||
|
private lateinit var emailEditText: EditText
|
||||||
|
|
||||||
private var isPasswordVisible = false
|
private var isPasswordVisible = false
|
||||||
|
|
||||||
@@ -47,8 +55,10 @@ class LoginFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
// 绑定控件(id 必须和 xml 里的一样)
|
// 绑定控件(id 必须和 xml 里的一样)
|
||||||
passwordEditText = view.findViewById(R.id.et_password)
|
passwordEditText = view.findViewById(R.id.et_password)
|
||||||
|
emailEditText = view.findViewById(R.id.et_email)
|
||||||
toggleImageView = view.findViewById(R.id.iv_toggle)
|
toggleImageView = view.findViewById(R.id.iv_toggle)
|
||||||
// loginButton = view.findViewById(R.id.btn_login) // 如果没有这个按钮就把这一行和变量删了
|
loginButton = view.findViewById(R.id.btn_login)
|
||||||
|
|
||||||
|
|
||||||
// 初始是隐藏密码状态
|
// 初始是隐藏密码状态
|
||||||
passwordEditText.inputType =
|
passwordEditText.inputType =
|
||||||
@@ -73,10 +83,30 @@ class LoginFragment : Fragment() {
|
|||||||
passwordEditText.setSelection(passwordEditText.text?.length ?: 0)
|
passwordEditText.setSelection(passwordEditText.text?.length ?: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// // 登录按钮逻辑你自己填
|
// // 登录按钮逻辑
|
||||||
// loginButton.setOnClickListener {
|
loginButton.setOnClickListener {
|
||||||
// val pwd = passwordEditText.text?.toString().orEmpty()
|
val pwd = passwordEditText.text?.toString().orEmpty()
|
||||||
// // TODO: 登录处理
|
val email = emailEditText.text?.toString().orEmpty()
|
||||||
// }
|
if (pwd.isEmpty() || email.isEmpty()) {
|
||||||
|
// 输入框不能为空
|
||||||
|
Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
// 调用登录API
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val loginRequest = LoginRequest(
|
||||||
|
mail = email, // 使用email作为username
|
||||||
|
password = pwd
|
||||||
|
)
|
||||||
|
val response = RetrofitClient.apiService.login(loginRequest)
|
||||||
|
Log.d("1314520-LoginFragment", "登录API响应: $response")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("1314520-LoginFragment", "登录请求失败: ${e.message}", e)
|
||||||
|
Toast.makeText(requireContext(), "登录失败: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,61 @@ import java.io.*
|
|||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
fun unzipToDir(zipInputStream: InputStream, targetDir: File) {
|
|
||||||
|
// @param zipInputStream zip 的输入流(网络/本地/asset都行)
|
||||||
|
// @param targetDir 解压目标目录
|
||||||
|
// @param overwrite 是否覆盖同名文件(默认 true:存在则跳过)
|
||||||
|
|
||||||
|
fun unzipToDir(
|
||||||
|
zipInputStream: InputStream,
|
||||||
|
targetDir: File,
|
||||||
|
overwrite: Boolean = true
|
||||||
|
) {
|
||||||
|
// 确保目标目录存在
|
||||||
|
if (!targetDir.exists()) targetDir.mkdirs()
|
||||||
|
|
||||||
|
// 规范化目标目录路径(用于 Zip Slip 防护)
|
||||||
|
val canonicalDirPath = targetDir.canonicalFile.path + File.separator
|
||||||
|
|
||||||
ZipInputStream(BufferedInputStream(zipInputStream)).use { zis ->
|
ZipInputStream(BufferedInputStream(zipInputStream)).use { zis ->
|
||||||
var entry: ZipEntry? = zis.nextEntry
|
|
||||||
val buffer = ByteArray(4096)
|
val buffer = ByteArray(4096)
|
||||||
|
|
||||||
while (entry != null) {
|
while (true) {
|
||||||
val file = File(targetDir, entry.name)
|
val entry: ZipEntry = zis.nextEntry ?: break
|
||||||
|
|
||||||
|
// entry.name 可能包含 ../ 之类的路径,必须防护
|
||||||
|
val outFile = File(targetDir, entry.name)
|
||||||
|
|
||||||
|
// ===== Zip Slip 防护:确保输出文件仍在 targetDir 内 =====
|
||||||
|
val canonicalOutPath = outFile.canonicalFile.path
|
||||||
|
if (!canonicalOutPath.startsWith(canonicalDirPath)) {
|
||||||
|
// 越界路径:直接跳过
|
||||||
|
zis.closeEntry()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
file.mkdirs()
|
outFile.mkdirs()
|
||||||
} else {
|
} else {
|
||||||
file.parentFile?.mkdirs()
|
outFile.parentFile?.mkdirs()
|
||||||
FileOutputStream(file).use { fos ->
|
|
||||||
var count: Int
|
// 覆盖策略:默认不覆盖(存在就跳过)
|
||||||
while (zis.read(buffer).also { count = it } != -1) {
|
if (outFile.exists() && !overwrite) {
|
||||||
|
zis.closeEntry()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
FileOutputStream(outFile).use { fos ->
|
||||||
|
while (true) {
|
||||||
|
val count = zis.read(buffer)
|
||||||
|
if (count == -1) break
|
||||||
fos.write(buffer, 0, count)
|
fos.write(buffer, 0, count)
|
||||||
}
|
}
|
||||||
|
fos.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
zis.closeEntry()
|
zis.closeEntry()
|
||||||
entry = zis.nextEntry
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
<translate
|
<translate
|
||||||
android:fromYDelta="20%"
|
android:fromYDelta="20%"
|
||||||
android:toYDelta="0"
|
android:toYDelta="0"
|
||||||
android:duration="250" />
|
android:duration="300" />
|
||||||
|
|
||||||
<!-- 透明度动画:从透明到不透明 -->
|
<!-- 透明度动画:从透明到不透明 -->
|
||||||
<alpha
|
<alpha
|
||||||
android:fromAlpha="0"
|
android:fromAlpha="0"
|
||||||
android:toAlpha="1"
|
android:toAlpha="1"
|
||||||
android:duration="250" />
|
android:duration="300" />
|
||||||
|
|
||||||
</set>
|
</set>
|
||||||
|
|||||||
BIN
app/src/main/res/drawable/input_icon.png
Normal file
BIN
app/src/main/res/drawable/input_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
5
app/src/main/res/drawable/input_message_bg.xml
Normal file
5
app/src/main/res/drawable/input_message_bg.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="#EDEDED"/>
|
||||||
|
<corners android:radius="100dp"/>
|
||||||
|
</shape>
|
||||||
5
app/src/main/res/drawable/keyboard_button_bg4.xml
Normal file
5
app/src/main/res/drawable/keyboard_button_bg4.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<solid android:color="#8002BEAC"/>
|
||||||
|
<corners android:radius="50dp"/>
|
||||||
|
</shape>
|
||||||
BIN
app/src/main/res/drawable/send_icon.png
Normal file
BIN
app/src/main/res/drawable/send_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -33,6 +33,17 @@
|
|||||||
android:rotation="180"
|
android:rotation="180"
|
||||||
android:scaleType="fitCenter" />
|
android:scaleType="fitCenter" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="49dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:text="Key of love"
|
||||||
|
android:textColor="#1B1F1A"
|
||||||
|
android:textSize="16sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<androidx.core.widget.NestedScrollView
|
<androidx.core.widget.NestedScrollView
|
||||||
@@ -52,6 +63,7 @@
|
|||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<!-- 头像 -->
|
<!-- 头像 -->
|
||||||
@@ -127,32 +139,61 @@
|
|||||||
android:id="@+id/bottom_panel"
|
android:id="@+id/bottom_panel"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_horizontal"
|
android:gravity="center_vertical"
|
||||||
|
android:background="@drawable/input_message_bg"
|
||||||
|
android:padding="5dp"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:paddingStart="16dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:padding="16dp"
|
|
||||||
android:layout_gravity="bottom">
|
android:layout_gravity="bottom">
|
||||||
<EditText
|
<FrameLayout
|
||||||
android:id="@+id/input_message"
|
android:layout_width="0dp"
|
||||||
android:layout_width="200dp"
|
android:layout_height="34dp"
|
||||||
android:layout_height="52dp"
|
android:layout_weight="1">
|
||||||
android:background="@drawable/input_box_bg"
|
|
||||||
android:padding="15dp"
|
<EditText
|
||||||
android:hint="Please enter your content"
|
android:id="@+id/input_message"
|
||||||
android:textColorHint="#CBCBCB"
|
android:layout_width="match_parent"
|
||||||
android:textSize="14sp"
|
android:layout_height="match_parent"
|
||||||
android:textColor="#CBCBCB"
|
android:background="#00000000"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:focusableInTouchMode="true"
|
android:focusableInTouchMode="true"
|
||||||
android:cursorVisible="true"
|
android:cursorVisible="true"
|
||||||
android:imeOptions="actionSend"
|
android:imeOptions="actionSend"
|
||||||
android:inputType="text"/>
|
android:textColor="#CBCBCB"
|
||||||
<Button
|
android:textSize="12sp"
|
||||||
|
android:inputType="text"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/hintLayout"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="19dp"
|
||||||
|
android:src="@drawable/input_icon"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/hint_text"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:text="Please enter your content"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="#CBCBCB"/>
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<ImageView
|
||||||
android:id="@+id/btn_send"
|
android:id="@+id/btn_send"
|
||||||
android:layout_width="60dp"
|
android:layout_width="34dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="34dp"
|
||||||
android:text="Send"
|
android:layout_marginStart="8dp"
|
||||||
android:textColor="#FFFFFF"
|
android:src="@drawable/send_icon"
|
||||||
android:textSize="14sp"
|
|
||||||
android:imeOptions="actionSend"
|
android:imeOptions="actionSend"
|
||||||
android:inputType="text"/>
|
android:inputType="text"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:id="@+id/background"
|
android:id="@+id/background"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
@@ -30,31 +31,32 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent" />
|
app:layout_constraintBottom_toBottomOf="parent" />
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:id="@+id/keyboard_row_1"
|
android:id="@+id/keyboard_row_1"
|
||||||
android:paddingStart="4dp"
|
android:paddingStart="4dp"
|
||||||
android:paddingEnd="4dp"
|
android:paddingEnd="4dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="vertical">
|
||||||
<!-- 粘贴框和人设 -->
|
<!-- 粘贴框-->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:orientation="horizontal">
|
||||||
<!-- 粘贴框 -->
|
<!-- 粘贴框 -->
|
||||||
<HorizontalScrollView
|
<HorizontalScrollView
|
||||||
android:id="@+id/completion_scroll"
|
android:id="@+id/completion_scroll"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="41dp"
|
android:layout_height="41dp"
|
||||||
|
android:layout_weight="1"
|
||||||
android:overScrollMode="never"
|
android:overScrollMode="never"
|
||||||
android:scrollbars="none"
|
android:scrollbars="none"
|
||||||
android:paddingStart="8dp"
|
android:paddingStart="8dp"
|
||||||
android:paddingEnd="8dp"
|
android:paddingEnd="8dp"
|
||||||
android:background="@drawable/keyboard_button_bg3"
|
android:background="@drawable/keyboard_button_bg3"
|
||||||
android:fillViewport="false">
|
android:fillViewport="false">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/completion_container"
|
android:id="@+id/completion_container"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -76,9 +78,35 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</HorizontalScrollView>
|
</HorizontalScrollView>
|
||||||
|
|
||||||
<!-- 人设 -->
|
<LinearLayout
|
||||||
|
android:id="@+id/keyboard_button_Paste"
|
||||||
|
android:layout_width="60dp"
|
||||||
|
android:layout_height="41dp"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="@drawable/keyboard_button_bg1">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Paste"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 人设和操作 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ai_persona"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_height="131dp"
|
android:layout_height="131dp"
|
||||||
android:scrollbars="none">
|
android:scrollbars="none">
|
||||||
@@ -89,9 +117,9 @@
|
|||||||
app:flexDirection="row"
|
app:flexDirection="row"
|
||||||
app:flexWrap="wrap"
|
app:flexWrap="wrap"
|
||||||
app:justifyContent="space_between">
|
app:justifyContent="space_between">
|
||||||
|
|
||||||
<!-- 卡片 -->
|
<!-- 卡片 -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/card"
|
||||||
android:layout_width="90dp"
|
android:layout_width="90dp"
|
||||||
android:layout_height="41dp"
|
android:layout_height="41dp"
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginBottom="4dp"
|
||||||
@@ -115,35 +143,16 @@
|
|||||||
android:textColor="#1B1F1A"
|
android:textColor="#1B1F1A"
|
||||||
android:textSize="13sp"/>
|
android:textSize="13sp"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<!-- ```````````````` -->
|
|
||||||
|
|
||||||
</com.google.android.flexbox.FlexboxLayout>
|
</com.google.android.flexbox.FlexboxLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
<!-- 操作 -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:id="@+id/keyboard_button_1"
|
android:id="@+id/keyboard_button_1"
|
||||||
android:layout_marginStart="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/keyboard_button_Paste"
|
|
||||||
android:layout_width="60dp"
|
|
||||||
android:layout_height="41dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:background="@drawable/keyboard_button_bg1">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Paste"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/keyboard_button_Delete"
|
android:id="@+id/keyboard_button_Delete"
|
||||||
android:layout_width="60dp"
|
android:layout_width="60dp"
|
||||||
@@ -190,5 +199,41 @@
|
|||||||
android:textSize="13sp" />
|
android:textSize="13sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<!-- `````````````````````````````````````````````````````````````````````````````````````` -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ai_output"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="131dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:background="@drawable/keyboard_button_bg3"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/scroll_messages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/container_messages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"/>
|
||||||
|
</ScrollView>
|
||||||
|
<!-- 切换按钮 -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/Return_keyboard"
|
||||||
|
android:layout_width="50dp"
|
||||||
|
android:layout_height="30dp"
|
||||||
|
android:layout_marginStart="-55dp"
|
||||||
|
android:layout_marginTop="5dp"
|
||||||
|
android:background="@drawable/keyboard_button_bg4"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="Return"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
<!-- `````````````````````````````````````````````````````````````````````````````````````` -->
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
<!-- 输入框 -->
|
<!-- 输入框 -->
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/et_username"
|
android:id="@+id/et_email"
|
||||||
android:layout_width="315dp"
|
android:layout_width="315dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
android:layout_marginTop="20dp"
|
android:layout_marginTop="20dp"
|
||||||
|
|||||||
15
app/src/main/res/layout/item_ai_message.xml
Normal file
15
app/src/main/res/layout/item_ai_message.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_content"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:background="#f5f5f5"
|
||||||
|
android:padding="10dp" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="40dp"
|
android:layout_marginTop="30dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
android:gravity="end"
|
android:gravity="end"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<!-- 头像 -->
|
<!-- 头像 -->
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="40dp"
|
android:layout_marginTop="30dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<!-- 头像 -->
|
<!-- 头像 -->
|
||||||
|
|||||||
12
app/src/main/res/xml/network_security_config.xml
Normal file
12
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">192.168.2.21</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
Reference in New Issue
Block a user