- 106 动画 + 2366 表情包 + 语义标签 - 三层记忆 / 心情系统 / 9 可配置插件 / 10 技能 - DeepSeek 对话直连 + MCP/HTTP 接入 + 接入文档 - 动画随机链 + 物理引擎 + 贴边碰撞校准 - 大资源(动画webp/表情图)由 scripts/ 再生成,不入库
2620 lines
106 KiB
Kotlin
2620 lines
106 KiB
Kotlin
package com.dsh.petfloat
|
||
|
||
import android.app.Activity
|
||
import android.app.AlertDialog
|
||
import android.content.ClipData
|
||
import android.content.ClipboardManager
|
||
import android.content.Context
|
||
import android.content.Intent
|
||
import android.content.res.ColorStateList
|
||
import android.content.res.Configuration
|
||
import android.graphics.Color
|
||
import android.graphics.Typeface
|
||
import android.graphics.drawable.GradientDrawable
|
||
import android.graphics.drawable.RippleDrawable
|
||
import android.net.Uri
|
||
import android.os.Build
|
||
import android.os.Bundle
|
||
import android.os.Handler
|
||
import android.os.Looper
|
||
import android.provider.Settings
|
||
import android.text.TextUtils
|
||
import android.view.Gravity
|
||
import android.view.MotionEvent
|
||
import android.view.View
|
||
import android.view.ViewGroup
|
||
import android.view.WindowInsets
|
||
import android.widget.EditText
|
||
import android.widget.FrameLayout
|
||
import android.widget.GridLayout
|
||
import android.widget.ImageView
|
||
import android.widget.HorizontalScrollView
|
||
import android.widget.LinearLayout
|
||
import android.widget.ScrollView
|
||
import android.widget.SeekBar
|
||
import android.widget.TextView
|
||
import android.widget.Toast
|
||
import android.view.animation.DecelerateInterpolator
|
||
import org.json.JSONArray
|
||
import org.json.JSONObject
|
||
import java.net.HttpURLConnection
|
||
import java.net.URL
|
||
import java.text.SimpleDateFormat
|
||
import java.util.Date
|
||
import java.util.Locale
|
||
|
||
/**
|
||
* 主界面(DeepSeek 风格:白底分组列表 / 细分割线 / 品牌蓝 #4D6BFE)。
|
||
* 三个页签:状态 · 动作 · 设置;跟随系统浅色/深色。
|
||
*/
|
||
class MainActivity : Activity() {
|
||
|
||
private data class SkillItem(val id: String, val name: String, val desc: String)
|
||
|
||
private val SKILLS = listOf(
|
||
SkillItem("work_start", "开工", "工作动作 + 开工宣言"),
|
||
SkillItem("work_done", "收工", "雀跃庆祝 + 夸奖表情"),
|
||
SkillItem("work_error", "出错了", "垂头叹气 + 出错表情"),
|
||
SkillItem("slack", "摸鱼", "休息动作 + 摸鱼台词"),
|
||
SkillItem("meal", "干饭", "吃饭动作 + 白米饭台词"),
|
||
SkillItem("greet", "打招呼", "挥手 + 问候"),
|
||
SkillItem("sleep", "睡觉", "待机 + 晚安"),
|
||
SkillItem("report", "汇报", "工作动画 + 可传自定义文字"),
|
||
SkillItem("hydrate", "喝水", "喝水提醒"),
|
||
SkillItem("stretch", "伸懒腰", "伸懒腰 + 活动一下")
|
||
)
|
||
|
||
companion object {
|
||
const val EXTRA_TAB = "tab"
|
||
const val TAB_STATUS = 0
|
||
const val TAB_CHAT = 1
|
||
const val TAB_ACTIONS = 2
|
||
const val TAB_SETTINGS = 3
|
||
const val KEY_CHAT_API = "chat_api_key"
|
||
const val KEY_CHAT_MODEL = "chat_model"
|
||
const val KEY_CHAT_BASE = "chat_base_url"
|
||
const val KEY_CHAT_HISTORY = "chat_history"
|
||
const val ENDPOINT = "http://127.0.0.1:7350"
|
||
val STYLE_KEYS = listOf("default", "dark", "outline", "note")
|
||
val STYLE_LABELS = listOf("简约", "深色", "描边", "便签")
|
||
}
|
||
|
||
private data class Pal(
|
||
val bg: Int, val card: Int, val line: Int, val text: Int,
|
||
val sub: Int, val accent: Int, val accentSoft: Int, val danger: Int,
|
||
val chip: Int, val track: Int
|
||
)
|
||
|
||
private val ui = Handler(Looper.getMainLooper())
|
||
private lateinit var pal: Pal
|
||
private lateinit var config: PetConfig
|
||
private var running = false
|
||
|
||
private val tabs = ArrayList<TextView>()
|
||
private lateinit var tabIndicator: View
|
||
private var selectedTab = 0
|
||
private val pages = ArrayList<View>()
|
||
|
||
private lateinit var statusDot: View
|
||
private lateinit var statusTitle: TextView
|
||
private lateinit var statusSub1: TextView
|
||
private lateinit var statusSub2: TextView
|
||
private lateinit var moodValue: TextView
|
||
private lateinit var moodFill: View
|
||
private lateinit var moodTrack: View
|
||
private lateinit var moodEmotions: TextView
|
||
private lateinit var moodReason: TextView
|
||
private lateinit var primaryBtn: TextView
|
||
private lateinit var overlayHint: TextView
|
||
private lateinit var histRows: LinearLayout
|
||
private lateinit var histUnread: TextView
|
||
|
||
private lateinit var scaleValue: TextView
|
||
private lateinit var opacityValue: TextView
|
||
private lateinit var offsetValue: TextView
|
||
private lateinit var scaleBar: SeekBar
|
||
private lateinit var opacityBar: SeekBar
|
||
private lateinit var offsetBar: SeekBar
|
||
private lateinit var authToggle: TextView
|
||
private lateinit var authRows: LinearLayout
|
||
private lateinit var addrValue: TextView
|
||
private lateinit var chatModelValue: TextView
|
||
private lateinit var chatBaseValue: TextView
|
||
private lateinit var chatList: LinearLayout
|
||
private lateinit var chatScroll: ScrollView
|
||
private lateinit var chatInput: EditText
|
||
private lateinit var chatSendBtn: TextView
|
||
private var chatBusy = false
|
||
private lateinit var chainGapValue: TextView
|
||
private lateinit var idleValue: TextView
|
||
private lateinit var edgeValue: TextView
|
||
private lateinit var edgeBar: SeekBar
|
||
private lateinit var edgePet: ImageView
|
||
private lateinit var memeGrid: GridLayout
|
||
private lateinit var memeMore: TextView
|
||
private var memeCursor = 0
|
||
private val memeBatch = 60
|
||
|
||
private var groups: List<Pair<String, List<String>>> = emptyList()
|
||
|
||
private val poller = object : Runnable {
|
||
override fun run() {
|
||
refreshStatus()
|
||
refreshMood()
|
||
ui.postDelayed(this, 2500)
|
||
}
|
||
}
|
||
|
||
override fun onCreate(savedInstanceState: Bundle?) {
|
||
super.onCreate(savedInstanceState)
|
||
val night = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
|
||
Configuration.UI_MODE_NIGHT_YES
|
||
pal = if (night) {
|
||
Pal(
|
||
0xFF121316.toInt(), 0xFF1B1D21.toInt(), 0xFF2A2D33.toInt(), 0xFFE9EAEC.toInt(),
|
||
0xFF9AA0A8.toInt(), 0xFF7B8CFF.toInt(), 0x337B8CFF, 0xFFFF6B6B.toInt(),
|
||
0xFF23262B.toInt(), 0xFF2E323A.toInt()
|
||
)
|
||
} else {
|
||
Pal(
|
||
0xFFF7F8FA.toInt(), 0xFFFFFFFF.toInt(), 0xFFECEEF2.toInt(), 0xFF1A1A1A.toInt(),
|
||
0xFF6B7280.toInt(), 0xFF4D6BFE.toInt(), 0x1A4D6BFE, 0xFFE5484D.toInt(),
|
||
0xFFF2F4F7.toInt(), 0xFFE7EAF0.toInt()
|
||
)
|
||
}
|
||
config = PetConfig.load(this)
|
||
|
||
|
||
|
||
val root = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setBackgroundColor(pal.bg)
|
||
}
|
||
|
||
val header = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(20), dp(24), dp(20), dp(8))
|
||
}
|
||
header.addView(TextView(this).apply {
|
||
text = "小鲸娘"
|
||
textSize = 22f
|
||
setTextColor(pal.text)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
})
|
||
header.addView(TextView(this).apply {
|
||
text = "会溜达、会被丢、会替你播报任务"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(4), 0, 0)
|
||
})
|
||
root.addView(header)
|
||
|
||
val content = FrameLayout(this)
|
||
pages.add(buildStatusPage())
|
||
pages.add(buildChatPage())
|
||
pages.add(buildActionsPage())
|
||
pages.add(buildSettingsPage())
|
||
pages.forEach {
|
||
content.addView(it, FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
|
||
))
|
||
}
|
||
root.addView(content, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f
|
||
))
|
||
|
||
// 底部 TabBar:胶囊容器 + 滑动指示器 + 按压缩放
|
||
val tabBar = FrameLayout(this).apply {
|
||
background = rounded(pal.card, 30, pal.line)
|
||
elevation = dp(8).toFloat()
|
||
setPadding(dp(5), dp(5), dp(5), dp(5))
|
||
}
|
||
tabIndicator = View(this).apply {
|
||
background = rounded(pal.accentSoft, 22)
|
||
}
|
||
tabBar.addView(tabIndicator, FrameLayout.LayoutParams(0, dp(44)))
|
||
|
||
val tabRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
|
||
listOf("状态", "对话", "动作", "设置").forEachIndexed { i, name ->
|
||
val tv = TextView(this).apply {
|
||
text = name
|
||
textSize = 15f
|
||
gravity = Gravity.CENTER
|
||
isClickable = true
|
||
setOnClickListener { selectTab(i) }
|
||
setOnTouchListener { v, e ->
|
||
when (e.actionMasked) {
|
||
MotionEvent.ACTION_DOWN -> v.animate()
|
||
.scaleX(0.93f).scaleY(0.93f).setDuration(80).start()
|
||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> v.animate()
|
||
.scaleX(1f).scaleY(1f).setDuration(140).start()
|
||
}
|
||
false
|
||
}
|
||
}
|
||
tabRow.addView(tv, LinearLayout.LayoutParams(0, dp(44), 1f))
|
||
tabs.add(tv)
|
||
}
|
||
tabBar.addView(tabRow, FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
))
|
||
root.addView(tabBar, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply {
|
||
leftMargin = dp(16); rightMargin = dp(16)
|
||
bottomMargin = dp(14); topMargin = dp(6)
|
||
})
|
||
tabBar.post { layoutTabIndicator(selectedTab, animate = false) }
|
||
|
||
setContentView(root)
|
||
applySystemBars(root, night)
|
||
selectTab(intent.getIntExtra(EXTRA_TAB, TAB_STATUS))
|
||
ensureOverlayAndService()
|
||
}
|
||
|
||
override fun onNewIntent(intent: Intent?) {
|
||
super.onNewIntent(intent)
|
||
intent?.let { selectTab(it.getIntExtra(EXTRA_TAB, TAB_STATUS)) }
|
||
}
|
||
|
||
override fun onResume() {
|
||
super.onResume()
|
||
refreshStatus()
|
||
refreshHistory()
|
||
refreshMood()
|
||
ui.removeCallbacks(poller)
|
||
ui.post(poller)
|
||
}
|
||
|
||
override fun onPause() {
|
||
super.onPause()
|
||
ui.removeCallbacks(poller)
|
||
}
|
||
|
||
// ================= 页 1:状态 =================
|
||
|
||
private fun buildStatusPage(): View {
|
||
val scroll = ScrollView(this)
|
||
val col = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(20), dp(8), dp(20), dp(20))
|
||
}
|
||
scroll.addView(col)
|
||
|
||
val card = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
}
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
}
|
||
statusDot = View(this).apply {
|
||
background = GradientDrawable().apply {
|
||
setColor(pal.sub); cornerRadius = dp(5).toFloat()
|
||
}
|
||
}
|
||
row.addView(statusDot, LinearLayout.LayoutParams(dp(9), dp(9)).apply {
|
||
rightMargin = dp(9)
|
||
})
|
||
statusTitle = TextView(this).apply {
|
||
text = "检查中…"
|
||
textSize = 17f
|
||
setTextColor(pal.text)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
}
|
||
row.addView(statusTitle)
|
||
card.addView(row)
|
||
statusSub1 = TextView(this).apply {
|
||
textSize = 13.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(8), 0, 0)
|
||
}
|
||
card.addView(statusSub1)
|
||
statusSub2 = TextView(this).apply {
|
||
textSize = 13.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(3), 0, 0)
|
||
}
|
||
card.addView(statusSub2)
|
||
col.addView(card)
|
||
|
||
// ---- 心情卡 ----
|
||
val moodCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
}
|
||
val moodHead = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
}
|
||
moodHead.addView(TextView(this).apply {
|
||
text = "心情"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
moodValue = TextView(this).apply {
|
||
text = "—"
|
||
textSize = 15f
|
||
setTextColor(pal.accent)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
}
|
||
moodHead.addView(moodValue)
|
||
moodCard.addView(moodHead)
|
||
|
||
// 进度条
|
||
val barWrap = FrameLayout(this).apply {
|
||
setPadding(0, dp(10), 0, 0)
|
||
}
|
||
moodTrack = View(this).apply {
|
||
background = rounded(pal.track, 4)
|
||
}
|
||
barWrap.addView(moodTrack, FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, dp(8)
|
||
))
|
||
moodFill = View(this).apply {
|
||
background = rounded(pal.accent, 4)
|
||
}
|
||
barWrap.addView(moodFill, FrameLayout.LayoutParams(0, dp(8)))
|
||
moodCard.addView(barWrap)
|
||
|
||
moodEmotions = TextView(this).apply {
|
||
textSize = 12.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(10), 0, 0)
|
||
}
|
||
moodCard.addView(moodEmotions)
|
||
moodReason = TextView(this).apply {
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(4), 0, 0)
|
||
}
|
||
moodCard.addView(moodReason)
|
||
col.addView(moodCard, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { topMargin = dp(12) })
|
||
|
||
primaryBtn = TextView(this).apply {
|
||
text = "显示宠物"
|
||
textSize = 16f
|
||
gravity = Gravity.CENTER
|
||
setTextColor(Color.WHITE)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
background = ripple(pal.accent, 12, Color.argb(0x40, 255, 255, 255))
|
||
isClickable = true
|
||
setPadding(0, dp(14), 0, dp(14))
|
||
setOnClickListener { if (running) stopPet() else startPet() }
|
||
}
|
||
col.addView(primaryBtn, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { topMargin = dp(12) })
|
||
|
||
overlayHint = TextView(this).apply {
|
||
text = "需要「显示在其他应用上层」权限,点此去授权"
|
||
textSize = 12.5f
|
||
setTextColor(pal.danger)
|
||
gravity = Gravity.CENTER
|
||
setPadding(0, dp(10), 0, 0)
|
||
isClickable = true
|
||
setOnClickListener {
|
||
runCatching {
|
||
startActivity(
|
||
Intent(
|
||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||
Uri.parse("package:$packageName")
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
col.addView(overlayHint)
|
||
|
||
col.addView(sectionLabel("快捷"))
|
||
val quick = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
quick.addView(actionRow("换动作") { selectTab(TAB_ACTIONS) })
|
||
quick.addView(divider())
|
||
quick.addView(actionRow("复制接入地址") { copyText(ENDPOINT) })
|
||
quick.addView(divider())
|
||
quick.addView(actionRow("打开接入文档") { openDocs() })
|
||
col.addView(quick)
|
||
|
||
val histHead = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
}
|
||
val label = TextView(this).apply {
|
||
text = "最近通知"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(4), dp(20), 0, dp(8))
|
||
}
|
||
histHead.addView(label, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
histUnread = TextView(this).apply {
|
||
textSize = 11.5f
|
||
setTextColor(pal.accent)
|
||
setPadding(dp(8), dp(2), dp(8), dp(2))
|
||
background = rounded(pal.accentSoft, 8)
|
||
visibility = View.GONE
|
||
}
|
||
histHead.addView(histUnread)
|
||
histHead.addView(TextView(this).apply {
|
||
text = "清除"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(10), dp(14), dp(4), dp(4))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putString(FloatingPetService.KEY_PUSH_HISTORY, "[]").apply()
|
||
refreshHistory()
|
||
}
|
||
})
|
||
col.addView(histHead)
|
||
histRows = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(14), dp(2), dp(14), dp(2))
|
||
}
|
||
col.addView(histRows)
|
||
return scroll
|
||
}
|
||
|
||
// ================= 页 2:对话(DeepSeek) =================
|
||
|
||
private fun chatKey(): String =
|
||
FloatingPetService.prefs(this).getString(KEY_CHAT_API, "") ?: ""
|
||
|
||
private fun chatBase(): String =
|
||
FloatingPetService.prefs(this).getString(KEY_CHAT_BASE, ChatClient.DEFAULT_BASE_URL)
|
||
?: ChatClient.DEFAULT_BASE_URL
|
||
|
||
private fun chatModel(): String =
|
||
FloatingPetService.prefs(this).getString(KEY_CHAT_MODEL, ChatClient.MODEL_CHAT)
|
||
?: ChatClient.MODEL_CHAT
|
||
|
||
private fun loadChat(): MutableList<ChatClient.Msg> {
|
||
val raw = FloatingPetService.prefs(this).getString(KEY_CHAT_HISTORY, "[]") ?: "[]"
|
||
return runCatching {
|
||
val arr = JSONArray(raw)
|
||
(0 until arr.length()).map {
|
||
val o = arr.getJSONObject(it)
|
||
ChatClient.Msg(o.optString("role", "user"), o.optString("content"))
|
||
}.toMutableList()
|
||
}.getOrDefault(mutableListOf())
|
||
}
|
||
|
||
private fun saveChat(list: List<ChatClient.Msg>) {
|
||
val arr = JSONArray()
|
||
list.takeLast(40).forEach {
|
||
arr.put(JSONObject().put("role", it.role).put("content", it.content))
|
||
}
|
||
FloatingPetService.prefs(this).edit()
|
||
.putString(KEY_CHAT_HISTORY, arr.toString()).apply()
|
||
}
|
||
|
||
/** 二级菜单:记忆管理。 */
|
||
private fun showMemoryDialog() {
|
||
val list = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(0, dp(4), 0, 0)
|
||
}
|
||
val scroll = ScrollView(this).apply { addView(list) }
|
||
val auto = FloatingPetService.prefs(this).getBoolean("memory_auto", true)
|
||
val autoRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(20), dp(12), dp(20), dp(12))
|
||
isClickable = true
|
||
}
|
||
autoRow.addView(TextView(this).apply {
|
||
text = "自动记忆"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
val autoState = TextView(this).apply {
|
||
text = if (auto) "开" else "关"
|
||
textSize = 13f
|
||
setTextColor(if (auto) pal.accent else pal.sub)
|
||
}
|
||
autoRow.addView(autoState)
|
||
autoRow.setOnClickListener {
|
||
val now = FloatingPetService.prefs(this).getBoolean("memory_auto", true)
|
||
FloatingPetService.prefs(this).edit().putBoolean("memory_auto", !now).apply()
|
||
autoState.text = if (!now) "开" else "关"
|
||
autoState.setTextColor(if (!now) pal.accent else pal.sub)
|
||
}
|
||
list.addView(autoRow)
|
||
val dlgRef = arrayOfNulls<AlertDialog>(1)
|
||
fun renderRows() {
|
||
while (list.childCount > 1) list.removeViewAt(1)
|
||
val mems = MemoryStore.load(this)
|
||
if (mems.isEmpty()) {
|
||
list.addView(TextView(this).apply {
|
||
text = "还没有记忆。聊聊就有了,也可以手动添加。"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(20), dp(20), dp(20), dp(16))
|
||
})
|
||
return
|
||
}
|
||
mems.forEach { m ->
|
||
list.addView(divider())
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(20), dp(12), dp(20), dp(12))
|
||
isClickable = true
|
||
setOnLongClickListener {
|
||
AlertDialog.Builder(this@MainActivity)
|
||
.setMessage("删除这条记忆?\n${m.title}")
|
||
.setPositiveButton("删除") { _, _ ->
|
||
MemoryStore.remove(this@MainActivity, m.id)
|
||
renderRows()
|
||
}
|
||
.setNegativeButton("取消", null).show()
|
||
true
|
||
}
|
||
}
|
||
row.addView(TextView(this).apply {
|
||
text = m.title + if (m.importance >= 3) " ★" else ""
|
||
textSize = 14f
|
||
setTextColor(pal.text)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
})
|
||
row.addView(TextView(this).apply {
|
||
text = m.content
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(2), 0, 0)
|
||
})
|
||
list.addView(row)
|
||
}
|
||
}
|
||
renderRows()
|
||
val d = AlertDialog.Builder(this)
|
||
.setTitle("记忆(长按删除)")
|
||
.setView(scroll)
|
||
.setNeutralButton("添加", null)
|
||
.setNegativeButton("整理", null)
|
||
.setPositiveButton("关闭", null)
|
||
.create()
|
||
d.setOnShowListener {
|
||
d.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { addMemoryDialog { renderRows(); d.dismiss() } }
|
||
d.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||
val n = MemoryStore.consolidate(this)
|
||
renderRows()
|
||
Toast.makeText(this, if (n > 0) "已合并/清理 $n 条" else "记忆已经很整洁了", Toast.LENGTH_SHORT).show()
|
||
}
|
||
d.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||
AlertDialog.Builder(this)
|
||
.setMessage("清空全部记忆?")
|
||
.setPositiveButton("清空") { _, _ -> MemoryStore.clear(this); renderRows() }
|
||
.setNegativeButton("取消", null).show()
|
||
}
|
||
}
|
||
d.show()
|
||
}
|
||
|
||
private fun addMemoryDialog(onDone: () -> Unit) {
|
||
val box = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(22), dp(6), dp(22), dp(2))
|
||
}
|
||
val title = EditText(this).apply { hint = "标题,如:饮食偏好"; textSize = 14f; setSingleLine(true) }
|
||
val content = EditText(this).apply { hint = "内容,如:喜欢白米饭,不吃香菜"; textSize = 14f }
|
||
box.addView(title); box.addView(content)
|
||
AlertDialog.Builder(this)
|
||
.setTitle("添加记忆")
|
||
.setView(box)
|
||
.setPositiveButton("保存") { dd, _ ->
|
||
if (MemoryStore.add(this, title.text.toString(), content.text.toString())) onDone()
|
||
dd.dismiss()
|
||
}
|
||
.setNegativeButton("取消", null).show()
|
||
}
|
||
|
||
/** 对话里的显式记忆指令:「记住…」「忘掉…」(不需要额外模型调用)。 */
|
||
private fun handleMemoryCommand(text: String) {
|
||
val remember = Regex("^(记住|记下|别忘了|帮我记住)[::,,\\s]*(.+)").find(text)
|
||
if (remember != null) {
|
||
val body = remember.groupValues[2].trim()
|
||
if (body.isNotEmpty()) {
|
||
val title = body.take(12)
|
||
MemoryStore.add(this, title, body, emptyList(), 3)
|
||
Toast.makeText(this, "记住啦:$title", Toast.LENGTH_SHORT).show()
|
||
}
|
||
return
|
||
}
|
||
val forget = Regex("^(忘掉|忘记|删除记忆)[::,,\\s]*(.+)").find(text)
|
||
if (forget != null) {
|
||
val kw = forget.groupValues[2].trim()
|
||
if (kw.isEmpty()) return
|
||
val list = MemoryStore.load(this)
|
||
val hit = list.filter { it.title.contains(kw) || it.content.contains(kw) }
|
||
hit.forEach { MemoryStore.remove(this, it.id) }
|
||
Toast.makeText(this, if (hit.isEmpty()) "没找到相关记忆" else "忘掉了 ${hit.size} 条",
|
||
Toast.LENGTH_SHORT).show()
|
||
}
|
||
}
|
||
|
||
/** 每 2 轮对话抽取一次长期记忆(后台静默,失败不影响聊天)。 */
|
||
private fun maybeExtractMemory(key: String, history: List<ChatClient.Msg>) {
|
||
if (!FloatingPetService.prefs(this).getBoolean("memory_auto", true)) return
|
||
val turns = FloatingPetService.prefs(this).getInt("memory_turn", 0) + 1
|
||
FloatingPetService.prefs(this).edit().putInt("memory_turn", turns).apply()
|
||
if (turns % 2 != 0) return
|
||
Thread {
|
||
// 心情判定(feiyu-mood 参数:每轮/每两轮一次短调用)
|
||
val judge = ChatClient.judgeMood(key, chatModel(), history, chatBase())
|
||
if (judge != null) {
|
||
MoodStore.apply(this, judge.moodDelta, judge.emotions,
|
||
if (judge.confidence >= 0.5) judge.reason else "")
|
||
}
|
||
val drafts = ChatClient.extractMemories(key, chatModel(), history, chatBase())
|
||
if (drafts.isEmpty()) return@Thread
|
||
val changed = MemoryStore.applyOps(this, drafts.map {
|
||
MemoryStore.Op(it.action, it.title, it.content, it.tags, it.importance, it.longTerm)
|
||
})
|
||
if (changed > 0) ui.post {
|
||
Toast.makeText(this, "记忆已更新($changed 条)", Toast.LENGTH_SHORT).show()
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
/** 二级菜单:插件管理。 */
|
||
private fun showPluginsDialog() {
|
||
val states = HashMap<String, TextView>()
|
||
val enabled = HashMap<String, Boolean>()
|
||
val list = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(0, dp(6), 0, 0)
|
||
}
|
||
val scroll = ScrollView(this).apply { addView(list) }
|
||
BuiltinPlugins.list.forEachIndexed { i, plugin ->
|
||
if (i > 0) list.addView(divider())
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(20), dp(12), dp(20), dp(12))
|
||
isClickable = true
|
||
}
|
||
val colv = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
colv.addView(TextView(this).apply {
|
||
text = plugin.name
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
})
|
||
colv.addView(TextView(this).apply {
|
||
text = plugin.desc
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(2), 0, 0)
|
||
})
|
||
row.addView(colv, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
val state = TextView(this).apply {
|
||
text = "…"
|
||
textSize = 12.5f
|
||
setTextColor(pal.sub)
|
||
}
|
||
states[plugin.id] = state
|
||
row.addView(state)
|
||
row.setOnClickListener {
|
||
val on = enabled[plugin.id] ?: false
|
||
showPluginConfig(plugin, on) { nowOn ->
|
||
enabled[plugin.id] = nowOn
|
||
state.text = if (nowOn) "已开启" else "已关闭"
|
||
state.setTextColor(if (nowOn) pal.accent else pal.sub)
|
||
}
|
||
}
|
||
list.addView(row)
|
||
}
|
||
val dlg = AlertDialog.Builder(this)
|
||
.setTitle("插件")
|
||
.setView(scroll)
|
||
.setPositiveButton("关闭", null)
|
||
.show()
|
||
|
||
Thread {
|
||
val installed = runCatching {
|
||
val h = JSONObject(httpGet("$ENDPOINT/api/plugins"))
|
||
val arr = h.optJSONArray("plugins") ?: JSONArray()
|
||
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
||
}.getOrDefault(emptySet())
|
||
ui.post {
|
||
BuiltinPlugins.list.forEach { p ->
|
||
val on = p.id in installed
|
||
enabled[p.id] = on
|
||
states[p.id]?.text = if (on) "已开启" else "已关闭"
|
||
states[p.id]?.setTextColor(if (on) pal.accent else pal.sub)
|
||
}
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
/** 插件配置页:滑条/开关 + 启用状态,保存后按新配置重装。 */
|
||
private fun showPluginConfig(plugin: BuiltinPlugins.Plugin, enabledNow: Boolean, onChanged: (Boolean) -> Unit) {
|
||
val values = BuiltinPlugins.configOf(this, plugin)
|
||
val box = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(20), dp(6), dp(20), dp(4))
|
||
}
|
||
var enabled = enabledNow
|
||
val enableRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
isClickable = true
|
||
setPadding(0, dp(6), 0, dp(10))
|
||
}
|
||
enableRow.addView(TextView(this).apply {
|
||
text = "启用此插件"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
val enableState = TextView(this).apply {
|
||
text = if (enabled) "已开启" else "已关闭"
|
||
textSize = 13f
|
||
setTextColor(if (enabled) pal.accent else pal.sub)
|
||
}
|
||
enableRow.addView(enableState)
|
||
enableRow.setOnClickListener {
|
||
enabled = !enabled
|
||
enableState.text = if (enabled) "已开启" else "已关闭"
|
||
enableState.setTextColor(if (enabled) pal.accent else pal.sub)
|
||
}
|
||
box.addView(enableRow)
|
||
|
||
val edits = HashMap<String, Int>()
|
||
val bools = HashMap<String, Boolean>()
|
||
val texts = HashMap<String, EditText>()
|
||
plugin.params.forEach { p ->
|
||
when (p.kind) {
|
||
BuiltinPlugins.Kind.BOOL -> {
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(0, dp(8), 0, dp(8))
|
||
isClickable = true
|
||
}
|
||
row.addView(TextView(this).apply {
|
||
text = p.label
|
||
textSize = 14f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
var b = (values[p.key] as? Boolean) ?: (p.default as Boolean)
|
||
bools[p.key] = b
|
||
val tv = TextView(this).apply {
|
||
text = if (b) "开" else "关"
|
||
textSize = 13f
|
||
setTextColor(if (b) pal.accent else pal.sub)
|
||
}
|
||
row.addView(tv)
|
||
row.setOnClickListener {
|
||
b = !b
|
||
bools[p.key] = b
|
||
tv.text = if (b) "开" else "关"
|
||
tv.setTextColor(if (b) pal.accent else pal.sub)
|
||
}
|
||
box.addView(row)
|
||
}
|
||
BuiltinPlugins.Kind.INT, BuiltinPlugins.Kind.HOUR -> {
|
||
val label = TextView(this).apply {
|
||
text = "${p.label}:${values[p.key]}${p.unit}"
|
||
textSize = 14f
|
||
setTextColor(pal.text)
|
||
setPadding(0, dp(10), 0, 0)
|
||
}
|
||
box.addView(label)
|
||
val cur = (values[p.key] as? Int) ?: (p.default as Int)
|
||
edits[p.key] = cur
|
||
val bar = seekBar(((cur - p.min) * 100 / (p.max - p.min).coerceAtLeast(1)))
|
||
box.addView(bar, blockLp(34))
|
||
bar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(sb: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (!fromUser) return
|
||
val v = p.min + progress * (p.max - p.min) / 100
|
||
edits[p.key] = v
|
||
label.text = "${p.label}:$v${p.unit}"
|
||
}
|
||
override fun onStartTrackingTouch(sb: SeekBar?) {}
|
||
override fun onStopTrackingTouch(sb: SeekBar?) {}
|
||
})
|
||
if (p.presets.isNotEmpty()) {
|
||
val chips = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
setPadding(0, dp(6), 0, 0)
|
||
}
|
||
p.presets.forEach { pv ->
|
||
chips.addView(TextView(this).apply {
|
||
text = "$pv${p.unit}"
|
||
textSize = 12f
|
||
setTextColor(pal.accent)
|
||
background = rounded(pal.accentSoft, 10)
|
||
setPadding(dp(10), dp(5), dp(10), dp(5))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
edits[p.key] = pv
|
||
label.text = "${p.label}:$pv${p.unit}"
|
||
bar.progress = ((pv - p.min) * 100 / (p.max - p.min).coerceAtLeast(1))
|
||
}
|
||
}, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { rightMargin = dp(6) })
|
||
}
|
||
box.addView(chips)
|
||
}
|
||
if (p.hint.isNotBlank()) box.addView(TextView(this).apply {
|
||
text = p.hint
|
||
textSize = 11.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(4), 0, 0)
|
||
})
|
||
}
|
||
BuiltinPlugins.Kind.TEXT -> {
|
||
box.addView(TextView(this).apply {
|
||
text = p.label
|
||
textSize = 14f
|
||
setTextColor(pal.text)
|
||
setPadding(0, dp(10), 0, dp(4))
|
||
})
|
||
val et = EditText(this).apply {
|
||
setText(values[p.key] as? String ?: "")
|
||
textSize = 14f
|
||
maxLines = 3
|
||
hint = p.hint
|
||
}
|
||
box.addView(et)
|
||
texts[p.key] = et
|
||
}
|
||
}
|
||
}
|
||
box.addView(TextView(this).apply {
|
||
text = "保存后立即按新配置重载插件"
|
||
textSize = 11.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(10), 0, 0)
|
||
})
|
||
AlertDialog.Builder(this)
|
||
.setTitle(plugin.name)
|
||
.setView(box)
|
||
.setNeutralButton("恢复默认", null)
|
||
.setNegativeButton("删除插件", null)
|
||
.setPositiveButton("保存") { d, _ ->
|
||
val merged = HashMap<String, Any>(values)
|
||
merged.putAll(edits)
|
||
merged.putAll(bools)
|
||
texts.forEach { (k, et) -> merged[k] = et.text.toString().trim() }
|
||
BuiltinPlugins.setConfig(this, plugin, merged)
|
||
if (enabled) {
|
||
askService(FloatingPetService.ACTION_INSTALL_PLUGIN) {
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_ID, plugin.id)
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_CODE,
|
||
BuiltinPlugins.codeWithConfig(this@MainActivity, plugin))
|
||
}
|
||
} else {
|
||
askService(FloatingPetService.ACTION_PLUGIN_REMOVE) {
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_ID, plugin.id)
|
||
}
|
||
}
|
||
onChanged(enabled)
|
||
Toast.makeText(this, "已保存", Toast.LENGTH_SHORT).show()
|
||
d.dismiss()
|
||
}
|
||
.create()
|
||
.also { dlg ->
|
||
dlg.setOnShowListener {
|
||
dlg.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener {
|
||
BuiltinPlugins.resetConfig(this, plugin)
|
||
askService(FloatingPetService.ACTION_INSTALL_PLUGIN) {
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_ID, plugin.id)
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_CODE,
|
||
BuiltinPlugins.codeWithConfig(this@MainActivity, plugin))
|
||
}
|
||
Toast.makeText(this, "已恢复默认", Toast.LENGTH_SHORT).show()
|
||
dlg.dismiss()
|
||
}
|
||
dlg.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||
BuiltinPlugins.resetConfig(this, plugin)
|
||
askService(FloatingPetService.ACTION_PLUGIN_REMOVE) {
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_ID, plugin.id)
|
||
}
|
||
onChanged(false)
|
||
Toast.makeText(this, "已删除(可随时重新开启)", Toast.LENGTH_SHORT).show()
|
||
dlg.dismiss()
|
||
}
|
||
}
|
||
dlg.show()
|
||
}
|
||
}
|
||
|
||
/** 二级菜单:技能列表,点一下执行。 */
|
||
private fun showSkillsDialog() {
|
||
val list = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(0, dp(6), 0, 0)
|
||
}
|
||
val scroll = ScrollView(this).apply { addView(list) }
|
||
SKILLS.forEachIndexed { i, sk ->
|
||
if (i > 0) list.addView(divider())
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(20), dp(13), dp(20), dp(13))
|
||
isClickable = true
|
||
}
|
||
val colv = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
colv.addView(TextView(this).apply {
|
||
text = sk.name
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
})
|
||
colv.addView(TextView(this).apply {
|
||
text = sk.desc
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(2), 0, 0)
|
||
})
|
||
row.addView(colv, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
row.addView(chevron())
|
||
list.addView(row)
|
||
}
|
||
val dlg = AlertDialog.Builder(this)
|
||
.setTitle("技能")
|
||
.setView(scroll)
|
||
.setPositiveButton("关闭", null)
|
||
.show()
|
||
SKILLS.forEachIndexed { i, sk ->
|
||
(list.getChildAt(i) as? View)?.setOnClickListener {
|
||
askService(FloatingPetService.ACTION_RUN_SKILL) {
|
||
putExtra(FloatingPetService.EXTRA_SKILL_ID, sk.id)
|
||
}
|
||
Toast.makeText(this, "已执行:${sk.name}", Toast.LENGTH_SHORT).show()
|
||
dlg.dismiss()
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun refreshChatSettings() {
|
||
if (::chatModelValue.isInitialized) chatModelValue.text = chatModel()
|
||
if (::chatBaseValue.isInitialized) chatBaseValue.text = chatBase().replace("https://", "")
|
||
}
|
||
|
||
/** 编辑接口地址(默认 DeepSeek 官方,可换代理 / OpenAI 兼容端点)。 */
|
||
private fun editChatBase() {
|
||
val box = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(22), dp(8), dp(22), dp(4))
|
||
}
|
||
val input = EditText(this).apply {
|
||
hint = ChatClient.DEFAULT_BASE_URL
|
||
textSize = 14f
|
||
setSingleLine(true)
|
||
setText(chatBase())
|
||
setSelection(text.length)
|
||
typeface = Typeface.MONOSPACE
|
||
}
|
||
box.addView(input)
|
||
box.addView(TextView(this).apply {
|
||
text = "填根地址即可(自动补 /chat/completions);也可直接贴完整地址。"
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(6), 0, 0)
|
||
})
|
||
AlertDialog.Builder(this)
|
||
.setTitle("接口地址")
|
||
.setView(box)
|
||
.setPositiveButton("保存") { d, _ ->
|
||
val v = input.text.toString().trim()
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putString(KEY_CHAT_BASE, v.ifBlank { ChatClient.DEFAULT_BASE_URL }).apply()
|
||
refreshChatSettings()
|
||
d.dismiss()
|
||
}
|
||
.setNegativeButton("取消", null)
|
||
.show()
|
||
}
|
||
|
||
/** 手动填写模型名(带常用预设快捷填入)。 */
|
||
private fun editChatModel() {
|
||
val box = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(22), dp(8), dp(22), dp(4))
|
||
}
|
||
val input = EditText(this).apply {
|
||
hint = "deepseek-chat"
|
||
textSize = 14f
|
||
setSingleLine(true)
|
||
setText(chatModel())
|
||
setSelection(text.length)
|
||
typeface = Typeface.MONOSPACE
|
||
}
|
||
box.addView(input)
|
||
box.addView(TextView(this).apply {
|
||
text = "可填任意模型名。常用:"
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(8), 0, dp(4))
|
||
})
|
||
val presets = listOf(ChatClient.MODEL_CHAT, ChatClient.MODEL_REASONER)
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
}
|
||
var dlgRef: AlertDialog? = null
|
||
presets.forEach { m ->
|
||
row.addView(TextView(this).apply {
|
||
text = m
|
||
textSize = 12.5f
|
||
setTextColor(pal.accent)
|
||
background = rounded(pal.accentSoft, 10)
|
||
setPadding(dp(10), dp(7), dp(10), dp(7))
|
||
isClickable = true
|
||
setOnClickListener { input.setText(m); input.setSelection(m.length) }
|
||
}, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { rightMargin = dp(8) })
|
||
}
|
||
box.addView(row)
|
||
dlgRef = AlertDialog.Builder(this)
|
||
.setTitle("模型名")
|
||
.setView(box)
|
||
.setPositiveButton("保存") { d, _ ->
|
||
val m = input.text.toString().trim()
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putString(KEY_CHAT_MODEL, m.ifBlank { ChatClient.MODEL_CHAT }).apply()
|
||
refreshChatSettings()
|
||
Toast.makeText(this, "模型已设为 ${chatModel()}", Toast.LENGTH_SHORT).show()
|
||
d.dismiss()
|
||
}
|
||
.setNegativeButton("取消", null)
|
||
.show()
|
||
dlgRef?.let { }
|
||
}
|
||
|
||
/** 二级界面里编辑 API Key(一级界面只显示掩码)。 */
|
||
private fun editChatKey() {
|
||
val box = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(22), dp(8), dp(22), dp(4))
|
||
}
|
||
val input = EditText(this).apply {
|
||
hint = "sk-..."
|
||
textSize = 14f
|
||
setSingleLine(true)
|
||
setText(chatKey())
|
||
setSelection(text.length)
|
||
typeface = Typeface.MONOSPACE
|
||
}
|
||
box.addView(input)
|
||
box.addView(TextView(this).apply {
|
||
text = "在 platform.deepseek.com 创建;只存在本机。"
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(6), 0, 0)
|
||
})
|
||
AlertDialog.Builder(this)
|
||
.setTitle("DeepSeek API Key")
|
||
.setView(box)
|
||
.setPositiveButton("保存") { d, _ ->
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putString(KEY_CHAT_API, input.text.toString().trim()).apply()
|
||
renderChat()
|
||
Toast.makeText(this, "已保存", Toast.LENGTH_SHORT).show()
|
||
d.dismiss()
|
||
}
|
||
.setNegativeButton("取消", null)
|
||
.show()
|
||
}
|
||
|
||
private fun testChatConnection() {
|
||
val key = chatKey()
|
||
if (key.isBlank()) {
|
||
Toast.makeText(this, "先填 API Key", Toast.LENGTH_SHORT).show()
|
||
return
|
||
}
|
||
Toast.makeText(this, "测试中…", Toast.LENGTH_SHORT).show()
|
||
Thread {
|
||
val r = ChatClient.chat(key, chatModel(), listOf(ChatClient.Msg("user", "你好")), chatBase())
|
||
ui.post {
|
||
r.onSuccess { Toast.makeText(this, "连接成功:${it.content.take(20)}", Toast.LENGTH_LONG).show() }
|
||
.onFailure { Toast.makeText(this, "失败:${it.message}", Toast.LENGTH_LONG).show() }
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
private fun buildChatPage(): View {
|
||
val page = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
chatScroll = ScrollView(this).apply { isVerticalScrollBarEnabled = false }
|
||
chatList = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(16), dp(8), dp(16), dp(12))
|
||
}
|
||
chatScroll.addView(chatList)
|
||
page.addView(chatScroll, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f
|
||
))
|
||
|
||
// 输入行
|
||
val inputRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(14), dp(8), dp(14), dp(10))
|
||
}
|
||
chatInput = EditText(this).apply {
|
||
hint = "跟小鲸娘说点什么…"
|
||
setOnFocusChangeListener { _, hasFocus -> if (hasFocus) ui.postDelayed({ scrollChatToBottom() }, 250) }
|
||
textSize = 15f
|
||
maxLines = 4
|
||
setTextColor(pal.text)
|
||
setHintTextColor(pal.sub)
|
||
background = rounded(pal.card, 20, pal.line)
|
||
setPadding(dp(16), dp(10), dp(16), dp(10))
|
||
}
|
||
inputRow.addView(chatInput, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
chatSendBtn = TextView(this).apply {
|
||
text = "发送"
|
||
textSize = 15f
|
||
gravity = Gravity.CENTER
|
||
setTextColor(Color.WHITE)
|
||
typeface = Typeface.DEFAULT_BOLD
|
||
background = ripple(pal.accent, 20, Color.argb(0x40, 255, 255, 255))
|
||
setPadding(dp(18), dp(11), dp(18), dp(11))
|
||
isClickable = true
|
||
setOnClickListener { sendChat() }
|
||
}
|
||
inputRow.addView(chatSendBtn, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { leftMargin = dp(8) })
|
||
page.addView(inputRow)
|
||
|
||
renderChat()
|
||
return page
|
||
}
|
||
|
||
private fun renderChat() {
|
||
if (!::chatList.isInitialized) return
|
||
chatList.removeAllViews()
|
||
val list = loadChat()
|
||
if (chatKey().isBlank()) {
|
||
chatList.addView(TextView(this).apply {
|
||
text = "还没有配置 DeepSeek API Key。\n点「设置 → 对话」填入后就能聊啦~"
|
||
textSize = 13.5f
|
||
setTextColor(pal.sub)
|
||
gravity = Gravity.CENTER
|
||
setPadding(dp(20), dp(40), dp(20), dp(20))
|
||
})
|
||
return
|
||
}
|
||
if (list.isEmpty()) {
|
||
chatList.addView(TextView(this).apply {
|
||
text = "问问小鲸娘吧~ 例如:「今天写点什么好」"
|
||
textSize = 13.5f
|
||
setTextColor(pal.sub)
|
||
gravity = Gravity.CENTER
|
||
setPadding(dp(20), dp(40), dp(20), dp(20))
|
||
})
|
||
return
|
||
}
|
||
list.forEach { m -> chatList.addView(chatBubble(m)) }
|
||
}
|
||
|
||
private fun chatBubble(m: ChatClient.Msg): View {
|
||
val mine = m.role == "user"
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = if (mine) Gravity.END else Gravity.START
|
||
setPadding(0, dp(4), 0, dp(4))
|
||
}
|
||
val tv = TextView(this).apply {
|
||
text = m.content
|
||
textSize = 14.5f
|
||
setTextColor(if (mine) Color.WHITE else pal.text)
|
||
setPadding(dp(14), dp(10), dp(14), dp(10))
|
||
background = rounded(if (mine) pal.accent else pal.card, 16,
|
||
if (mine) null else pal.line)
|
||
maxWidth = (resources.displayMetrics.widthPixels * 0.78f).toInt()
|
||
}
|
||
row.addView(tv)
|
||
return row
|
||
}
|
||
|
||
private fun tagForReply(text: String): String = when {
|
||
listOf("开心", "哈哈", "好耶", "棒", "喜欢", "可爱").any { text.contains(it) } -> "happy"
|
||
listOf("错", "失败", "抱歉", "不行", "难过").any { text.contains(it) } -> "sad"
|
||
listOf("吃", "饭", "饿", "零食").any { text.contains(it) } -> "eat"
|
||
listOf("工作", "代码", "忙", "任务").any { text.contains(it) } -> "work"
|
||
listOf("睡", "困", "休息", "摸鱼").any { text.contains(it) } -> "rest"
|
||
else -> "click"
|
||
}
|
||
|
||
private fun sendChat() {
|
||
if (chatBusy) return
|
||
val text = chatInput.text.toString().trim()
|
||
if (text.isEmpty()) return
|
||
val key = chatKey()
|
||
if (key.isBlank()) {
|
||
Toast.makeText(this, "请先在「设置 → 对话」填入 DeepSeek API Key", Toast.LENGTH_LONG).show()
|
||
return
|
||
}
|
||
handleMemoryCommand(text)
|
||
val history = loadChat()
|
||
history.add(ChatClient.Msg("user", text))
|
||
saveChat(history)
|
||
chatInput.setText("")
|
||
renderChat()
|
||
scrollChatToBottom()
|
||
|
||
chatBusy = true
|
||
chatSendBtn.text = "…"
|
||
chatSendBtn.alpha = 0.6f
|
||
Thread {
|
||
val memBlock = MemoryStore.renderPrompt(MemoryStore.recall(this, text))
|
||
val caps = buildCapabilities()
|
||
val res = ChatClient.chat(key, chatModel(), history, chatBase(), memBlock, caps)
|
||
ui.post {
|
||
chatBusy = false
|
||
chatSendBtn.text = "发送"
|
||
chatSendBtn.alpha = 1f
|
||
res.onSuccess { reply ->
|
||
// 从回复里提取 <<<PET_PLUGIN id="x">>> ... <<<END>>> 并安装
|
||
val installed = installPluginsFromReply(reply.content)
|
||
runActionFromReply(reply.content)
|
||
val visible = stripPluginBlocks(stripActionBlocks(reply.content))
|
||
val list = loadChat()
|
||
list.add(ChatClient.Msg("assistant", visible))
|
||
saveChat(list)
|
||
renderChat()
|
||
scrollChatToBottom()
|
||
if (installed.isNotEmpty()) {
|
||
val tip = "已安装插件:" + installed.joinToString("、")
|
||
list.add(ChatClient.Msg("assistant", tip))
|
||
saveChat(list)
|
||
renderChat()
|
||
scrollChatToBottom()
|
||
}
|
||
// 自动记忆:每 2 轮对话抽取一次长期记忆
|
||
maybeExtractMemory(key, history)
|
||
// 气泡说 + 动作呼应
|
||
askService(FloatingPetService.ACTION_SAY) {
|
||
putExtra(FloatingPetService.EXTRA_SAY_TEXT, visible.ifBlank { reply.content })
|
||
}
|
||
askService(FloatingPetService.ACTION_PLAY_TAG) {
|
||
putExtra(FloatingPetService.EXTRA_ANIM_TAG, tagForReply(reply.content))
|
||
}
|
||
}.onFailure { e ->
|
||
val msg = e.message.orEmpty()
|
||
val timeout = msg.contains("timeout", true) || msg.contains("超时") ||
|
||
e is java.net.SocketTimeoutException
|
||
if (timeout) {
|
||
// TIMEOUT_SIGNAL:用尾巴动作代替空话
|
||
val line = "(尾巴啪地拍了一下水)……等太久啦,我先趴一会儿,主人过会儿再叫我~"
|
||
val list = loadChat()
|
||
list.add(ChatClient.Msg("assistant", line))
|
||
saveChat(list)
|
||
renderChat()
|
||
scrollChatToBottom()
|
||
askService(FloatingPetService.ACTION_SAY) {
|
||
putExtra(FloatingPetService.EXTRA_SAY_TEXT, line)
|
||
}
|
||
askService(FloatingPetService.ACTION_PLAY_TAG) {
|
||
putExtra(FloatingPetService.EXTRA_ANIM_TAG, "rest")
|
||
}
|
||
} else {
|
||
Toast.makeText(this, "对话失败:$msg", Toast.LENGTH_LONG).show()
|
||
}
|
||
}
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
private val pluginBlock = Regex(
|
||
"<<" + "<PET_PLUGIN\\s+id=\"([A-Za-z0-9_-]+)\"\\s*>>>([\\s\\S]*?)<<" + "<END>>>"
|
||
)
|
||
|
||
/** 组装给模型看的「真实能力清单」。 */
|
||
private fun buildCapabilities(): String {
|
||
val sb = StringBuilder()
|
||
sb.append("技能(共 ").append(SKILLS.size).append(" 个,问起就按这个列表原文列举):\n")
|
||
sb.append(SKILLS.joinToString("、") { it.name }).append('\n')
|
||
sb.append("技能 id(执行时用):")
|
||
.append(SKILLS.joinToString("、") { it.name + "=" + it.id }).append('\n')
|
||
val pluginNames = BuiltinPlugins.list.joinToString("、") {
|
||
it.name + if (it.params.isEmpty()) "" else "(可配置)"
|
||
}
|
||
sb.append("插件:").append(pluginNames).append('\n')
|
||
sb.append("表情包:").append(config.memes.size)
|
||
.append(" 张,可按标签 happy/praise/error/eat/rest/work/fun 等调用\n")
|
||
sb.append("动作:106 个动画,可按语义标签 work/happy/sad/eat/play/rest/greet/festival 等点播\n")
|
||
sb.append("记忆:已记 ").append(MemoryStore.load(this).size).append(" 条长期记忆\n")
|
||
sb.append(MoodStore.describe(this)).append('\n')
|
||
sb.append("其他:可以说气泡、播动作、弹表情;本机 7350 端口还提供 HTTP/MCP 接口给外部应用调用")
|
||
return sb.toString()
|
||
}
|
||
|
||
private val actionBlock = Regex("<<<PET_ACTION\\s*(\\{[\\s\\S]*?\\})\\s*>>>")
|
||
|
||
private fun stripActionBlocks(text: String): String = actionBlock.replace(text, "").trim()
|
||
|
||
private fun runActionFromReply(text: String) {
|
||
actionBlock.findAll(text).forEach { m ->
|
||
val obj = runCatching { JSONObject(m.groupValues[1]) }.getOrNull() ?: return@forEach
|
||
when (obj.optString("type")) {
|
||
"skill" -> askService(FloatingPetService.ACTION_RUN_SKILL) {
|
||
putExtra(FloatingPetService.EXTRA_SKILL_ID, obj.optString("id"))
|
||
}
|
||
"anim" -> {
|
||
val tag = obj.optString("tag")
|
||
if (tag.isNotBlank()) askService(FloatingPetService.ACTION_PLAY_TAG) {
|
||
putExtra(FloatingPetService.EXTRA_ANIM_TAG, tag)
|
||
} else askService(FloatingPetService.ACTION_PLAY_ANIM) {
|
||
putExtra(FloatingPetService.EXTRA_ANIM_NAME, obj.optString("name"))
|
||
}
|
||
}
|
||
"meme" -> askService(FloatingPetService.ACTION_SHOW_MEME) {
|
||
putExtra(
|
||
FloatingPetService.EXTRA_MEME_FILE,
|
||
obj.optString("file").ifBlank { config.memes.randomOrNull()?.file ?: "" }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 解析回复里的插件块,写入插件目录,返回已安装 id。 */
|
||
private fun installPluginsFromReply(text: String): List<String> {
|
||
val ids = ArrayList<String>()
|
||
pluginBlock.findAll(text).forEach { m ->
|
||
val id = m.groupValues[1]
|
||
val code = m.groupValues[2].trim()
|
||
if (id.isNotEmpty() && code.isNotEmpty()) {
|
||
askService(FloatingPetService.ACTION_INSTALL_PLUGIN) {
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_ID, id)
|
||
putExtra(FloatingPetService.EXTRA_PLUGIN_CODE, code)
|
||
}
|
||
ids.add(id)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
/** 展示时去掉插件代码块。 */
|
||
private fun stripPluginBlocks(text: String): String =
|
||
pluginBlock.replace(text, "").trim().ifBlank { "插件已装好~重启桌宠或刷新就生效" }
|
||
|
||
private fun scrollChatToBottom() {
|
||
chatScroll.post { chatScroll.fullScroll(View.FOCUS_DOWN) }
|
||
}
|
||
|
||
// ================= 页 3:动作(按分类分组的白色卡片) =================
|
||
|
||
private fun buildActionsPage(): View {
|
||
val scroll = ScrollView(this).apply { isVerticalScrollBarEnabled = false }
|
||
val col = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(20), dp(2), dp(20), dp(20))
|
||
}
|
||
scroll.addView(col)
|
||
|
||
// ---- 技能 ----
|
||
col.addView(TextView(this).apply {
|
||
text = "技能"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(4), dp(2), 0, dp(8))
|
||
})
|
||
val skillCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
SKILLS.forEachIndexed { i, sk ->
|
||
if (i > 0) skillCard.addView(divider())
|
||
skillCard.addView(actionRow(sk.name) {
|
||
askService(FloatingPetService.ACTION_RUN_SKILL) {
|
||
putExtra(FloatingPetService.EXTRA_SKILL_ID, sk.id)
|
||
}
|
||
})
|
||
}
|
||
col.addView(skillCard)
|
||
|
||
groups = buildGroups()
|
||
groups.forEach { (title, names) ->
|
||
// 分组标题(灰色小字)
|
||
col.addView(TextView(this).apply {
|
||
text = title
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(4), dp(16), 0, dp(8))
|
||
})
|
||
|
||
// 白色分组卡片
|
||
val card = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||
}
|
||
val grid = GridLayout(this).apply { columnCount = 2 }
|
||
names.forEach { name ->
|
||
val cell = TextView(this).apply {
|
||
text = name
|
||
textSize = 13.5f
|
||
gravity = Gravity.CENTER
|
||
setTextColor(pal.text)
|
||
background = RippleDrawable(
|
||
ColorStateList.valueOf(Color.argb(0x14, 0, 0, 0)),
|
||
rounded(pal.chip, 12), null
|
||
)
|
||
isClickable = true
|
||
setOnClickListener {
|
||
askService(FloatingPetService.ACTION_PLAY_ANIM) {
|
||
putExtra(FloatingPetService.EXTRA_ANIM_NAME, name)
|
||
}
|
||
}
|
||
}
|
||
grid.addView(cell, GridLayout.LayoutParams().apply {
|
||
width = 0
|
||
height = dp(44)
|
||
columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||
setMargins(dp(4), dp(4), dp(4), dp(4))
|
||
})
|
||
}
|
||
card.addView(grid)
|
||
col.addView(card)
|
||
}
|
||
|
||
// ---- 表情包(分页渲染,支持长按隐藏)----
|
||
val memeHead = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(4), dp(16), 0, dp(8))
|
||
}
|
||
memeHead.addView(TextView(this).apply {
|
||
text = "表情包"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
val memeCount = TextView(this).apply {
|
||
textSize = 11.5f
|
||
setTextColor(pal.sub)
|
||
}
|
||
memeHead.addView(memeCount)
|
||
col.addView(memeHead)
|
||
|
||
val memeCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(8), dp(8), dp(8), dp(4))
|
||
}
|
||
memeGrid = GridLayout(this).apply { columnCount = 3 }
|
||
memeCard.addView(memeGrid)
|
||
|
||
memeMore = TextView(this).apply {
|
||
textSize = 13f
|
||
gravity = Gravity.CENTER
|
||
setTextColor(pal.accent)
|
||
setPadding(0, dp(10), 0, dp(12))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
renderMemePage()
|
||
if (memeCursor >= visibleMemes().size) visibility = View.GONE
|
||
}
|
||
}
|
||
memeCard.addView(memeMore, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
))
|
||
col.addView(memeCard)
|
||
|
||
memeCursor = 0
|
||
renderMemePage()
|
||
memeCount.text = "共 ${visibleMemes().size} 张"
|
||
return scroll
|
||
}
|
||
|
||
private fun hiddenMemes(): MutableSet<String> {
|
||
val raw = FloatingPetService.prefs(this).getString("hidden_memes", "[]") ?: "[]"
|
||
return runCatching {
|
||
val a = JSONArray(raw)
|
||
(0 until a.length()).map { a.getString(it) }.toMutableSet()
|
||
}.getOrDefault(mutableSetOf())
|
||
}
|
||
|
||
private fun visibleMemes(): List<Meme> {
|
||
val hidden = hiddenMemes()
|
||
return config.memes.filter { it.file !in hidden }
|
||
}
|
||
|
||
/** 分批把表情包渲染进网格(2436 张不能一次建 View)。 */
|
||
private fun renderMemePage() {
|
||
val all = visibleMemes()
|
||
val batch = all.drop(memeCursor).take(memeBatch)
|
||
batch.forEach { meme ->
|
||
val cell = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
gravity = Gravity.CENTER_HORIZONTAL
|
||
background = RippleDrawable(
|
||
ColorStateList.valueOf(Color.argb(0x14, 0, 0, 0)),
|
||
rounded(pal.chip, 12), null
|
||
)
|
||
setPadding(dp(6), dp(8), dp(6), dp(8))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
askService(FloatingPetService.ACTION_SHOW_MEME) {
|
||
putExtra(FloatingPetService.EXTRA_MEME_FILE, meme.file)
|
||
}
|
||
}
|
||
setOnLongClickListener {
|
||
val hidden = hiddenMemes()
|
||
if (hidden.add(meme.file)) {
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putString("hidden_memes", JSONArray(hidden.toList()).toString())
|
||
.apply()
|
||
memeGrid.removeAllViews()
|
||
memeCursor = 0
|
||
renderMemePage()
|
||
}
|
||
true
|
||
}
|
||
}
|
||
val bmp = runCatching {
|
||
val opt = android.graphics.BitmapFactory.Options().apply { inSampleSize = 2 }
|
||
assets.open(meme.file + ".webp").use {
|
||
android.graphics.BitmapFactory.decodeStream(it, null, opt)
|
||
}
|
||
}.getOrNull()
|
||
val iv = ImageView(this).apply {
|
||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||
if (bmp != null) setImageBitmap(bmp)
|
||
}
|
||
cell.addView(iv, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, dp(72)
|
||
))
|
||
cell.addView(TextView(this).apply {
|
||
text = meme.name
|
||
textSize = 11f
|
||
setTextColor(pal.sub)
|
||
gravity = Gravity.CENTER
|
||
maxLines = 1
|
||
ellipsize = TextUtils.TruncateAt.END
|
||
setPadding(0, dp(4), 0, 0)
|
||
})
|
||
memeGrid.addView(cell, GridLayout.LayoutParams().apply {
|
||
width = 0
|
||
height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||
columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||
setMargins(dp(4), dp(4), dp(4), dp(4))
|
||
})
|
||
}
|
||
memeCursor += batch.size
|
||
if (::memeMore.isInitialized) {
|
||
val left = all.size - memeCursor
|
||
memeMore.visibility = if (left > 0) View.VISIBLE else View.GONE
|
||
if (left > 0) memeMore.text = "加载更多(还有 $left 张)"
|
||
}
|
||
}
|
||
|
||
private fun buildGroups(): List<Pair<String, List<String>>> {
|
||
val list = ArrayList<Pair<String, List<String>>>()
|
||
fun add(title: String, names: List<String>) {
|
||
if (names.isNotEmpty()) list.add(title to names)
|
||
}
|
||
add("待机", config.idle.map { it.name })
|
||
add("转向", config.turn.map { it.name })
|
||
add("拖拽", config.drag.map { it.name })
|
||
add("点击回应", config.clicks.map { it.name })
|
||
add("移动", config.moves.map { it.name })
|
||
config.categories.forEach { c -> add(c.id, c.actions.map { it.name }) }
|
||
add("余额", config.events["balance"]?.map { it.name } ?: emptyList())
|
||
add("碎碎念", config.events["whisper"]?.map { it.name } ?: emptyList())
|
||
add("工作状态", config.events["workStatus"]?.map { it.name } ?: emptyList())
|
||
return list
|
||
}
|
||
|
||
// ================= 页 4:设置 =================
|
||
|
||
private fun buildSettingsPage(): View {
|
||
val scroll = ScrollView(this)
|
||
val col = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(20), dp(6), dp(20), dp(20))
|
||
}
|
||
scroll.addView(col)
|
||
val p = FloatingPetService.prefs(this)
|
||
|
||
col.addView(sectionLabel("外观"))
|
||
val look = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(16), dp(6), dp(16), dp(10))
|
||
}
|
||
scaleValue = sliderRow(look, "大小", "${(p.getFloat(FloatingPetService.KEY_SCALE, 1f) * 100).toInt()}%")
|
||
scaleBar = seekBar(((p.getFloat(FloatingPetService.KEY_SCALE, 1f) - 0.5f) / 1.5f * 100).toInt())
|
||
look.addView(scaleBar, blockLp(34))
|
||
scaleBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(b: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (fromUser) scaleValue.text = "%.0f%%".format((0.5f + 1.5f * progress / 100f) * 100)
|
||
}
|
||
override fun onStartTrackingTouch(b: SeekBar?) {}
|
||
override fun onStopTrackingTouch(b: SeekBar?) {
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putFloat(FloatingPetService.KEY_SCALE, 0.5f + 1.5f * scaleBar.progress / 100f)
|
||
.apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
}
|
||
})
|
||
opacityValue = sliderRow(look, "透明度", "${(p.getFloat(FloatingPetService.KEY_OPACITY, 1f) * 100).toInt()}%")
|
||
opacityBar = seekBar(((p.getFloat(FloatingPetService.KEY_OPACITY, 1f) - 0.3f) / 0.7f * 100).toInt())
|
||
look.addView(opacityBar, blockLp(34))
|
||
opacityBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(b: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (fromUser) opacityValue.text = "%.0f%%".format((0.3f + 0.7f * progress / 100f) * 100)
|
||
}
|
||
override fun onStartTrackingTouch(b: SeekBar?) {}
|
||
override fun onStopTrackingTouch(b: SeekBar?) {
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putFloat(FloatingPetService.KEY_OPACITY, 0.3f + 0.7f * opacityBar.progress / 100f)
|
||
.apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
}
|
||
})
|
||
col.addView(look)
|
||
|
||
col.addView(sectionLabel("气泡"))
|
||
val bub = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(16), dp(12), dp(16), dp(10))
|
||
}
|
||
bub.addView(TextView(this).apply {
|
||
text = "样式"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
})
|
||
val chipRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
setPadding(0, dp(8), 0, dp(4))
|
||
}
|
||
val styleViews = ArrayList<TextView>()
|
||
var curStyle = p.getString(FloatingPetService.KEY_BUBBLE_STYLE, "default") ?: "default"
|
||
fun renderStyle() {
|
||
styleViews.forEachIndexed { i, tv ->
|
||
val on = STYLE_KEYS[i] == curStyle
|
||
tv.background = if (on) rounded(pal.accentSoft, 18) else rounded(pal.chip, 18)
|
||
tv.setTextColor(if (on) pal.accent else pal.text)
|
||
tv.typeface = Typeface.create(
|
||
Typeface.DEFAULT, if (on) Typeface.BOLD else Typeface.NORMAL
|
||
)
|
||
}
|
||
}
|
||
STYLE_KEYS.forEachIndexed { i, key ->
|
||
val tv = TextView(this).apply {
|
||
text = STYLE_LABELS[i]
|
||
textSize = 13f
|
||
gravity = Gravity.CENTER
|
||
setPadding(dp(14), dp(7), dp(14), dp(7))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
curStyle = key
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putString(FloatingPetService.KEY_BUBBLE_STYLE, key).apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
renderStyle()
|
||
}
|
||
}
|
||
chipRow.addView(tv, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { rightMargin = dp(8) })
|
||
styleViews.add(tv)
|
||
}
|
||
bub.addView(chipRow)
|
||
renderStyle()
|
||
|
||
val curOffset = p.getInt(FloatingPetService.KEY_BUBBLE_OFFSET, 6)
|
||
offsetValue = sliderRow(bub, "距头顶", "${curOffset}dp")
|
||
offsetBar = seekBar((curOffset * 100 / 40).coerceIn(0, 100))
|
||
bub.addView(offsetBar, blockLp(34))
|
||
offsetBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(b: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (fromUser) offsetValue.text = "${progress * 40 / 100}dp"
|
||
}
|
||
override fun onStartTrackingTouch(b: SeekBar?) {}
|
||
override fun onStopTrackingTouch(b: SeekBar?) {
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putInt(FloatingPetService.KEY_BUBBLE_OFFSET, offsetBar.progress * 40 / 100)
|
||
.apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
}
|
||
})
|
||
col.addView(bub)
|
||
|
||
col.addView(sectionLabel("宠物"))
|
||
val petCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
petCard.addView(actionRow("重置位置") {
|
||
FloatingPetService.prefs(this).edit()
|
||
.remove(FloatingPetService.KEY_X)
|
||
.remove(FloatingPetService.KEY_Y).apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
})
|
||
petCard.addView(divider())
|
||
petCard.addView(actionRow("发一条测试气泡") {
|
||
askService(FloatingPetService.ACTION_TEST_NOTIFY)
|
||
})
|
||
col.addView(petCard)
|
||
|
||
// ---- 贴边间距 ----
|
||
// ---- 扩展:插件 / 技能(二级菜单)----
|
||
col.addView(sectionLabel("扩展"))
|
||
val extCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
extCard.addView(rowWith("插件", TextView(this).apply {
|
||
text = "${BuiltinPlugins.list.size} 个内置"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
}) { showPluginsDialog() })
|
||
extCard.addView(divider())
|
||
extCard.addView(rowWith("技能", TextView(this).apply {
|
||
text = "${SKILLS.size} 个"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
}) { showSkillsDialog() })
|
||
extCard.addView(divider())
|
||
extCard.addView(rowWith("记忆", TextView(this).apply {
|
||
text = "${MemoryStore.load(this@MainActivity).size} 条"
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
}) { showMemoryDialog() })
|
||
col.addView(extCard)
|
||
|
||
// ---- 对话(DeepSeek)----
|
||
col.addView(sectionLabel("对话(DeepSeek)"))
|
||
val chatCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
val baseRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
isClickable = true
|
||
setOnClickListener { editChatBase() }
|
||
}
|
||
baseRow.addView(TextView(this).apply {
|
||
text = "接口地址"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
chatBaseValue = TextView(this).apply {
|
||
text = chatBase().replace("https://", "")
|
||
textSize = 12.5f
|
||
setTextColor(pal.sub)
|
||
}
|
||
baseRow.addView(chatBaseValue)
|
||
baseRow.addView(chevron())
|
||
chatCard.addView(baseRow)
|
||
chatCard.addView(divider())
|
||
|
||
val keyRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
isClickable = true
|
||
setOnClickListener { editChatKey() }
|
||
}
|
||
keyRow.addView(TextView(this).apply {
|
||
text = "API Key"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
val keyValue = TextView(this).apply {
|
||
val k = chatKey()
|
||
text = if (k.isBlank()) "未配置" else k.take(4) + "••••" + k.takeLast(4)
|
||
textSize = 13f
|
||
setTextColor(if (k.isBlank()) pal.danger else pal.sub)
|
||
typeface = Typeface.MONOSPACE
|
||
}
|
||
keyRow.addView(keyValue)
|
||
keyRow.addView(chevron())
|
||
chatCard.addView(keyRow)
|
||
chatCard.addView(divider())
|
||
|
||
val modelRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
isClickable = true
|
||
setOnClickListener { editChatModel() }
|
||
}
|
||
modelRow.addView(TextView(this).apply {
|
||
text = "模型"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
chatModelValue = TextView(this).apply { textSize = 13f; setTextColor(pal.sub) }
|
||
modelRow.addView(chatModelValue)
|
||
modelRow.addView(chevron())
|
||
chatCard.addView(modelRow)
|
||
chatCard.addView(divider())
|
||
|
||
chatCard.addView(actionRow("清空对话记录") {
|
||
FloatingPetService.prefs(this).edit().putString(KEY_CHAT_HISTORY, "[]").apply()
|
||
renderChat()
|
||
Toast.makeText(this, "已清空", Toast.LENGTH_SHORT).show()
|
||
})
|
||
chatCard.addView(divider())
|
||
chatCard.addView(actionRow("测试连接") { testChatConnection() })
|
||
col.addView(chatCard)
|
||
refreshChatSettings()
|
||
|
||
// ---- 动作节奏 ----
|
||
col.addView(sectionLabel("动作"))
|
||
val actCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(16), dp(6), dp(16), dp(10))
|
||
}
|
||
val curGapS = FloatingPetService.prefs(this).getInt(FloatingPetService.KEY_CHAIN_GAP, 2)
|
||
chainGapValue = sliderRow(actCard, "切换间隔", "${curGapS}s")
|
||
val gapBar = seekBar((curGapS * 100 / 60).coerceIn(0, 100))
|
||
actCard.addView(gapBar, blockLp(34))
|
||
gapBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(b: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (fromUser) chainGapValue.text = "${progress * 60 / 100}s"
|
||
}
|
||
override fun onStartTrackingTouch(b: SeekBar?) {}
|
||
override fun onStopTrackingTouch(b: SeekBar?) {
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putInt(FloatingPetService.KEY_CHAIN_GAP, gapBar.progress * 60 / 100).apply()
|
||
askService(FloatingPetService.ACTION_RESUME_CHAIN)
|
||
}
|
||
})
|
||
|
||
val curIdle = FloatingPetService.prefs(this).getInt(FloatingPetService.KEY_IDLE_AFTER, 60)
|
||
idleValue = sliderRow(actCard, "空闲回待机", if (curIdle == 0) "关闭" else "${curIdle}s")
|
||
val idleBar = seekBar((curIdle * 100 / 300).coerceIn(0, 100))
|
||
actCard.addView(idleBar, blockLp(34))
|
||
idleBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(b: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (!fromUser) return
|
||
val v = progress * 300 / 100
|
||
idleValue.text = if (v == 0) "关闭" else "${v}s"
|
||
}
|
||
override fun onStartTrackingTouch(b: SeekBar?) {}
|
||
override fun onStopTrackingTouch(b: SeekBar?) {
|
||
val v = idleBar.progress * 300 / 100
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putInt(FloatingPetService.KEY_IDLE_AFTER, v).apply()
|
||
askService(FloatingPetService.ACTION_RESUME_CHAIN)
|
||
}
|
||
})
|
||
|
||
actCard.addView(divider())
|
||
actCard.addView(actionRow("随机播一个动作") {
|
||
askService(FloatingPetService.ACTION_PLAY_RANDOM)
|
||
})
|
||
actCard.addView(divider())
|
||
actCard.addView(actionRow("回到待机") {
|
||
askService(FloatingPetService.ACTION_PLAY_IDLE)
|
||
})
|
||
col.addView(actCard)
|
||
|
||
col.addView(sectionLabel("贴边间距"))
|
||
val edgeCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
setPadding(dp(16), dp(12), dp(16), dp(10))
|
||
}
|
||
// 预览带:左边竖线 = 屏幕边缘,宠物缩略图按间距移动
|
||
val strip = FrameLayout(this).apply {
|
||
background = rounded(pal.chip, 12, pal.line)
|
||
clipChildren = false
|
||
}
|
||
strip.addView(View(this).apply {
|
||
setBackgroundColor(pal.accent)
|
||
}, FrameLayout.LayoutParams(dp(2), ViewGroup.LayoutParams.MATCH_PARENT).apply {
|
||
gravity = Gravity.START
|
||
})
|
||
edgePet = ImageView(this).apply {
|
||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||
runCatching {
|
||
assets.open("a0058.webp").use {
|
||
setImageBitmap(android.graphics.BitmapFactory.decodeStream(it))
|
||
}
|
||
}
|
||
}
|
||
val petW = dp(110)
|
||
strip.addView(edgePet, FrameLayout.LayoutParams(petW, dp(62)).apply {
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
})
|
||
edgeCard.addView(strip, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, dp(62)
|
||
).apply { bottomMargin = dp(6) })
|
||
edgeCard.addView(TextView(this).apply {
|
||
text = "竖线是屏幕边缘,拖滑条看宠物实际留多少"
|
||
textSize = 11.5f
|
||
setTextColor(pal.sub)
|
||
})
|
||
|
||
val curGap = p.getInt(FloatingPetService.KEY_EDGE_GAP, 0)
|
||
edgeValue = sliderRow(edgeCard, "间距", "${curGap}dp")
|
||
edgeBar = seekBar(((curGap + 20) * 100 / 80).coerceIn(0, 100))
|
||
edgeCard.addView(edgeBar, blockLp(34))
|
||
// 素材左侧有透明留白:预览要把这段扣掉,角色可见边缘才和竖线对齐
|
||
val padL = FloatingPetService.prefs(this)
|
||
.getFloat("sprite_pad_l", 0.34f)
|
||
fun updateEdgePreview(gapDp: Int) {
|
||
edgeValue.text = "${gapDp}dp"
|
||
val shift = dp(gapDp) - padL * petW
|
||
edgePet.translationX = shift
|
||
}
|
||
updateEdgePreview(curGap)
|
||
edgeBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||
override fun onProgressChanged(b: SeekBar?, progress: Int, fromUser: Boolean) {
|
||
if (fromUser) updateEdgePreview(-20 + progress * 80 / 100)
|
||
}
|
||
override fun onStartTrackingTouch(b: SeekBar?) {}
|
||
override fun onStopTrackingTouch(b: SeekBar?) {
|
||
val gap = -20 + edgeBar.progress * 80 / 100
|
||
FloatingPetService.prefs(this@MainActivity).edit()
|
||
.putInt(FloatingPetService.KEY_EDGE_GAP, gap).apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
}
|
||
})
|
||
edgeCard.addView(LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
setPadding(0, dp(6), 0, 0)
|
||
addView(TextView(this@MainActivity).apply {
|
||
text = "0 = 角色刚好贴边"
|
||
textSize = 11.5f
|
||
setTextColor(pal.sub)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
addView(TextView(this@MainActivity).apply {
|
||
text = "负值 = 再压出屏幕"
|
||
textSize = 11.5f
|
||
setTextColor(pal.sub)
|
||
})
|
||
})
|
||
col.addView(edgeCard)
|
||
|
||
col.addView(sectionLabel("接入"))
|
||
val api = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
addrValue = TextView(this).apply {
|
||
text = ENDPOINT
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
typeface = Typeface.MONOSPACE
|
||
}
|
||
api.addView(rowWith("地址", addrValue) { copyText(ENDPOINT) })
|
||
api.addView(divider())
|
||
api.addView(actionRow("复制接入地址") { copyText(ENDPOINT) })
|
||
api.addView(divider())
|
||
api.addView(actionRow("查看接入文档") { openDocs() })
|
||
col.addView(api)
|
||
|
||
col.addView(sectionLabel("推送授权"))
|
||
val authCard = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
val authRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
val pp = FloatingPetService.prefs(this@MainActivity)
|
||
val now = pp.getBoolean(FloatingPetService.KEY_PUSH_AUTH, true)
|
||
pp.edit().putBoolean(FloatingPetService.KEY_PUSH_AUTH, !now).apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
refreshAuthUi()
|
||
}
|
||
}
|
||
authRow.addView(TextView(this).apply {
|
||
text = "仅授权应用可推送"
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
authToggle = TextView(this).apply { textSize = 13f }
|
||
authRow.addView(authToggle)
|
||
authCard.addView(authRow)
|
||
authCard.addView(divider())
|
||
authCard.addView(actionRow("+ 生成授权码") {
|
||
val list = readTokens().toMutableList()
|
||
list.add("应用 ${list.size + 1}" to newToken())
|
||
writeTokens(list)
|
||
refreshAuthUi()
|
||
})
|
||
col.addView(authCard)
|
||
|
||
col.addView(sectionLabel("授权应用"))
|
||
authRows = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
background = rounded(pal.card, 16, pal.line)
|
||
}
|
||
col.addView(authRows)
|
||
|
||
refreshAuthUi()
|
||
return scroll
|
||
}
|
||
|
||
// ---------------- 授权 ----------------
|
||
|
||
private fun newToken(): String =
|
||
java.util.UUID.randomUUID().toString().replace("-", "").take(16)
|
||
|
||
private fun readTokens(): List<Pair<String, String>> {
|
||
val raw = FloatingPetService.prefs(this)
|
||
.getString(FloatingPetService.KEY_PUSH_TOKENS, "[]") ?: "[]"
|
||
return runCatching {
|
||
val arr = JSONArray(raw)
|
||
(0 until arr.length()).map { i ->
|
||
val o = arr.getJSONObject(i)
|
||
o.optString("name", "应用") to o.getString("token")
|
||
}
|
||
}.getOrDefault(emptyList())
|
||
}
|
||
|
||
private fun writeTokens(list: List<Pair<String, String>>) {
|
||
val arr = JSONArray()
|
||
list.forEach { arr.put(JSONObject().put("name", it.first).put("token", it.second)) }
|
||
FloatingPetService.prefs(this).edit()
|
||
.putString(FloatingPetService.KEY_PUSH_TOKENS, arr.toString()).apply()
|
||
askService(FloatingPetService.ACTION_APPLY_APPEARANCE)
|
||
}
|
||
|
||
private fun maskToken(t: String): String =
|
||
if (t.length <= 6) "••••••" else t.take(4) + "••••••" + t.takeLast(2)
|
||
|
||
private fun refreshAuthUi() {
|
||
if (!::authToggle.isInitialized) return
|
||
val require = FloatingPetService.prefs(this)
|
||
.getBoolean(FloatingPetService.KEY_PUSH_AUTH, true)
|
||
authToggle.text = if (require) "已开启" else "已关闭"
|
||
authToggle.setTextColor(if (require) pal.accent else pal.sub)
|
||
|
||
authRows.removeAllViews()
|
||
val list = readTokens()
|
||
if (list.isEmpty()) {
|
||
authRows.addView(TextView(this).apply {
|
||
text = "暂无授权应用"
|
||
textSize = 13.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
})
|
||
return
|
||
}
|
||
list.forEachIndexed { idx, (name, token) ->
|
||
if (idx > 0) authRows.addView(divider())
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(12), dp(16), dp(12))
|
||
isClickable = true
|
||
setOnClickListener { openAppDetail(idx) }
|
||
}
|
||
val colv = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||
colv.addView(TextView(this).apply {
|
||
text = name
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
})
|
||
colv.addView(TextView(this).apply {
|
||
text = maskToken(token)
|
||
textSize = 12.5f
|
||
typeface = Typeface.MONOSPACE
|
||
setTextColor(pal.sub)
|
||
setPadding(0, dp(2), 0, 0)
|
||
})
|
||
row.addView(colv, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
row.addView(chevron())
|
||
authRows.addView(row)
|
||
}
|
||
}
|
||
|
||
private fun openAppDetail(idx: Int) {
|
||
val list = readTokens()
|
||
if (idx !in list.indices) return
|
||
val (name, token) = list[idx]
|
||
var dlg: AlertDialog? = null
|
||
|
||
val box = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(22), dp(6), dp(22), dp(2))
|
||
}
|
||
box.addView(TextView(this).apply {
|
||
text = "应用名称"; textSize = 12.5f; setTextColor(pal.sub)
|
||
})
|
||
val nameInput = EditText(this).apply {
|
||
setText(name)
|
||
setSelection(name.length)
|
||
textSize = 15f
|
||
setSingleLine(true)
|
||
}
|
||
box.addView(nameInput)
|
||
box.addView(TextView(this).apply {
|
||
text = "授权码"; textSize = 12.5f; setTextColor(pal.sub); setPadding(0, dp(14), 0, dp(2))
|
||
})
|
||
box.addView(TextView(this).apply {
|
||
text = token
|
||
textSize = 15f
|
||
typeface = Typeface.MONOSPACE
|
||
setTextColor(pal.text)
|
||
setTextIsSelectable(true)
|
||
setPadding(0, dp(4), 0, dp(6))
|
||
})
|
||
box.addView(TextView(this).apply {
|
||
text = "填进调用方应用配置;不要贴到公开地方。"
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
})
|
||
val btnRow = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
setPadding(0, dp(14), 0, 0)
|
||
}
|
||
btnRow.addView(TextView(this).apply {
|
||
text = "复制授权码"
|
||
textSize = 13.5f
|
||
setTextColor(pal.accent)
|
||
setPadding(dp(12), dp(8), dp(12), dp(8))
|
||
background = rounded(pal.accentSoft, 10)
|
||
isClickable = true
|
||
setOnClickListener { copyText(token) }
|
||
})
|
||
btnRow.addView(TextView(this).apply {
|
||
text = "删除此授权"
|
||
textSize = 13.5f
|
||
setTextColor(pal.danger)
|
||
setPadding(dp(12), dp(8), dp(12), dp(8))
|
||
isClickable = true
|
||
setOnClickListener {
|
||
val l = readTokens().toMutableList()
|
||
if (idx in l.indices) l.removeAt(idx)
|
||
writeTokens(l)
|
||
refreshAuthUi()
|
||
dlg?.dismiss()
|
||
}
|
||
}, LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||
).apply { leftMargin = dp(8) })
|
||
box.addView(btnRow)
|
||
|
||
dlg = AlertDialog.Builder(this)
|
||
.setView(box)
|
||
.setPositiveButton("保存") { d, _ ->
|
||
val newName = nameInput.text.toString().ifBlank { name }
|
||
val l = readTokens().toMutableList()
|
||
if (idx in l.indices) l[idx] = newName to l[idx].second
|
||
writeTokens(l)
|
||
refreshAuthUi()
|
||
d.dismiss()
|
||
}
|
||
.setNegativeButton("取消", null)
|
||
.show()
|
||
}
|
||
|
||
// ---------------- 历史 ----------------
|
||
|
||
private fun refreshHistory() {
|
||
if (!::histRows.isInitialized) return
|
||
histRows.removeAllViews()
|
||
val raw = FloatingPetService.prefs(this)
|
||
.getString(FloatingPetService.KEY_PUSH_HISTORY, "[]") ?: "[]"
|
||
val list = runCatching {
|
||
val arr = JSONArray(raw)
|
||
(0 until arr.length()).map { arr.getJSONObject(it) }
|
||
}.getOrDefault(emptyList())
|
||
val readTs = FloatingPetService.prefs(this)
|
||
.getLong(FloatingPetService.KEY_PUSH_READ_TS, 0L)
|
||
val unread = list.count { it.optLong("ts", 0L) > readTs }
|
||
histUnread.visibility = if (unread > 0) View.VISIBLE else View.GONE
|
||
histUnread.text = "未读 $unread"
|
||
if (list.isEmpty()) {
|
||
histRows.addView(TextView(this).apply {
|
||
text = "暂无通知"
|
||
textSize = 13.5f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(2), dp(12), 0, dp(12))
|
||
})
|
||
return
|
||
}
|
||
val fmt = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||
list.take(6).forEachIndexed { i, o ->
|
||
if (i > 0) histRows.addView(divider())
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(0, dp(11), 0, dp(11))
|
||
}
|
||
row.addView(View(this).apply {
|
||
background = GradientDrawable().apply {
|
||
setColor(levelColor(o.optString("level")))
|
||
cornerRadius = dp(3).toFloat()
|
||
}
|
||
}, LinearLayout.LayoutParams(dp(6), dp(6)).apply { rightMargin = dp(10) })
|
||
row.addView(TextView(this).apply {
|
||
text = o.optString("text").ifBlank { "(表情包)" }
|
||
textSize = 14f
|
||
setTextColor(pal.text)
|
||
maxLines = 1
|
||
ellipsize = TextUtils.TruncateAt.END
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
row.addView(TextView(this).apply {
|
||
text = fmt.format(Date(o.optLong("ts", 0L)))
|
||
textSize = 12f
|
||
setTextColor(pal.sub)
|
||
})
|
||
histRows.addView(row)
|
||
}
|
||
}
|
||
|
||
private fun levelColor(level: String?): Int = when (level?.lowercase()) {
|
||
"error", "failed", "fail" -> 0xFFE5484D.toInt()
|
||
"success", "done" -> 0xFF12B981.toInt()
|
||
"warn", "warning", "waiting" -> 0xFFE0A100.toInt()
|
||
"working", "busy" -> pal.accent
|
||
else -> pal.sub
|
||
}
|
||
|
||
/**
|
||
* 全面屏适配:
|
||
* - 内容延伸到状态栏/手势条下方(edge-to-edge)
|
||
* - 用 insets 给根容器加内边距,避开状态栏、刘海和底部小白条
|
||
* - 系统栏透明,图标明暗跟随日/夜
|
||
*/
|
||
private fun applySystemBars(root: View, night: Boolean) {
|
||
val decor = window.decorView
|
||
var flags = decor.systemUiVisibility
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||
window.setDecorFitsSystemWindows(false)
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
run {
|
||
flags = flags or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||
}
|
||
}
|
||
flags = if (night) {
|
||
flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() and
|
||
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
|
||
} else {
|
||
flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or
|
||
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||
}
|
||
decor.systemUiVisibility = flags
|
||
window.statusBarColor = Color.TRANSPARENT
|
||
window.navigationBarColor = Color.TRANSPARENT
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||
window.isStatusBarContrastEnforced = false
|
||
window.isNavigationBarContrastEnforced = false
|
||
}
|
||
|
||
root.setOnApplyWindowInsetsListener { v, insets ->
|
||
var l = 0; var t = 0; var r = 0; var b = 0
|
||
var imeVisible = false
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||
val i = insets.getInsets(
|
||
WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout()
|
||
)
|
||
l = i.left; t = i.top; r = i.right; b = i.bottom
|
||
// 输入法弹出时把输入框顶上去,否则会被键盘遮住
|
||
val ime = insets.getInsets(WindowInsets.Type.ime()).bottom
|
||
if (ime > b) { b = ime; imeVisible = true }
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
run {
|
||
l = insets.systemWindowInsetLeft
|
||
t = insets.systemWindowInsetTop
|
||
r = insets.systemWindowInsetRight
|
||
b = insets.systemWindowInsetBottom
|
||
}
|
||
}
|
||
v.setPadding(l, t, r, b)
|
||
// 键盘弹出时把对话滚到底,保证最新消息可见
|
||
if (imeVisible) ui.post { scrollChatToBottom() }
|
||
insets
|
||
}
|
||
root.requestApplyInsets()
|
||
}
|
||
|
||
// ---------------- 页签 ----------------
|
||
|
||
private fun selectTab(idx: Int) {
|
||
val i = idx.coerceIn(0, pages.size - 1)
|
||
pages.forEachIndexed { k, p -> p.visibility = if (k == i) View.VISIBLE else View.GONE }
|
||
tabs.forEachIndexed { k, tv ->
|
||
val on = k == i
|
||
tv.setTextColor(if (on) pal.accent else pal.sub)
|
||
tv.typeface = Typeface.create(
|
||
Typeface.DEFAULT, if (on) Typeface.BOLD else Typeface.NORMAL
|
||
)
|
||
}
|
||
selectedTab = i
|
||
layoutTabIndicator(i, animate = true)
|
||
if (i == TAB_STATUS) {
|
||
FloatingPetService.prefs(this).edit()
|
||
.putLong(FloatingPetService.KEY_PUSH_READ_TS, System.currentTimeMillis()).apply()
|
||
refreshHistory()
|
||
refreshStatus()
|
||
refreshMood()
|
||
}
|
||
}
|
||
|
||
/** 指示器宽度 = 单元格宽 - 间距;切换时横向滑动。 */
|
||
private fun layoutTabIndicator(idx: Int, animate: Boolean) {
|
||
if (!::tabIndicator.isInitialized || tabs.size < 2) return
|
||
val row = tabs[0].parent as? View ?: return
|
||
val cellW = row.width / tabs.size
|
||
if (cellW <= 0) {
|
||
row.post { layoutTabIndicator(idx, animate) }
|
||
return
|
||
}
|
||
val targetW = cellW - dp(6)
|
||
val lp = tabIndicator.layoutParams
|
||
if (lp.width != targetW) {
|
||
lp.width = targetW
|
||
lp.height = dp(44)
|
||
tabIndicator.layoutParams = lp
|
||
}
|
||
val targetX = (idx * cellW + dp(3)).toFloat()
|
||
if (animate) {
|
||
tabIndicator.animate()
|
||
.translationX(targetX)
|
||
.setDuration(220)
|
||
.setInterpolator(DecelerateInterpolator())
|
||
.start()
|
||
} else {
|
||
tabIndicator.translationX = targetX
|
||
}
|
||
}
|
||
|
||
// ---------------- 服务 ----------------
|
||
|
||
private fun ensureOverlayAndService() {
|
||
if (!Settings.canDrawOverlays(this)) {
|
||
runCatching {
|
||
startActivity(
|
||
Intent(
|
||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||
Uri.parse("package:$packageName")
|
||
)
|
||
)
|
||
}
|
||
return
|
||
}
|
||
startPet()
|
||
}
|
||
|
||
private fun startPet() {
|
||
runCatching {
|
||
startForegroundService(Intent(this, FloatingPetService::class.java))
|
||
ui.postDelayed({ refreshStatus() }, 900)
|
||
}
|
||
}
|
||
|
||
private fun stopPet() {
|
||
runCatching {
|
||
startService(
|
||
Intent(this, FloatingPetService::class.java)
|
||
.setAction(FloatingPetService.ACTION_STOP_PET)
|
||
)
|
||
ui.postDelayed({ refreshStatus() }, 700)
|
||
}
|
||
}
|
||
|
||
private fun askService(action: String, configure: Intent.() -> Unit = {}) {
|
||
runCatching {
|
||
val i = Intent(this, FloatingPetService::class.java).setAction(action)
|
||
i.configure()
|
||
startService(i)
|
||
}
|
||
}
|
||
|
||
private fun openDocs() {
|
||
runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("$ENDPOINT/docs"))) }
|
||
}
|
||
|
||
private fun refreshStatus() {
|
||
val hasOverlay = Settings.canDrawOverlays(this)
|
||
if (::overlayHint.isInitialized) {
|
||
overlayHint.visibility = if (hasOverlay) View.GONE else View.VISIBLE
|
||
}
|
||
Thread {
|
||
var ok = false; var anim = ""; var clients = 0; var auth = true
|
||
try {
|
||
val h = JSONObject(httpGet("$ENDPOINT/api/health"))
|
||
ok = h.optBoolean("ok", false)
|
||
clients = h.optInt("sseClients", 0)
|
||
auth = h.optBoolean("authRequired", true)
|
||
val s = JSONObject(httpGet("$ENDPOINT/api/state"))
|
||
anim = s.optJSONObject("anim")?.optString("name") ?: ""
|
||
} catch (_: Exception) {
|
||
ok = false
|
||
}
|
||
val fOk = ok; val fAnim = anim; val fClients = clients; val fAuth = auth
|
||
ui.post {
|
||
if (!::statusTitle.isInitialized) return@post
|
||
running = fOk
|
||
(statusDot.background as? GradientDrawable)?.setColor(
|
||
if (fOk) 0xFF12B981.toInt() else pal.danger
|
||
)
|
||
statusTitle.text = if (fOk) "宠物运行中" else "宠物未运行"
|
||
statusSub1.text = if (fOk && fAnim.isNotEmpty()) "当前动作:$fAnim" else "点下方按钮启动"
|
||
statusSub2.text = if (fOk) {
|
||
"接入已开启 · 订阅 $fClients · ${if (fAuth) "需授权码" else "无授权限制"}"
|
||
} else {
|
||
""
|
||
}
|
||
primaryBtn.text = if (fOk) "收起宠物" else "显示宠物"
|
||
primaryBtn.background = if (fOk) {
|
||
ripple(pal.chip, 12, Color.argb(0x14, 0, 0, 0))
|
||
} else {
|
||
ripple(pal.accent, 12, Color.argb(0x40, 255, 255, 255))
|
||
}
|
||
primaryBtn.setTextColor(if (fOk) pal.text else Color.WHITE)
|
||
}
|
||
}.start()
|
||
}
|
||
|
||
/** 刷新状态页顶部的心情卡。 */
|
||
private fun refreshMood() {
|
||
if (!::moodValue.isInitialized) return
|
||
val st = MoodStore.load(this)
|
||
val m = st.mood.toInt()
|
||
moodValue.text = "$m / 100"
|
||
val label = when {
|
||
m >= 85 -> "心情特别好"
|
||
m >= 70 -> "心情不错"
|
||
m >= 50 -> "心情平平"
|
||
m >= 30 -> "有点低落"
|
||
else -> "心情很差"
|
||
}
|
||
val color = when {
|
||
m >= 70 -> 0xFF12B981.toInt()
|
||
m >= 50 -> pal.accent
|
||
m >= 30 -> 0xFFE0A100.toInt()
|
||
else -> pal.danger
|
||
}
|
||
moodValue.setTextColor(color)
|
||
(moodFill.background as? android.graphics.drawable.GradientDrawable)?.setColor(color)
|
||
moodTrack.post {
|
||
val w = (moodTrack.width * (m.coerceIn(0, 100) / 100f)).toInt()
|
||
moodFill.layoutParams = (moodFill.layoutParams as FrameLayout.LayoutParams).apply { width = w }
|
||
moodFill.requestLayout()
|
||
}
|
||
val top = st.emotions.entries.filter { it.value > 1.0 }
|
||
.sortedByDescending { it.value }.take(4)
|
||
moodEmotions.text = if (top.isEmpty()) "情绪:平静" else
|
||
"情绪:" + top.joinToString(" · ") { (k, v) ->
|
||
(MoodStore.EMOTIONS.firstOrNull { it.first == k }?.second ?: k) + " " + v.toInt()
|
||
}
|
||
moodReason.text = if (st.lastReason.isBlank()) "最近没什么波动" else "最近:" + st.lastReason
|
||
}
|
||
|
||
private fun httpGet(url: String): String {
|
||
val conn = URL(url).openConnection() as HttpURLConnection
|
||
conn.connectTimeout = 1500
|
||
conn.readTimeout = 1500
|
||
conn.requestMethod = "GET"
|
||
return conn.inputStream.bufferedReader().use { it.readText() }
|
||
}
|
||
|
||
// ---------------- 小部件 ----------------
|
||
|
||
private fun rounded(bg: Int, radius: Int, border: Int? = null): GradientDrawable =
|
||
GradientDrawable().apply {
|
||
setColor(bg)
|
||
cornerRadius = dp(radius).toFloat()
|
||
if (border != null) setStroke(dp(1), border)
|
||
}
|
||
|
||
private fun ripple(bg: Int, radius: Int, rippleColor: Int): RippleDrawable =
|
||
RippleDrawable(ColorStateList.valueOf(rippleColor), rounded(bg, radius), null)
|
||
|
||
private fun divider() = View(this).apply {
|
||
setBackgroundColor(pal.line)
|
||
}.also {
|
||
it.layoutParams = LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, dp(1)
|
||
)
|
||
}
|
||
|
||
private fun chevron() = TextView(this).apply {
|
||
text = "›"
|
||
textSize = 18f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(10), 0, dp(2), 0)
|
||
}
|
||
|
||
private fun actionRow(text: String, onClick: () -> Unit) = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(15), dp(16), dp(15))
|
||
isClickable = true
|
||
setOnClickListener { onClick() }
|
||
addView(TextView(this@MainActivity).apply {
|
||
this.text = text
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
addView(chevron())
|
||
}
|
||
|
||
private fun rowWith(label: String, valueView: TextView, onClick: () -> Unit) =
|
||
LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(16), dp(14), dp(16), dp(14))
|
||
isClickable = true
|
||
setOnClickListener { onClick() }
|
||
addView(TextView(this@MainActivity).apply {
|
||
text = label
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
})
|
||
addView(View(this@MainActivity), LinearLayout.LayoutParams(0, 1, 1f))
|
||
addView(valueView)
|
||
addView(chevron())
|
||
}
|
||
|
||
private fun sliderRow(parent: LinearLayout, name: String, valueText: String): TextView {
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(0, dp(14), 0, 0)
|
||
}
|
||
row.addView(TextView(this).apply {
|
||
text = name
|
||
textSize = 15f
|
||
setTextColor(pal.text)
|
||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||
val v = TextView(this).apply {
|
||
text = valueText
|
||
textSize = 14f
|
||
setTextColor(pal.accent)
|
||
}
|
||
row.addView(v)
|
||
parent.addView(row)
|
||
return v
|
||
}
|
||
|
||
private fun seekBar(progress: Int) = SeekBar(this).apply {
|
||
max = 100
|
||
this.progress = progress.coerceIn(0, 100)
|
||
progressTintList = ColorStateList.valueOf(pal.accent)
|
||
thumbTintList = ColorStateList.valueOf(pal.accent)
|
||
progressBackgroundTintList = ColorStateList.valueOf(pal.track)
|
||
// 左右各留出拇指半径,避免拖到 0%/100% 时被卡片裁掉
|
||
setPadding(dp(16), 0, dp(16), 0)
|
||
splitTrack = false
|
||
}
|
||
|
||
private fun blockLp(h: Int) = LinearLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT, dp(h)
|
||
)
|
||
|
||
private fun sectionLabel(text: String) = TextView(this).apply {
|
||
this.text = text
|
||
textSize = 13f
|
||
setTextColor(pal.sub)
|
||
setPadding(dp(4), dp(20), 0, dp(8))
|
||
}
|
||
|
||
private fun copyText(text: String) {
|
||
runCatching {
|
||
val cm = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||
cm.setPrimaryClip(ClipData.newPlainText("dsh-pet-float", text))
|
||
}
|
||
}
|
||
|
||
private fun dp(v: Int) = (v * resources.displayMetrics.density).toInt()
|
||
} |