Files
dsh-pet-floating/app/src/main/java/com/dsh/petfloat/MainActivity.kt
T
mtfyun 7d2e8fed8e 小鲸娘 v1.0:悬浮宠物完整工程
- 106 动画 + 2366 表情包 + 语义标签
- 三层记忆 / 心情系统 / 9 可配置插件 / 10 技能
- DeepSeek 对话直连 + MCP/HTTP 接入 + 接入文档
- 动画随机链 + 物理引擎 + 贴边碰撞校准
- 大资源(动画webp/表情图)由 scripts/ 再生成,不入库
2026-11-08 08:07:09 +00:00

2620 lines
106 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}