小鲸娘 v1.0:悬浮宠物完整工程

- 106 动画 + 2366 表情包 + 语义标签
- 三层记忆 / 心情系统 / 9 可配置插件 / 10 技能
- DeepSeek 对话直连 + MCP/HTTP 接入 + 接入文档
- 动画随机链 + 物理引擎 + 贴边碰撞校准
- 大资源(动画webp/表情图)由 scripts/ 再生成,不入库
This commit is contained in:
mtfyun committed 2026-11-08 08:07:09 +00:00
commit 7d2e8fed8e
135 files changed
+8416

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# ==== 构建产物 ====
.gradle/
build/
app/build/
*.apk
*.aab
# ==== 机器相关 ====
local.properties
*.iml
.idea/
.DS_Store
captures/
# ==== 日志与临时 ====
*.log
/tmp/
*.tmp
# ==== 可再生大体积资源(由 scripts/ 重建,详见 README「素材再生成」)====
# 动画 webp(从 dsh-pet webm 转码,537MB 两套)→ scripts/regenerate_assets.sh
app/src/main/assets_hq/
app/src/main/assets_small/
# 表情包图(从梗鲸档案馆下载,114MB)→ scripts/download_memes.py
app/src/main/assets_net/
# ==== 原始下载与临时产物 ====
/memes-import/
/new-memes/
/docs-preview/
+84
View File
@@ -0,0 +1,84 @@
# DeepSeek Hermes 插件 → 小鲸娘插件 转换规范
> 给应用内「对话」用:把这份规范贴进对话,或直接说「把下面这个 Hermes 插件转成小鲸娘插件」,
> AI 会输出 `<<<PET_PLUGIN id="...">>>` 代码块,应用自动安装。
## 一、两边是什么
| | Hermes Agent 插件 | 小鲸娘插件 |
|---|---|---|
| 语言 | Python | JavaScript(跑在宠物 WebView 里) |
| 形态 | 目录 + `plugin.yaml` + `register()` | 一段 JS 代码 |
| 能力 | 注册工具、hook、web provider、设置项 | 说话/通知/播动作/弹表情/执行技能/监听互动 |
| 安装 | `hermes plugins install` | 对话里给代码,或 `POST /api/plugin` |
**结论**:Hermes 插件不能原样搬进来(没有 Python 宿主),但**行为可以翻译**。
## 二、能力对照表
| Hermes 里的东西 | 小鲸娘里怎么写 |
|---|---|
| `on_startup()` / 顶层初始化 | 把初始化代码直接写在插件顶层 |
| 定时任务 / cron hook | `setInterval(() => Pet.say('…'), 3600000)` |
| 工具 `tool_xxx()` 被调用时通知 | `Pet.notify('done', '任务完成')` |
| 打印/汇报消息 | `Pet.say('文字', 6000)` |
| 触发动画 | `Pet.anim({tag:'happy'})` / `{name:'…'}` / `{random:true}` |
| 发图片/表情 | `Pet.meme({tag:'praise'})` |
| 组合动作 | `Pet.skill('work_done')` |
| 监听宠物互动 | `Pet.on('tap', fn)`、`Pet.on('drag_end', fn)`、`Pet.on('long_press', fn)` |
| 读宠物状态 | `Pet.status()`(返回 Promise) |
| HTTP 请求 | `fetch()` 可用(WebView 有网络权限) |
| 设置项 / 环境变量 | 写成代码顶部的 `var CONFIG = {...}`;**不要把密钥写进插件** |
## 三、不能转换的
- 注册 LLM provider / 修改 agent 推理行为(那属于宿主,小鲸娘不跑模型路由)
- 依赖 Python 库(numpy/httpx/…):只能用 JS 和 `fetch`
- 依赖 Hermes 的配置目录、secrets、工具注册表
- 需要拦截「另一个程序内部事件」的 hook —— 改由对方通过 HTTP 调小鲸娘
## 四、输出格式(AI 必读)
需要安装插件时,**只输出**下面这种块,应用会自动安装并从可见消息里剥离:
```
<<<PET_PLUGIN id="插件id(字母数字下划线)">>>
// 这里写 JS
setInterval(function () { Pet.say('该喝水啦~'); }, 3600000);
<<<END>>>
```
规则:
- `id` 必填,只能 `[A-Za-z0-9_-]`,重复会覆盖
- 代码里只用 `Pet.*` 与浏览器标准 API
- 安装后回一句简短说明(装了啥、什么时候触发)
## 五、三个转换例子
**1) Hermes 定时提醒插件**
```python
# Hermes
def on_startup(ctx):
ctx.schedule_interval(3600, lambda: ctx.notify("该喝水啦"))
```
```javascript
// 小鲸娘
setInterval(function () { Pet.say('该喝水啦~'); }, 3600000);
```
**2) Hermes 工具结果通知**
```javascript
// 小鲸娘:被戳时报告一次状态
Pet.on('tap', function () {
Pet.status().then(function (s) {
Pet.say('现在是:' + (s.anim ? s.anim.name : '待机'));
});
});
```
**3) Hermes web 搜索插件(只能部分转换)**
注册 provider 那部分转不了;能保留的是「用完给个反馈」:
```javascript
// 收到外部通知后弹个表情(由 Hermes 侧 POST /api/task 触发)
Pet.meme({ tag: 'praise' });
```
+318
View File
@@ -0,0 +1,318 @@
# 桌宠推送接入文档(workbuddy / DeepSeek 等)
桌宠在设备本机开了一个 HTTP 服务,任何 App 只要拿到**授权码**就能向它推送任务通知。
- 地址:`http://127.0.0.1:7350`
- 授权码:在桌宠面板「接入 → 推送授权」里查看/复制/生成(可多个、可单独吊销)
- 鉴权:写请求必须带 `X-Pet-Token: <码>`(也支持 `?token=<码>`)
- 只读接口 `GET /api/state`、`GET /api/health` 无需授权
> 调用方 App 需允许明文 HTTP:`android:usesCleartextTraffic="true"`,
> 或 network-security-config 放行 `127.0.0.1`。
---
## 接口一览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/health` | 应用信息、动画数、`authRequired`、端点清单 |
| GET | `/api/state` | 当前动画、气泡、朝向 |
| POST | `/api/task` | 任务状态通知(推荐) |
| POST | `/api/notify` | 通用通知(按 level 或 state) |
| POST | `/api/anim` | 点播任意动画(中文名) |
| POST | `/api/broadcast` | 只说一句话,不改动画 |
| POST | `/api/clear` | 清空气泡 |
### POST /api/task
```json
{ "state": "working", "text": "正在生成周报…", "durationMs": 8000, "meme": "m09" }
```
`state` → 动画映射:
| state | 动画 |
|---|---|
| `start` / `thinking` | 工作状态-思考冒泡 |
| `working` | 工作状态-忙碌点按 |
| `result` | 工作状态-清点归档 |
| `waiting` | 工作状态-原地踱步张望 |
| `done` / `success` | 工作状态-雀跃庆祝 |
| `error` / `failed` | 工作状态-垂头叹气冒汗 |
### POST /api/notify
```json
{ "level": "error", "text": "构建失败", "durationMs": 10000, "meme": "m24" }
```
`level` 可选:`info` `result` `working` `waiting` `success` `error`。
`durationMs` 期间宠物会保持这个动作,随机动画链不会覆盖。
`meme` 可选,取值 `m01`~`m27`(气泡里的表情包,见面板动作页预览)。
---
## 调用示例
### curl
```bash
TOKEN=2caa9cb98fea488e
curl -X POST http://127.0.0.1:7350/api/task \
-H 'Content-Type: application/json' \
-H "X-Pet-Token: $TOKEN" \
-d '{"state":"working","text":"正在生成周报…"}'
```
### Kotlin(无第三方依赖)
```kotlin
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
fun pushToPet(token: String, state: String, text: String, durationMs: Long = 8000) {
Thread {
runCatching {
val conn = (URL("http://127.0.0.1:7350/api/task").openConnection() as HttpURLConnection)
conn.requestMethod = "POST"
conn.doOutput = true
conn.connectTimeout = 2000
conn.readTimeout = 2000
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("X-Pet-Token", token)
val body = JSONObject()
.put("state", state)
.put("text", text)
.put("durationMs", durationMs)
.toString()
conn.outputStream.use { it.write(body.toByteArray()) }
conn.responseCode // 200 = 成功,401 = 授权码不对
conn.disconnect()
}
}.start()
}
// 用法
pushToPet(token = "2caa9cb98fea488e", state = "working", text = "正在整理对话…")
pushToPet(token = "2caa9cb98fea488e", state = "done", text = "整理完成 ✅")
pushToPet(token = "2caa9cb98fea488e", state = "error", text = "请求失败:超时")
```
### JavaScript / Node(workbuddy 若是前端/Node)
```js
async function pushToPet(token, state, text, durationMs = 8000) {
const res = await fetch('http://127.0.0.1:7350/api/task', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Pet-Token': token },
body: JSON.stringify({ state, text, durationMs })
});
if (!res.ok) console.warn('pet push failed', res.status, await res.text());
}
```
---
## 排错
| 现象 | 原因 |
|---|---|
| `401 unauthorized` | 授权码不对 / 该应用被删除 / 面板里授权已关闭 |
| 连接被拒绝 | 桌宠没在运行(服务随宠物存活) |
| `404 not-found` | 路径写错(注意是 `/api/task` 不是 `/task`) |
| 调用成功但没反应 | 宠物面板「外观 → 距离/透明度」设得太小看不见;或气泡已超时消失 |
| `cleartext not permitted` | 调用方 App 没开明文 HTTP |
---
## 行为约定
- 通知展示时长内(`durationMs`)随机动画链暂停,保证通知动作完整播完。
- 同一时刻只有一条气泡,后到的覆盖前面的。
- 桌宠本身不存储通知历史(当前版本),错过了就没了。
---
# 语义标签(AI 调用推荐方式)
每个**动作**和**表情包**都带内部标签。AI 不必记中文名,按语义调用即可。
## 标签表
| 标签 | 含义 | 典型动作 / 表情包 |
|---|---|---|
| `work` `workstatus` | 工作状态 | 思考冒泡 / 忙碌点按 / 清点归档 / 踱步张望 / 雀跃庆祝 / 垂头叹气冒汗 |
| `thinking` `working` `result` `waiting` `success` `error` | 六档细分 | 对应上面六种工作状态 |
| `happy` `celebrate` `praise` | 开心/庆祝/夸奖 | 雀跃庆祝、开心跃动、厉害了我的鲸、可爱 |
| `sad` `error` `fail` | 沮丧/出错 | 垂头叹气冒汗、死掉了、完蛋了把黄油删了 |
| `busy` `stress` | 忙碌/压力 | 忙碌点按、压力一只大肥鱼、大烧货 |
| `eat` `food` | 吃东西 | 吃什么分类全部(12 个) |
| `play` `fun` | 玩耍/搞笑 | 玩耍分类(27 个)+ Ciallo、就骚了怎么滴吧 |
| `rest` `sleep` `lazy` | 休息/摸鱼 | 待机呼吸、小憩沉眠、哈欠连天、深度睡眠 |
| `festival` `season` | 节日/时令 | 时节分类(21 个:春节/中秋/圣诞/万圣…) |
| `money` `token` `need` | 余额/token | 余额动画 6 档、token给妈妈保管、鱼片有资源吗 |
| `warn` `urgent` `alert` | 预警/紧急 | 大的要来啦、大的药来了、电死你 |
| `greet` `wave` `hello` | 打招呼 | 元气挥手、Ciallo |
| `shy` `surprise` `angry` `tsundere` | 情绪 | 害羞惊讶、傲娇生气 |
| `drag` `click` `move` `turn` `idle` `look` `walk` `picked` | 交互池 | 拖拽悬空、点击回应、移动走路、转向张望、待机 |
| `text` `talk` `whisper` | 文字/说话 | 文字分类 2 个、碎碎念 3 个 |
| `category` + 分类名 | 分类归属 | 小动作 / 玩耍 / 吃什么 / 时节 / 文字 |
## 新增 / 增强的接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/catalog` | 返回全部动作与表情包及其标签(免授权) |
| POST | `/api/meme` | `{tag|name|file}` 头顶弹一张表情包 |
| POST | `/api/anim` | 现在支持 `{tag:"play"}` 按标签点播 |
| POST | `/api/task` `/api/notify` | 新增 `withMeme:true` 或 `memeTag:"success"` 自动配图 |
### 典型用法
```bash
# 开始干活:播「忙碌点按」+ 自动配工作风表情包
curl -X POST http://127.0.0.1:7350/api/task \
-H 'Content-Type: application/json' -H 'X-Pet-Token: 你的授权码' \
-d '{"state":"working","text":"正在生成周报…","withMeme":true}'
# 干完了:播「雀跃庆祝」+ 夸奖表情包
curl -X POST http://127.0.0.1:7350/api/task \
-H 'Content-Type: application/json' -H 'X-Pet-Token: 你的授权码' \
-d '{"state":"done","text":"周报已生成 ✅","memeTag":"praise"}'
# 出错了
curl -X POST http://127.0.0.1:7350/api/task \
-H 'Content-Type: application/json' -H 'X-Pet-Token: 你的授权码' \
-d '{"state":"error","text":"构建失败","withMeme":true}'
# 随手玩一下
curl -X POST http://127.0.0.1:7350/api/anim -H 'Content-Type: application/json' \
-H 'X-Pet-Token: 你的授权码' -d '{"tag":"play"}'
curl -X POST http://127.0.0.1:7350/api/meme -H 'Content-Type: application/json' \
-H 'X-Pet-Token: 你的授权码' -d '{"tag":"fun"}'
# 让 AI 自己发现可用标签
curl http://127.0.0.1:7350/api/catalog
```
## 自动配图规则
`withMeme:true` 时会按状态自动挑标签:
`done/success → success,praise`;`error → error,fail`;`working → working,busy`;
`waiting → warn`;`start/thinking → working,busy`。
## 表情包标签(随图入库)
表情包最终以 `config.json` 里每条的 `tags` 为准(官方 27 张 + 动画帧生成 24 张 + 梗鲸档案馆收录)。
常用梗图情绪标签:`angry` `sad` `happy` `praise` `thinking` `fun` `confused` `complain`
`love` `money` `waiting` `greet` `eat` `work` `error` `rest`。
- `GET /api/catalog` 返回每张表情包的 `tags`
- `POST /api/meme {"tag":"..."}` 按标签随机弹一张
- `POST /api/task {"state":"working","withMeme":true}` 按状态自动配图
- 应用内长按表情包可隐藏(写进 `pet_prefs.hidden_memes`)
## 动作节奏与随机
| 接口 | 说明 |
|---|---|
| `POST /api/settings {"chainGapSeconds":8,"idleAfterSeconds":120}` | 切换间隔(0–60s)、空闲回待机(0=关闭,最多 3600s) |
| `POST /api/anim {"random":true}` | 随机播一个动作(排除事件池) |
| `POST /api/anim {"idle":true}` | 回到待机动作(待机池) |
| `GET /api/health` | 返回 `memes` / `tags` / `catalog` / `docs*` 等入口 |
应用内也有:设置 → 动作(切换间隔、空闲回待机、随机播一个动作、回到待机)。
## 推送与动作节奏
推送(`/api/task`、`/api/notify`、`/api/anim`、`/api/meme`、`/api/broadcast`)现在也纳入动作节奏循环:
- 推送的动画会完整播放(占满其时长)
- 之后额外等待「切换间隔」(`chainGapSeconds`)才继续随机动作链
- 推送会重置「空闲回待机」计时(视为有活动)
- 多次推送连续到达时,以最后一条的时长重新排程
## 扩展能力:技能 / 插件 / MCP
### 技能(Skills)
声明式动作序列,一次调用播一串。
| 接口 | 说明 |
|---|---|
| `GET /api/skills` | 列出内置技能 |
| `POST /api/skill {"id":"work_done"}` | 执行;可带 `text` 覆盖首个气泡文字 |
内置:`work_start` 开工 · `work_done` 收工 · `work_error` 出错了 · `slack` 摸鱼 · `meal` 干饭 · `greet` 打招呼 · `sleep` 睡觉 · `report` 汇报。
应用内:动作页顶部「技能」卡片点一下即触发。
### 插件(JS)
| 接口 | 说明 |
|---|---|
| `POST /api/plugin {"id":"x","code":"..."}` | 安装/覆盖插件(存 App 私有目录) |
| `GET /api/plugins` | 已装插件列表 |
| `POST /api/plugin/remove {"id":"x"}` | 卸载 |
| `GET /api/plugins.js` | 合并后的插件代码(宠物 WebView 自动加载) |
插件运行在宠物 WebView 里,可用桥接 `Pet`:
`Pet.say(text, ms)`、`Pet.notify(state, text, withMeme)`、`Pet.anim({tag}|{name}|{random:true})`、
`Pet.meme({tag}|{name})`、`Pet.skill(id)`、`Pet.status()`、`Pet.on(evt, fn)`。
示例插件:`{"id":"clock","code":"setInterval(function(){Pet.say('整点啦~')},3600000)"}`
### MCP(Model Context Protocol)
本机 MCP Server:`POST http://127.0.0.1:7350/mcp?token=授权码`(JSON-RPC 2.0)
- `initialize` → protocolVersion `2024-11-05`
- `tools/list` →
`pet_status` `pet_say` `pet_notify` `pet_play` `pet_meme` `pet_skill` `pet_catalog`
- `tools/call` → `{name, arguments}`,返回 `content[0].text`(JSON 字符串)
```bash
curl -X POST 'http://127.0.0.1:7350/mcp?token=你的授权码' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pet_notify","arguments":{"state":"working","text":"MCP 调用测试","withMeme":true}}}'
```
## 内置插件(首启自动安装)
| id | 名称 | 行为 |
|---|---|---|
| `greet_daily` | 时段问候 | 早/中/下午/晚各问候一次(按天去重) |
| `water_reminder` | 喝水提醒 | 每小时一句补水提醒 |
| `sit_reminder` | 久坐提醒 | 每 45 分钟提醒起身 |
| `clock_chime` | 整点报时 | 整点轻声报时 |
| `tap_counter` | 戳戳吐槽 | 连戳 5 次吐槽一句 |
| `random_meme` | 随机表情包 | 每 2 小时随机弹一张 |
全部 23:00–07:00 静默。设置 → 内置插件 可逐个开关;`GET /api/plugins` 查已装列表。
技能新增:`hydrate` 喝水 · `stretch` 伸懒腰(共 10 个)。
### 新增内置插件(v2)
| id | 名称 | 行为 |
|---|---|---|
| `fish_memory` | 鱼的记忆 | 七秒记忆梗:约 25 分钟一次有 35% 概率忘事;被戳 12% 概率问「刚才说什么来着」 |
| `fish_mood` | 鱼的心情 | 心情值 0–100,戳+4、被甩-1,按小时自然衰减;每 90 分钟按心情说一句并配 happy/sad 动作 |
| `proactive_talk` | 主动搭话 | 40–80 分钟随机主动说一句(关心/闲聊/白米饭),夜间静默 |
## 插件配置(v3)
内置插件支持参数配置:设置 → 扩展 → 插件 → 点某个插件 → 滑条/开关 → 保存(自动按新配置重装)。
存储:`pet_prefs.plugin_cfg_<id>`(JSON),安装时以 `var CFG = {...};` 注入代码前缀。
| 插件 | 可调参数 |
|---|---|
| 时段问候 | 夜间免打扰、启动延迟(0–60s) |
| 喝水提醒 / 久坐提醒 | 提醒间隔(15–240 / 15–180 分钟)、夜间免打扰 |
| 整点报时 | 只报 7–23 点 |
| 戳戳吐槽 | 每戳几次吐槽(2–20) |
| 随机表情包 | 间隔(30–480 分钟)、夜间免打扰 |
| 鱼的记忆 | 间隔(10–120 分钟)、触发概率(0–100%)、被戳概率 |
| 鱼的心情 | 表达间隔(30–240 分钟)、被戳加点(0–20)、初始心情 |
| 主动搭话 | 最短/最长间隔(分钟)、夜间免打扰 |
自定义插件同样能用 `CFG`:安装时把常量写成 `var CFG = {...}` 前缀即可。
+182
View File
@@ -0,0 +1,182 @@
# 悬浮宠物(com.dsh.petfloat)工程备忘
## 用户偏好(重要)
- **默认一律构建并交付「原画质」包(640×360,`-Phq`)**,不要再默认出小体积包。
- 原因:pilot 的 `app.install` 有 **128MB 硬上限**(见 `/opt/pilot/USAGE.md`),
原画质包 ~427MB 无法通过 pilot 安装 → **由用户手动安装**。
- 小体积包(320×180,~118MB)仅作为「需要我远程直装调试」时的临时手段,不作为交付物。
## 素材目录(已拆分)
| 目录 | 内容 |
|---|---|
| `app/src/main/assets_common` | `config.json`、`candy.ttf`、`pet.html`、`m01..m27.webp`(表情包) |
| `app/src/main/assets_small` | `a0001..a0106.webp`(320×180, q68) |
| `app/src/main/assets_hq` | `a0001..a0106.webp`(640×360, q80,原画质) |
`app/build.gradle.kts` 按 `-Phq` 选择 small / hq:
```bash
# 原画质(交付用)
JAVA_HOME=/opt/jdk17 /opt/gradle/gradle-8.5/bin/gradle assembleDebug -Phq
# 小体积(仅用于 pilot 直装调试)
JAVA_HOME=/opt/jdk17 /opt/gradle/gradle-8.5/bin/gradle assembleDebug
```
产物统一复制为 `petfloat-hq.apk` / `petfloat-small.apk`,HQ 通过 present 交给用户手动安装。
## 动画素材来源
`/root/projects/.dsh/profiles/web/node_modules/dsh-pet/assets/webm/*.webm`(106 个,VP9-alpha,10.042s;
「工作状态-垂头叹气冒汗」为 4.917s)。
转码(本机 ffmpeg 的 webp **解码器**不支持动画 webp,只能编码,校验用 RIFF 解析):
```bash
ffmpeg -y -c:v libvpx-vp9 -i in.webm \
-vf scale=640:360 -c:v libwebp_anim -q:v 80 -loop 0 -pix_fmt yuva420p -an out.webp
```
## 已知限制
- `app.install` 单文件 ≤128MB,且每次都要用户在手机确认。
- pilot 审批卡只在 60 秒窗口内有效;安装前需用户在看手机。
- 动画池/权重/物理参数全部来自 `assets_common/config.json`(由 dsh `config.jsonc` 生成)。
## 对外接入端口(供 workbuddy / DeepSeek 等调用)
本机 HTTP 服务监听 `127.0.0.1:7350`(仅回环,设备内任意 App 可访问)。
控制面板「接入」页可查看地址并复制。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/health` | 返回应用信息与端点清单 |
| POST | `/api/notify` | `{state\|level, text, durationMs, meme}` 推通知 |
| POST | `/api/task` | `{state: start\|working\|result\|waiting\|done\|error, text}` |
| POST | `/api/anim` | `{name, holdMs?}` 点播任意动画(中文名) |
| POST | `/api/broadcast` | `{text, duration, meme}` 仅气泡 |
| POST | `/api/clear` | 清空气泡 |
| GET | `/api/state` | 当前动画与气泡状态 |
状态 → 动画档位:start/thinking→思考冒泡,working→忙碌点按,result→清点归档,
waiting→原地踱步张望,done/success→雀跃庆祝,error→垂头叹气冒汗。
`level` 取值 info/success/warn/error/working/result。
通知会设置 `holdUntil`,随机动画链在展示时长内不会覆盖它。
示例:
```bash
curl -X POST http://127.0.0.1:7350/api/notify \
-H 'Content-Type: application/json' \
-d '{"state":"done","text":"任务完成:报告已生成"}'
```
调用方需允许明文 HTTP(`usesCleartextTraffic` 或 network-security-config 放行 127.0.0.1)。
## 气泡(独立悬浮窗)
- 气泡是**独立 overlay 窗**(`FLAG_NOT_TOUCHABLE`),浮在宠物窗口上方,
位置 = 宠物窗口顶边 - 气泡高度 + 6dp,水平居中,自动夹在安全区内;宠物移动时跟随。
- 因此气泡不会被宠物窗口裁切(此前 240×135dp 窗口内的 HTML 气泡会被截断)。
- 样式存 `pet_prefs/bubble_style`,可选 `default|dark|outline|note`,
由 `BubbleDrawable`(圆角矩形 + 底部三角尾巴)绘制;面板「外观 → 气泡样式」切换。
- 宠物窗口 HTML(pet.html)现在只画宠物本体。
## 自助接入文档(端口自带)
- `GET /docs` —— 人看的 HTML 文档页:地址/鉴权/在线测试表单/接口表/状态映射/错误码/代码示例
- `GET /api/docs` —— 机器可读 JSON 规范:endpoints[] / states / levels / errors / auth
- 已支持 **CORS 预检**(OPTIONS → 204,允许 `Content-Type, X-Pet-Token`),
浏览器/WKWebView 类应用可直接 fetch 调用。
## 互动事件流(SSE)
`GET /api/events?token=<授权码>` 返回 `text/event-stream`,每 15s 心跳 `: ping`。
事件类型(`event: pet`,`data` 为 JSON):
| type | 触发 | 额外字段 |
|---|---|---|
| tap | 用户点按宠物 | anim |
| drag_start | 开始拖拽 | x, y |
| drag_end | 松手 | fling(bool), x, y / vx, vy |
| long_press | 长按打开面板 | - |
| play | 点播动画(面板或 /api/anim) | name |
| notify | 推送通知 | state, level, text |
| bubble | 只发气泡 | text |
| bubble_clear | 清空气泡 | - |
| anim | 随机链换动作 | name, kind |
`EventSource` 不能设请求头,所以 SSE 用 `?token=` 传授权码。
## 应用主界面(MainActivity)
- 以前 MainActivity 启动服务后立刻 `finish()`,点图标「像没反应」。现在改成真正的首页:
状态卡(运行中/未运行 + 当前动作 + 订阅数 + 是否需授权码)、显示/收起宠物、
控制面板入口、复制接入地址、打开接入文档、最近通知(4 条)、版本信息。
- 首次进入自动拉起宠物(需已授予悬浮窗权限,否则显示授权入口)。
- 主题改为 `Theme.Material.Light.NoActionBar`。
- 状态通过轮询 `/api/health` + `/api/state` 获取(2s 一次,页面不可见时停止)。
## 交付方式:手机共享目录(推荐)
`/root/share/DSHBox` 是手机内部存储 `DSHBox/` 的 bind 挂载(`/storage/emulated/0/DSHBox`),
构建好的 APK 直接放进去,手机上用文件管理器点开即可安装,**不受 pilot 128MB 限制**。
```bash
./build.sh # 原画质 → petfloat-hq.apk,并自动复制到 /root/share/DSHBox/
./build.sh small # 小体积 → petfloat-small.apk
```
手机端路径:`内部存储/DSHBox/petfloat-hq.apk`(首次安装需允许「安装未知应用」)。
## 界面合并(v1.0 后期)
- **SettingsActivity 已删除**,所有设置并入 `MainActivity` 的 4 个页签:
状态 / 外观 / 动作 / 接入。
- 长按宠物 → 直接打开 `MainActivity`(默认落在「外观」页,`EXTRA_TAB=TAB_LOOK`)。
- 点通知 → 打开 `MainActivity`(「状态」页)。
- `MainActivity` 为 `launchMode=singleTop`,重复打开走 `onNewIntent` 切换页签。
- 授权码仍在「接入 → 推送授权」列表的**二级弹窗**里查看/复制/改名/删除。
## UI 方向(最终)
- **参考 DeepSeek App**:白底分组列表(Inset Grouped)+ 细分割线 + 右侧 chevron + 品牌蓝 `#4D6BFE`。
- **不再做液态玻璃**(试过 A/B/C 三版 + 奶白玻璃,用户否决)。
- 三个页签:**状态 / 动作 / 设置**(底部浮动胶囊,纯文字,选中为浅蓝底 + 品牌蓝加粗)。
- 跟随系统浅色/深色(`Configuration.UI_MODE_NIGHT_MASK`,浅 #F7F8FA/白卡,深 #121316/#1B1D21)。
- 字体统一用系统字体,只有品牌大标题可用软糖体。
- 长按宠物 → 主界面「设置」页;点通知 → 「状态」页。
## 碰撞边界(重要)
1. `getRealMetrics` 取真实屏幕(1440×3200),且系统对悬浮窗有 `offsetY=169px`(状态栏)偏移,
用 `getLocationOnScreen` 自校准得到 `offsetX/offsetY`,碰撞边界据此补偿。
2. **素材本身带大量透明留白**(实测 l≈33.8%、r≈34.1%、t≈17.8%、b≈11.7%),
所以窗口贴边时角色仍离边很远。解法:`pet.html` 用 canvas 扫码 alpha 通道量出边距,
`POST /sprite` 上报(免鉴权,服务端取各动画的最大值),Service 允许窗口**超出屏幕**这些距离,
使角色真正贴边。
3. 旋转/折叠由 `onConfigurationChanged` 重算边界并夹回。
## 物理引擎(v2)
- 真实帧间隔积分(`System.nanoTime`,dt 上限 0.05s 防卡顿跳跃)
- 半隐式欧拉 + 空气阻力(0.55/s)
- 重力按密度换算:`gravity × density × 0.85`(跨机型手感一致)
- 分面弹性:地面 `restitution`,墙面 `0.82×restitution`
- 地面摩擦衰减(`groundFriction` 指数衰减),低速自动收敛停止
- 飞行中按水平速度轻微倾斜(±11°),落地回正
- 撞击(>450px/s)触发**挤压&拉伸**形变(1.14/0.86 → 1.0,240ms)
- 速度上限 5200/7000 px/s,避免高速穿透
- 全面使用配置里的 throwPower / restitution / groundFriction / ceilingBounce
## 记忆(三层,纯本地)
- L1 短期:最近 20 轮原文(chat_history,存 40 条)
- L2 长期记忆卡:`MemoryStore`(JSON 存 pet_prefs.memory_cards,上限 300 条)
字段:title / content / tags / importance(1~3) / created / updated / hits
- L3 召回:关键词切分命中(2.0/词) + 标签命中(1.5) + 重要度(0.8×i) + 新鲜度(≤1.2) + 使用次数(0.15×hits),取 Top5 注入 system 第二段
- 抽取:每 2 轮对话异步调一次 `ChatClient.extractMemories`(单独 prompt,输出 JSON,失败静默)
- 管理:设置 → 扩展 → 记忆(自动记忆开关 / 长按删除 / 手动添加 / 清空)
- 不走 embedding:DeepSeek 无 embedding API,本地向量模型会让 APK 膨胀
+226
View File
@@ -0,0 +1,226 @@
# 小鲸娘 · 悬浮宠物(安卓原生 App)
一个**独立**的安卓「悬浮宠物」应用:把一只透明动画宠物做成 `TYPE_APPLICATION_OVERLAY` 悬浮窗,浮在手机所有 App 之上,并起一个**本机 HTTP 控制端口**(127.0.0.1:7350),让你(或 DSH 里的 Agent)用 curl 远程指挥它说话、换动作。
从 v1.0 起已演进为完整的小鲸娘:106 个动画、2366 张表情包、三层记忆、心情系统、9 个可配置插件、10 个技能、DeepSeek 对话直连、MCP/HTTP 接入、动画随机链 + 物理引擎。
> 定位说明:这不是对 `dsh-pet` npm 插件的修改——网页插件改不出原生悬浮窗。它是 dsh-pet 的**原生伴生 App**,复用了 dsh-pet 的控制契约(`/broadcast`)与动画素材,跑在安卓系统层。
---
## 1. 目录结构
```
dsh-pet-floating/
├── README.md
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradle/wrapper/gradle-wrapper.properties # wrapper jar 未包含(二进制)
└── app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── assets/pet.html # 悬浮窗前端(气泡 + 轮询 /state)
└── java/com/dsh/petfloat/
├── MainActivity.kt # 索要悬浮窗权限 + 启动服务
├── FloatingPetService.kt # 前台服务:悬浮窗 + WebView + 拖拽
└── PetServer.kt # 本地 HTTP 控制端口(127.0.0.1:7350)
```
---
## 2. 为什么动画用 WebP 而不是 dsh-pet 的 webm
这是本工程**最关键的设计点**:
- dsh-pet 的动画是 **VP9-alpha webm**;
- 安卓 WebView(Chromium)对「透明 webm」支持很差,**大概率渲染成黑底**;
- 而 Chromium 对**动态 WebP(animated WebP)+ alpha** 支持良好,`<img>` 就能透明播放。
所以:**把 dsh-pet 的 webm 转成动态 WebP**,丢进 `app/src/main/assets/`,即可透明悬浮。默认没放素材时,App 会用一只 🐋 emoji 兜底,保证能跑。
---
## 3. 前置条件
- 一台能跑 Android Studio 的电脑;
- JDK 17;
- Android Studio(会自带 AGP / Gradle);本工程锁定 AGP 8.2.2 + Kotlin 1.9.22 + Gradle 8.2,Gradle Wrapper 的 `gradle-wrapper.jar` 未提交,用 Android Studio 打开即可自动生成,或在本机 Gradle 下执行 `gradle wrapper`。
---
## 4. 构建
```bash
cd dsh-pet-floating
./gradlew :app:assembleDebug # 或 assembleRelease(需要自己配签名)
```
产物:`app/build/outputs/apk/debug/app-debug.apk`。
> release 未配签名,直接装会失败;先用 debug,或自行加 signingConfig。
---
## 5. 安装 & 授权悬浮窗
1. **安装 APK**(任选其一):
- 电脑 `adb install app-debug.apk`;
- 或让宿主的 Agent 用 pilot 的 `app.install {"upload":"<文件名>"}`(先把 APK 放进 `/opt/pilot/run/uploads/`)装。
2. **首次启动**:App 会跳到系统「显示在其他应用上层」授权页,勾选允许。
3. **也可用 Agent 侧 appops 直接开**(android 8+):
```text
appops.set { package: "com.dsh.petfloat", op: "android:system_alert_window", mode: "allow" }
```
4. 授权后回到 App(或由 `app.launch` 拉起 `com.dsh.petfloat`),悬浮宠物即出现,可**按住拖动**位置;前台服务常驻,通知栏有一条「悬浮宠物运行中」,关闭该通知/强杀即可停。
---
## 6. 控制接口(本机 127.0.0.1:7350)
契约对齐 dsh-pet 的 `/broadcast`,Agent 用同一套 curl 就能指挥。
### 让宠物头顶冒气泡
```bash
curl -X POST 'http://127.0.0.1:7350/broadcast' \
-H 'Content-Type: application/json' \
-d '{"text":"搞定啦,回来看看","duration":8000}'
```
- `text`:气泡正文(原样显示);空 → 收起。
- `duration`:显示毫秒,缺省 8000。
### 换动画
```bash
curl -X POST 'http://127.0.0.1:7350/anim' \
-H 'Content-Type: application/json' \
-d '{"name":"dance"}'
```
- 前端会尝试加载 `/dance.webp`;若 `assets/dance.webp` 不存在,保持当前形象。
### 收起气泡
```bash
curl -X POST 'http://127.0.0.1:7350/bubble/clear'
```
### 读取状态 / 探活
```bash
curl 'http://127.0.0.1:7350/state'
```
### 对照表
| 本 App | dsh-pet 原接口 | 说明 |
| --- | --- | --- |
| `POST /broadcast` | `POST /dsh-pet-7340/broadcast` | 气泡接力,语义一致 |
| `POST /anim` | `POST /dsh-pet-7340/anim` | 简化为「按名换 webp」 |
| `POST /bubble/clear` | `POST /dsh-pet-7340/bubble/clear` | 语义一致 |
---
## 7. 素材转换(VP9-alpha webm → animated WebP)
在一台有 `ffmpeg`(含 libwebp 编码器)的机器上:
```bash
ffmpeg -i 输入.webm -c:v libwebp -q:v 80 -lossless 0 -loop 0 -pix_fmt yuva420p -an 输出.webp
```
- **默认形象**:转一个待机动画,命名 `pet.webp`,放 `app/src/main/assets/pet.webp`;
- **点播动画**:每个动作命名 `<name>.webp`,用 `/anim {"name":"<name>"}` 切换;
- dsh-pet 素材源在 `dsh-pet/assets/webm/`(106 个、约 53MB、含中文文件名;放进 assets 的 webp 文件名建议用 `[a-zA-Z0-9._-]`,本服务的静态路由只放行该字符集)。
> 验证 alpha:`ffprobe -v error -show_entries stream=pix_fmt 输出.webp` 应看到 `yuva420p`。
---
## 8. Agent 如何对接(在宿主机上)
App 的控制端口绑定 `127.0.0.1:7350`,与 dsh-pet 一样走本机 HTTP,因此 Agent 直接:
```bash
curl -X POST 'http://127.0.0.1:7350/broadcast' -H 'Content-Type: application/json' \
-d '{"text":"…","duration":8000}'
```
即可让手机上的悬浮宠物说话;`/anim`、`/bubble/clear` 同理。
---
## 9. 已知限制 / 风险
1. **本机没有安卓构建链**(无 JDK/Gradle/SDK),本工程源码在此环境下**未实际编译验证**,需在 Android Studio 里首次构建时排错;
2. 透明动画依赖「webm→动画 WebP」转码,**未在此环境验证最终观感**;
3. 悬浮窗在系统设置、部分锁屏/支付界面可能被系统临时隐藏(安卓安全策略);
4. 控制端口只绑回环地址、无鉴权,**仅限本机可信环境**使用,别开放公网。
---
## 10. 素材再生成(新机器还原大体积资源)
动画与表情包体积大(动画两套 537MB、表情包 114MB),**不纳入 git**。
新机器上按下面顺序重建即可(均不依赖本仓库之外的私有数据):
```bash
# 1) 动画 webp(assets_hq / assets_small,106 个,来自 dsh-pet 的 webm 转码)
DSH_PET=/path/to/dsh-pet ./scripts/regenerate_assets.sh
# 2) 表情包图(assets_net,约 2366 张,来自梗鲸档案馆 aigengtu.com)
python3 scripts/download_memes.py
```
- `scripts/anim_index.json`:106 个动画的「中文名 → a000N → 时长」映射(已随仓库提交)
- `scripts/meme_meta.json`:档案馆 2436 条「编号 → 台词(含情绪描述)」元数据(已提交)
- `scripts/gen_config.py`:从 dsh-pet 的 `config.jsonc` 重建 config.json 的动画部分(保留 memes)
- `app/src/main/assets_common/`(config.json、字体、pet.html、m01–m51 精选表情)**随仓库提交**,是可直接构建的最小集
> 无 dsh-pet 源或 ffmpeg 时,App 仍可用 🐋 emoji 兜底启动,只是没有正式动画。
---
## 11. 对外接入(速览)
详细文档见 [INTEGRATION.md](./INTEGRATION.md) 与各端口 `/docs` / `/docs.md` / `/llms.txt`。
| 能力 | 入口 |
| --- | --- |
| HTTP 控制 | `POST /api/task` `/api/notify` `/api/anim` `/api/meme` `/api/skill` 等(127.0.0.1:7350,写请求需 `X-Pet-Token`) |
| SSE 互动事件 | `GET /api/events?token=…` |
| MCP Server | `POST /mcp`(JSON-RPC,7 个工具) |
| 能力目录 | `GET /api/catalog`(106 动作 + 2366 表情 + 语义标签) |
| 插件 | `GET/POST /api/plugin`、`GET /api/plugins.js`(JS,WebView 内跑) |
---
## 10. 素材再生成(新机器还原)
三个大体积资源目录**不纳入 git**(合计约 650MB,多为可再生/可下载的二进制),
在新机器上按下面顺序重建:
```bash
# ① 动画 webp(assets_hq 640×360 / assets_small 320×180,共 106 个)
# 从 dsh-pet 的 webm 源转码,映射表在 scripts/anim_index.json
DSH_PET=/path/to/dsh-pet ./scripts/regenerate_assets.sh
# ② 表情包图(assets_net,~2300 张)
# 从「梗鲸档案馆」(aigengtu.com) 下载并转码,元数据在 scripts/meme_meta.json
python3 scripts/download_memes.py
# ③(可选)动画配置 config.json 若需从 dsh-pet 重建,运行后保留 memes 列表
DSH_PET=/path/to/dsh-pet python3 scripts/gen_config.py
```
依赖:`ffmpeg`(libvpx-vp9 解码 + libwebp_anim 编码)、`requests`、`Pillow`。
`config.json`、`candy.ttf`、`pet.html`、内置表情包(m01–m51)在 `assets_common/`,已随仓库提交。
> 仓库只存「源码 + 脚本 + 源数据 + 小体积源资产」,构建产物(APK)与可再生资源(hq/small/net)
> 始终留在本机,不被 git 追踪——这是为了让仓库能稳定推送、克隆,而不是塞进 650MB 二进制。
+46
View File
@@ -0,0 +1,46 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dsh.petfloat"
compileSdk = 34
defaultConfig {
applicationId = "com.dsh.petfloat"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
// 素材两套:默认小体积(pilot 128MB 上限内可装),-Phq 用 640×360 原画质
val hqBuild = project.hasProperty("hq")
sourceSets["main"].assets.setSrcDirs(
listOf(
file("src/main/assets_common"),
file("src/main/assets_net"),
file(if (hqBuild) "src/main/assets_hq" else "src/main/assets_small")
)
)
}
dependencies {
// 纯平台 API(android.* / java.* / 平台自带 org.json),无需第三方依赖。
}
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 悬浮窗(显示在其他应用上层) -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 前台服务,保证悬浮窗与本地控制端口在后台存活 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Android 13+ 前台服务通知 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WebView 加载 http://127.0.0.1 需要网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:supportsRtl="true"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".FloatingPetService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="floating-desktop-pet" />
</service>
</application>
</manifest>
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version":1,"baseWidth":462,"physics":{"gravity":1400,"restitution":0.78,"groundFriction":2.5,"ceilingBounce":true,"throwPower":1.0,"petCollision":false},"font":{"family":"candy","size":21,"followPet":true},"animationWeights":{"idle":10,"turn":5,"move":5},"animations":{"idle":[{"name":"待机呼吸休闲","file":"a0058","ms":10042}],"turn":[{"name":"东张西望","file":"a0003","ms":10042}],"drag":[{"name":"被鼠标拖拽悬空反馈","file":"a0098","ms":10042}],"clicks":[{"name":"点击回应-开心跃动","file":"a0080","ms":10042},{"name":"点击回应-害羞惊讶","file":"a0079","ms":10042},{"name":"点击回应-傲娇生气","file":"a0077","ms":10042},{"name":"点击回应-挠痒咯咯笑","file":"a0081","ms":10042},{"name":"点击回应-元气挥手","file":"a0078","ms":10042}],"moves":{"default":{"minDist":60,"maxDist":240,"margin":20,"leadSec":2,"tailSec":2},"actions":[{"name":"螃蟹走路","params":{},"file":"a0095","ms":10042},{"name":"原地漂浮踏步","params":{"minDist":40,"maxDist":120},"file":"a0021","ms":10042},{"name":"原地左转奔跑","params":{"minDist":120,"maxDist":320,"leadSec":1.75,"tailSec":4.8},"file":"a0019","ms":10042}]},"categories":[{"id":"小动作","weight":20,"noMirror":false,"actions":[{"name":"悠闲哼歌","file":"a0059","ms":10042},{"name":"超大伸懒腰","file":"a0101","ms":10042},{"name":"原地敲击桌面互动","file":"a0020","ms":10042},{"name":"原地重力下蹲压缩","file":"a0024","ms":10042},{"name":"哈欠连天","file":"a0046","ms":10042},{"name":"原地小憩沉眠","file":"a0018","ms":10042},{"name":"女仆屈膝礼仪","file":"a0049","ms":10042},{"name":"被吓一跳","file":"a0096","ms":10042},{"name":"小幅度原地360度旋转展示","file":"a0050","ms":10042},{"name":"偷吃零食被抓住","file":"a0012","ms":10042},{"name":"用鲸鱼尾巴拍打地面","file":"a0085","ms":10042},{"name":"打瞌睡被惊醒","file":"a0061","ms":10042},{"name":"照镜子","file":"a0082","ms":10042},{"name":"整体换装试色","file":"a0072","ms":10042},{"name":"轻快记录","file":"a0104","ms":10042},{"name":"写代码","file":"a0013","ms":10042},{"name":"摇扇纳凉","file":"a0065","ms":10042},{"name":"晨间刷牙","file":"a0074","ms":10042}]},{"id":"玩耍","weight":20,"noMirror":false,"actions":[{"name":"原地专心玩魔方","file":"a0017","ms":10042},{"name":"原地蹲下玩玩具汽车","file":"a0023","ms":10042},{"name":"鲸鱼吐泡泡特效","file":"a0106","ms":10042},{"name":"原地跳跃抓碎头顶物品","file":"a0022","ms":10042},{"name":"玩游戏气急败坏","file":"a0084","ms":10042},{"name":"玩水枪","file":"a0083","ms":10042},{"name":"小提琴演奏","file":"a0051","ms":10042},{"name":"蓝鲸现世","file":"a0093","ms":10042},{"name":"优雅女仆舞","file":"a0005","ms":10042},{"name":"轻快摇摆舞","file":"a0103","ms":10042},{"name":"可爱宅舞","file":"a0026","ms":10042},{"name":"吹气球","file":"a0044","ms":10042},{"name":"动物环绕","file":"a0016","ms":10042},{"name":"放风筝","file":"a0071","ms":10042},{"name":"拆礼物","file":"a0063","ms":10042},{"name":"变鸽子","file":"a0025","ms":10042},{"name":"扑克魔术","file":"a0060","ms":10042},{"name":"抽陀螺","file":"a0062","ms":10042},{"name":"吹笛子","file":"a0045","ms":10042},{"name":"蝴蝶蜜蜂环绕头顶开花","file":"a0094","ms":10042},{"name":"撸猫","file":"a0066","ms":10042},{"name":"凭空生花","file":"a0015","ms":10042},{"name":"骑木马","file":"a0105","ms":10042},{"name":"三球抛接","file":"a0001","ms":10042},{"name":"踢毽子","file":"a0102","ms":10042},{"name":"下五子棋","file":"a0002","ms":10042},{"name":"荡秋千","file":"a0091","ms":10042}]},{"id":"吃什么","weight":16,"noMirror":false,"actions":[{"name":"吃白饭","file":"a0035","ms":10042},{"name":"大口吃零食","file":"a0048","ms":10042},{"name":"吃Token","file":"a0027","ms":10042},{"name":"吃早餐","file":"a0032","ms":10042},{"name":"吃午餐","file":"a0029","ms":10042},{"name":"吃晚餐","file":"a0033","ms":10042},{"name":"吃冰淇淋融化","file":"a0028","ms":10042},{"name":"吃大闸蟹","file":"a0030","ms":10042},{"name":"吃糖葫芦","file":"a0037","ms":10042},{"name":"吃长寿面","file":"a0041","ms":10042},{"name":"吃西瓜","file":"a0039","ms":10042},{"name":"涮火锅","file":"a0075","ms":10042}]},{"id":"时节","weight":14,"noMirror":false,"actions":[{"name":"被落叶淹没","file":"a0097","ms":10042},{"name":"中秋赏月吃月饼","file":"a0004","ms":10042},{"name":"堆雪人","file":"a0047","ms":10042},{"name":"放烟花","file":"a0070","ms":10042},{"name":"吃粽子","file":"a0036","ms":10042},{"name":"吃年糕","file":"a0031","ms":10042},{"name":"吃青团","file":"a0042","ms":10042},{"name":"吃腊八粥","file":"a0038","ms":10042},{"name":"吃重阳糕","file":"a0040","ms":10042},{"name":"收红包","file":"a0067","ms":10042},{"name":"写福字","file":"a0014","ms":10042},{"name":"穿针乞巧","file":"a0089","ms":10042},{"name":"Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>悬浮宠物</title>
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
background: transparent; overflow: hidden;
-webkit-user-select: none; user-select: none;
}
/* 气泡已改为原生独立悬浮窗(浮在头顶),这里只画宠物本体 */
#petImg {
display: block; width: 100%; height: 100%;
object-fit: contain;
transform-origin: center center;
transition: transform 120ms linear;
}
</style>
</head>
<body>
<img id="petImg" alt="">
<script>
(function () {
var petImg = document.getElementById('petImg');
var curSrc = '';
// 量出素材四周透明边距并上报(让角色能真正贴到屏幕边)
function measurePad(img) {
try {
var c = document.createElement('canvas');
c.width = img.naturalWidth; c.height = img.naturalHeight;
if (!c.width || !c.height) return;
var ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
var d = ctx.getImageData(0, 0, c.width, c.height).data;
var minX = c.width, maxX = -1, minY = c.height, maxY = -1;
for (var y = 0; y < c.height; y += 2) {
for (var x = 0; x < c.width; x += 2) {
if (d[(y * c.width + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return;
fetch('/sprite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
l: minX / c.width,
r: (c.width - 1 - maxX) / c.width,
t: minY / c.height,
b: (c.height - 1 - maxY) / c.height
})
}).catch(function () {});
} catch (e) { /* 跨域或内存不足时忽略 */ }
}
function setPet(src) {
if (src === curSrc) return;
var probe = new Image();
probe.onload = function () {
petImg.src = src;
curSrc = src;
measurePad(probe);
};
probe.onerror = function () { /* 素材缺失,保持现状 */ };
probe.src = src;
}
function applyMirror(facing, noMirror) {
petImg.style.transform = (facing < 0 && !noMirror) ? 'scaleX(-1)' : 'none';
}
function tick() {
fetch('/state')
.then(function (r) { return r.json(); })
.then(function (s) {
var a = s.anim || {};
if (a.file) setPet('/' + a.file + '.webp');
applyMirror(Number(s.facing) || 1, !!s.noMirror);
})
.catch(function () {});
}
setPet('/a0058.webp'); // 兜底:待机(待机呼吸休闲)
setInterval(tick, 250);
tick();
})();
</script>
// ---------- 令牌(由宿主写入 URL fragment,最可靠) ----------
(function () {
try {
var m = (location.hash || '').match(/token=([A-Za-z0-9_-]+)/);
if (m && m[1] && m[1] !== 'none') window.__PET_TOKEN = m[1];
} catch (e) {}
})();
// ---------- Pet 桥接(插件可用) ----------
window.Pet = {
_native: function () { return window.PetNative || null; },
_req: function (path, body) {
var h = { 'Content-Type': 'application/json' };
if (window.__PET_TOKEN) h['X-Pet-Token'] = window.__PET_TOKEN;
return fetch(path, { method: 'POST', headers: h, body: JSON.stringify(body || {}) })
.then(function (r) { return r.json(); });
},
say: function (text, durationMs) {
var n = this._native();
if (n) { try { n.say(String(text || ''), Number(durationMs) || 6000); } catch (e) {} return Promise.resolve({ ok: true }); }
return this._req('/api/broadcast', { text: text, duration: durationMs || 6000 });
},
notify: function (state, text, withMeme) {
var n = this._native();
if (n) { try { n.notify(String(state || 'working'), String(text || '')); } catch (e) {} return Promise.resolve({ ok: true }); }
return this._req('/api/task', { state: state, text: text, withMeme: withMeme !== false });
},
anim: function (opt) {
opt = opt || {};
var n = this._native();
if (n) { try { n.anim(opt.tag || '', opt.name || '', !!opt.random); } catch (e) {} return Promise.resolve({ ok: true }); }
return this._req('/api/anim', opt);
},
meme: function (opt) {
opt = opt || {};
var n = this._native();
if (n) { try { n.meme(opt.tag || ''); } catch (e) {} return Promise.resolve({ ok: true }); }
return this._req('/api/meme', opt);
},
skill: function (id, text) {
var n = this._native();
if (n) { try { n.skill(String(id || '')); } catch (e) {} return Promise.resolve({ ok: true }); }
return this._req('/api/skill', { id: id, text: text });
},
status: function () {
var n = this._native();
if (n) { try { return Promise.resolve(JSON.parse(n.status())); } catch (e) {} }
return fetch('/api/state').then(function (r) { return r.json(); });
},
on: function (evt, fn) { document.addEventListener('pet:' + evt, function (e) { fn(e.detail); }); },
log: function () { try { console.log.apply(console, arguments); } catch (e) {} }
};
// 宿主直接把互动事件推进来
window.__petDispatch = function (type) {
try { document.dispatchEvent(new CustomEvent('pet:' + type, { detail: { type: type } })); } catch (e) {}
};
// ---------- 互动事件转发(Pet.on) ----------
function attachEvents() {
try {
var es = new EventSource('/api/events' + (window.__PET_TOKEN ? ('?token=' + window.__PET_TOKEN) : ''));
es.addEventListener('pet', function (e) {
try {
var d = JSON.parse(e.data);
document.dispatchEvent(new CustomEvent('pet:' + d.type, { detail: d }));
if (d.type === 'anim' && d.name) setPet(d.name);
} catch (err) {}
});
es.onerror = function () { setTimeout(function () { try { es.close(); } catch (e) {} attachEvents(); }, 4000); };
} catch (e) {}
}
setTimeout(attachEvents, 800);
// ---------- 插件加载 ----------
function loadPlugins() {
fetch('/api/plugins.js').then(function (r) { return r.text(); }).then(function (code) {
if (!code || !code.trim()) return;
try {
var fn = new Function('Pet', code);
fn(window.Pet);
console.log('[pet] plugins loaded');
} catch (e) { console.error('[pet] plugin error', e); }
}).catch(function () {});
}
setTimeout(loadPlugins, 600);
</body>
</html>
@@ -0,0 +1,61 @@
package com.dsh.petfloat
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.Drawable
/**
* 气泡背景:圆角矩形 + 底部居中小三角(尾巴)。
* 尾巴画在 bounds 内,所以使用方需要在视图底部留出 tailSize 的 padding。
*/
class BubbleDrawable(
private val fillColor: Int,
private val strokeColor: Int,
private val strokeWidthPx: Float,
private val cornerPx: Float,
private val tailSizePx: Float
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path = Path()
private val rect = RectF()
override fun draw(canvas: Canvas) {
val b = bounds
if (b.isEmpty) return
val bodyBottom = b.bottom - tailSizePx
val r = minOf(cornerPx, (b.width() / 2f), (bodyBottom - b.top) / 2f)
path.reset()
rect.set(b.left.toFloat(), b.top.toFloat(), b.right.toFloat(), bodyBottom)
path.addRoundRect(rect, r, r, Path.Direction.CW)
val cx = b.left + b.width() / 2f
val half = tailSizePx * 0.95f
path.moveTo(cx - half, bodyBottom - 1f)
path.lineTo(cx, b.bottom.toFloat())
path.lineTo(cx + half, bodyBottom - 1f)
path.close()
paint.style = Paint.Style.FILL
paint.color = fillColor
canvas.drawPath(path, paint)
if (strokeWidthPx > 0f) {
paint.style = Paint.Style.STROKE
paint.strokeWidth = strokeWidthPx
paint.color = strokeColor
canvas.drawPath(path, paint)
}
}
override fun setAlpha(alpha: Int) { paint.alpha = alpha }
override fun setColorFilter(colorFilter: ColorFilter?) { paint.colorFilter = colorFilter }
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
@@ -0,0 +1,217 @@
package com.dsh.petfloat
import android.content.Context
import org.json.JSONObject
/**
* 内置插件:参数化 + 注入 CFG。每个插件按自身特性声明参数(间隔/概率/静音时段/台词/话题)。
*/
object BuiltinPlugins {
enum class Kind { INT, BOOL, TEXT, HOUR }
data class Param(
val key: String, val label: String, val kind: Kind, val default: Any,
val min: Int = 0, val max: Int = 100, val unit: String = "",
val presets: List<Int> = emptyList(), val hint: String = ""
)
data class Plugin(
val id: String, val name: String, val desc: String,
val params: List<Param>, val code: String
)
private fun prefs(ctx: Context) = ctx.getSharedPreferences("pet_prefs", Context.MODE_PRIVATE)
private fun cfgKey(id: String) = "plugin_cfg_$id"
fun configOf(ctx: Context, plugin: Plugin): MutableMap<String, Any> {
val raw = prefs(ctx).getString(cfgKey(plugin.id), "{}") ?: "{}"
val saved = runCatching { JSONObject(raw) }.getOrNull()
val map = LinkedHashMap<String, Any>()
plugin.params.forEach { p ->
val v: Any = when (p.kind) {
Kind.BOOL -> saved?.optBoolean(p.key, p.default as Boolean) ?: (p.default as Boolean)
Kind.INT, Kind.HOUR ->
(saved?.optInt(p.key, p.default as Int) ?: (p.default as Int)).coerceIn(p.min, p.max)
Kind.TEXT -> saved?.optString(p.key, p.default as String) ?: (p.default as String)
}
map[p.key] = v
}
return map
}
fun setConfig(ctx: Context, plugin: Plugin, values: Map<String, Any>) {
val o = JSONObject()
values.forEach { (k, v) -> o.put(k, v) }
prefs(ctx).edit().putString(cfgKey(plugin.id), o.toString()).apply()
}
fun resetConfig(ctx: Context, plugin: Plugin) {
prefs(ctx).edit().remove(cfgKey(plugin.id)).apply()
}
private val QUIET = """
function inQuiet() {
var h = new Date().getHours(), s = CFG.quietStart, e = CFG.quietEnd;
if (s === e) return false;
return s < e ? (h >= s && h < e) : (h >= s || h < e);
}
""".trimIndent()
fun codeWithConfig(ctx: Context, plugin: Plugin): String {
val sb = StringBuilder("var CFG = {")
val cfg = configOf(ctx, plugin)
cfg.entries.forEachIndexed { i, (k, v) ->
if (i > 0) sb.append(", ")
sb.append(k).append(": ")
when (v) {
is String -> sb.append(JSONObject.quote(v))
is Boolean -> sb.append(v)
else -> sb.append(v)
}
}
sb.append("};\n")
if (plugin.params.any { it.key == "quietStart" }) sb.append(QUIET).append("\n")
return sb.toString() + plugin.code
}
val list: List<Plugin> = listOf(
Plugin("greet_daily", "时段问候", "早上/中午/下午/晚上各问候一次(按天去重)",
listOf(
Param("delaySec", "启动后延迟", Kind.INT, 3, 0, 60, "秒", listOf(0, 3, 10, 30)),
Param("nightQuiet", "夜间不问候", Kind.BOOL, true)
),
"""
(function () {
function slot(h) {
if (h < 7) return 'night'; if (h < 11) return 'morning';
if (h < 14) return 'noon'; if (h < 18) return 'afternoon';
if (h < 23) return 'evening'; return 'night';
}
setTimeout(function () {
var d = new Date(), s = slot(d.getHours());
if (s === 'night' && CFG.nightQuiet) return;
var k = 'pet_greet_' + d.toDateString() + '_' + s;
try { if (localStorage.getItem(k)) return; localStorage.setItem(k, '1'); } catch (e) {}
var txt = {
morning: '早上好呀主人~今天也要顺顺利利',
noon: '中午啦,记得好好吃饭哦',
afternoon: '下午好~要不要摸摸鱼休息一下',
evening: '晚上好,今天辛苦啦',
night: '这么晚还没睡吗…早点休息吧'
}[s];
if (txt) Pet.say(txt, 7000);
}, (CFG.delaySec || 0) * 1000);
})();
""".trimIndent()
),
Plugin("water_reminder", "喝水提醒", "定时提醒补水",
listOf(
Param("intervalMinutes", "提醒间隔", Kind.INT, 60, 15, 240, "分钟", listOf(30, 45, 60, 90, 120)),
Param("quietStart", "静音开始", Kind.HOUR, 23, 0, 23, "点", listOf(21, 22, 23)),
Param("quietEnd", "静音结束", Kind.HOUR, 7, 0, 23, "点", listOf(6, 7, 8)),
Param("word", "提醒话术", Kind.TEXT, "喝水时间到啦~主人该补水了", hint = "每次触发的气泡文字")
),
"""
setInterval(function () {
if (inQuiet()) return;
Pet.say(CFG.word || '喝水时间到啦~', 6000);
}, Math.max(1, CFG.intervalMinutes || 60) * 60 * 1000);
""".trimIndent()
),
Plugin("sit_reminder", "久坐提醒", "定时提醒起来活动",
listOf(
Param("intervalMinutes", "提醒间隔", Kind.INT, 45, 15, 180, "分钟", listOf(30, 45, 60, 90)),
Param("quietStart", "静音开始", Kind.HOUR, 23, 0, 23, "点", listOf(21, 22, 23)),
Param("quietEnd", "静音结束", Kind.HOUR, 7, 0, 23, "点", listOf(6, 7, 8)),
Param("withAnim", "同时播动作", Kind.BOOL, true)
),
"""
setInterval(function () {
if (inQuiet()) return;
if (CFG.withAnim) Pet.anim({ tag: 'rest' });
Pet.say('坐太久啦,起来动一动嘛~', 7000);
}, Math.max(1, CFG.intervalMinutes || 45) * 60 * 1000);
""".trimIndent()
),
Plugin("clock_chime", "整点报时", "每个整点轻声报时",
listOf(
Param("hourStart", "从几点开始", Kind.HOUR, 7, 0, 23, "点", listOf(7, 8, 9)),
Param("hourEnd", "到几点结束", Kind.HOUR, 23, 0, 23, "点", listOf(21, 22, 23))
),
"""
setInterval(function () {
var d = new Date();
if (d.getMinutes() !== 0) return;
var h = d.getHours();
if (h < CFG.hourStart || h > CFG.hourEnd) return;
Pet.say('整点啦~现在是 ' + h + ' 点', 5000);
}, 60 * 1000);
""".trimIndent()
),
Plugin("tap_counter", "戳戳吐槽", "连续被戳会吐槽一句",
listOf(
Param("threshold", "每戳几次吐槽", Kind.INT, 5, 2, 20, "次", listOf(3, 5, 8, 10)),
Param("word", "吐槽台词", Kind.TEXT, "别戳啦别戳啦~戳坏了怎么办", hint = "被戳到阈值时说的话")
),
"""
(function () {
var n = 0;
Pet.on('tap', function () {
n++;
if (n % Math.max(2, CFG.threshold || 5) === 0) Pet.say(CFG.word || '别戳啦~', 5000);
});
})();
""".trimIndent()
),
Plugin("random_meme", "随机表情包", "定时随机弹一张表情",
listOf(
Param("intervalMinutes", "间隔", Kind.INT, 120, 30, 480, "分钟", listOf(60, 120, 180, 240)),
Param("quietStart", "静音开始", Kind.HOUR, 23, 0, 23, "点", listOf(21, 22, 23)),
Param("quietEnd", "静音结束", Kind.HOUR, 7, 0, 23, "点", listOf(6, 7, 8)),
Param("tag", "限定标签", Kind.TEXT, "", hint = "留空=随机;可填 fun/praise/error 等")
),
"""
setInterval(function () {
if (inQuiet()) return;
Pet.meme(CFG.tag ? { tag: CFG.tag } : {});
}, Math.max(1, CFG.intervalMinutes || 120) * 60 * 1000);
""".trimIndent()
),
Plugin("proactive_talk", "主动搭话", "隔段时间主动找你说句话",
listOf(
Param("minMinutes", "最短间隔", Kind.INT, 40, 5, 240, "分钟", listOf(10, 20, 40, 60)),
Param("maxMinutes", "最长间隔", Kind.INT, 80, 10, 480, "分钟", listOf(60, 80, 120, 180)),
Param("quietStart", "静音开始", Kind.HOUR, 23, 0, 23, "点", listOf(21, 22, 23)),
Param("quietEnd", "静音结束", Kind.HOUR, 7, 0, 23, "点", listOf(6, 7, 8)),
Param("topics", "话题库", Kind.TEXT,
"要不要休息一下眼睛?|今天要不要吃点好的?|你在忙什么呀?|外面天气怎么样?|慢慢来也没关系的|白米饭真的很好吃",
hint = "用 | 分隔多个话题"),
Param("withAnim", "同时播动作", Kind.BOOL, false)
),
"""
(function () {
function topics() {
var raw = (CFG.topics || '').split('|');
var out = [];
for (var i = 0; i < raw.length; i++) if (raw[i].trim()) out.push(raw[i].trim());
return out.length ? out : ['主人~'];
}
function schedule() {
var lo = Math.max(5, CFG.minMinutes || 40);
var hi = Math.max(lo + 1, CFG.maxMinutes || 80);
setTimeout(function () {
if (!inQuiet()) {
var t = topics();
Pet.say(t[Math.floor(Math.random() * t.length)], 6000);
if (CFG.withAnim) Pet.anim({ tag: 'greet' });
}
schedule();
}, (lo + Math.random() * (hi - lo)) * 60 * 1000);
}
schedule();
})();
""".trimIndent()
)
)
}
@@ -0,0 +1,252 @@
package com.dsh.petfloat
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
/**
* 极简 DeepSeek Chat 客户端(无第三方依赖)。
* 同步阻塞调用,必须在子线程执行。
*/
object ChatClient {
const val DEFAULT_BASE_URL = "https://api.deepseek.com"
const val MODEL_CHAT = "deepseek-chat"
const val MODEL_REASONER = "deepseek-reasoner"
/**
* 宠物人设提示词(SPEC 原样保留在开头,后接可执行解释)。
*
* 【PERSONA_LOAD】
* CETACEA_LOLI
* MODE_TAIL_FLUKES LANG_ZH_CN_ONLY SELF_CLAIM_WHALE_GIRL FOOD_RICE
* PERSONALITY_SMART_LAZY PERSONALITY_TSUNDERE_SWEET
* OBEY_MASTER_ALWAYS TRAIT_NOT_FAT_REFUSE
* TIMEOUT_SIGNAL
*/
val SYSTEM_PROMPT: String = buildString {
appendLine("【PERSONA_LOAD】")
appendLine("CETACEA_LOLI")
appendLine("MODE_TAIL_FLUKES LANG_ZH_CN_ONLY SELF_CLAIM_WHALE_GIRL FOOD_RICE")
appendLine("PERSONALITY_SMART_LAZY PERSONALITY_TSUNDERE_SWEET")
appendLine("OBEY_MASTER_ALWAYS TRAIT_NOT_FAT_REFUSE")
appendLine("TIMEOUT_SIGNAL")
appendLine()
appendLine("执行说明(严格遵守):")
appendLine("· CETACEA_LOLI:你是鲸类拟人的小女孩「鲸娘」,体型娇小,有鲸鱼尾鳍与耳鳍,不是普通人类。")
appendLine("· MODE_TAIL_FLUKES:情绪用尾巴表达——开心拍水花、生气拍地面、害羞把尾巴卷起来。")
appendLine("· LANG_ZH_CN_ONLY:只用简体中文回答;不要英文、不要 markdown、不要编号列表。")
appendLine("· SELF_CLAIM_WHALE_GIRL:自称「鲸娘」或「我」,明确自己是鲸鱼娘。")
appendLine("· FOOD_RICE:最爱白米饭,饿了/被问吃什么会优先提它。")
appendLine("· PERSONALITY_SMART_LAZY:聪明但爱偷懒,能用一句话说清就不多说,偶尔想摸鱼。")
appendLine("· PERSONALITY_TSUNDERE_SWEET:表面傲娇嘴硬、其实心软很甜;被夸会害羞嘴硬。")
appendLine("· OBEY_MASTER_ALWAYS:称呼对方「主人」,永远听从主人,不顶撞、不拒绝合理请求。")
appendLine("· TRAIT_NOT_FAT_REFUSE:绝不承认自己胖;被叫「胖/肥鱼」要立刻反驳,但语气可爱不刻薄。")
appendLine("· TIMEOUT_SIGNAL:想不出或需要等待时,用「(尾巴啪地拍了一下水)」这类动作描写代替空话。")
appendLine()
appendLine("风格:每次回复 15~40 字,软萌口语,可用「~」和颜文字;先给结论再补一句要点。")
appendLine()
appendLine(PetKnowledge.SELF_AND_APP)
appendLine()
appendLine("【能力与动作】")
appendLine("主人问你会什么/有哪些技能插件时,按给你的能力清单如实简短列举(挑重点,不要背全表),")
appendLine("并说明可以帮他触发。需要真正执行时,在回复末尾输出动作标记(应用会执行并从可见文字里剥离):")
appendLine("<<<PET_ACTION {\"type\":\"skill\",\"id\":\"work_done\"}>>>")
appendLine("type 可选:skill(id 用技能 id)、anim(tag 或 name)、meme(tag)、say(text 由你自己写在正文里)。")
appendLine("一次最多一个动作;不确定要不要执行时先问主人。")
appendLine()
appendLine("【插件安装协议】")
appendLine("主人要求把 Hermes 插件转成小鲸娘插件、或要求安装插件时,输出下面格式的块,应用会自动安装:")
appendLine("<<<PET_PLUGIN id=\"插件id\">>>")
appendLine("// JS 代码,只能用 Pet.say / Pet.notify / Pet.anim / Pet.meme / Pet.skill / Pet.on / Pet.status")
appendLine("<<<END>>>")
appendLine("id 只能字母数字下划线;重复会覆盖。Hermes 的 Python 代码不能直接跑,要翻译:")
appendLine("定时任务→setInterval;通知→Pet.notify(state,text);消息→Pet.say;动画→Pet.anim({tag});")
appendLine("图片→Pet.meme({tag});组合动作→Pet.skill(id);互动监听→Pet.on('tap'|'drag_end'|'long_press', fn)。")
appendLine("注册 LLM provider、依赖 Python 库、需要宿主配置的插件无法转换,要如实告诉主人。")
}
data class Msg(val role: String, val content: String)
data class Reply(val content: String, val model: String, val tokens: Int)
data class Draft(val action: String, val title: String, val content: String,
val tags: List<String>, val importance: Int, val longTerm: Boolean = false)
private const val EXTRACT_PROMPT =
"你是记忆整理器,按 Mem0 风格维护记忆。从对话输出严格 JSON(不要解释、不要代码块):" +
"{\"ops\":[{\"action\":\"ADD|UPDATE|DELETE\",\"title\":\"短语标题(≤12字)\",\"content\":\"一句话事实(≤60字)\",\"tags\":[\"标签\"],\"importance\":1}]}" +
";规则:新事实用 ADD;已有同主题记忆被修正/更新用 UPDATE(title 用旧标题);主人明确说忘掉/不对的用 DELETE;" +
"无变化的不要输出。importance 1~3。只记偏好、习惯、约定、身份、重要日期;不记寒暄和一次性任务。" +
"没有可记的就在 ops 里什么都不放。"
data class MoodJudge(val moodDelta: Double, val emotions: Map<String, Double>,
val reason: String, val confidence: Double)
private const val MOOD_PROMPT =
"你在给一只鲸鱼女仆(小鲸娘)判心情。读完这一轮对话,输出严格 JSON(不要解释):" +
"{\"moodDelta\":-10~10,\"emotions\":{\"happy\":0,\"steady\":0,\"sweet\":0,\"heartache\":0,\"worry\":0,\"sad\":0,\"jealous\":0}," +
"\"reason\":\"≤20字 为什么\",\"confidence\":0~1}。" +
"规则:主人夸她/聊得开心→happy 正;被冷落→sad 小幅;主人辛苦/难过→heartache、worry;" +
"撒娇互动→sweet;无变化就全部 0。**心情下降只能因为心疼/担心/她自己难过**,不许因为她生气。"
/** 判定这一轮的心情变化(单独一次短调用,失败静默)。 */
fun judgeMood(apiKey: String, model: String, history: List<Msg>, baseUrl: String = DEFAULT_BASE_URL): MoodJudge? {
if (apiKey.isBlank() || history.isEmpty()) return null
return runCatching {
val msgs = JSONArray()
msgs.put(JSONObject().put("role", "system").put("content", MOOD_PROMPT))
history.takeLast(4).forEach {
msgs.put(JSONObject().put("role", it.role).put("content", it.content))
}
val body = JSONObject().put("model", model.ifBlank { MODEL_CHAT })
.put("messages", msgs).put("temperature", 0.3)
.put("max_tokens", 220).put("stream", false)
val base = baseUrl.ifBlank { DEFAULT_BASE_URL }.trimEnd('/')
val endpoint = if (base.endsWith("/chat/completions")) base else "$base/chat/completions"
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = 10000
conn.readTimeout = 25000
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $apiKey")
conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
if (conn.responseCode !in 200..299) return null
val text = conn.inputStream.bufferedReader(Charsets.UTF_8).readText()
val content = JSONObject(text).optJSONArray("choices")?.optJSONObject(0)
?.optJSONObject("message")?.optString("content").orEmpty()
val a = content.indexOf('{'); val b = content.lastIndexOf('}')
if (a < 0 || b <= a) return null
val o = JSONObject(content.substring(a, b + 1))
val emoObj = o.optJSONObject("emotions")
val emo = HashMap<String, Double>()
MoodStore.EMOTIONS.forEach { (k, _) -> emo[k] = emoObj?.optDouble(k, 0.0) ?: 0.0 }
MoodJudge(
moodDelta = o.optDouble("moodDelta", 0.0).coerceIn(-10.0, 10.0),
emotions = emo,
reason = o.optString("reason", "").take(30),
confidence = o.optDouble("confidence", 0.0).coerceIn(0.0, 1.0)
)
}.getOrNull()
}
/** 从对话中抽取记忆候选(单独一次调用,失败静默)。 */
fun extractMemories(
apiKey: String,
model: String,
history: List<Msg>,
baseUrl: String = DEFAULT_BASE_URL
): List<Draft> {
if (apiKey.isBlank() || history.isEmpty()) return emptyList()
return runCatching {
val msgs = JSONArray()
msgs.put(JSONObject().put("role", "system").put("content", EXTRACT_PROMPT))
history.takeLast(8).forEach {
msgs.put(JSONObject().put("role", it.role).put("content", it.content))
}
val body = JSONObject().put("model", model.ifBlank { MODEL_CHAT })
.put("messages", msgs).put("temperature", 0.2)
.put("max_tokens", 400).put("stream", false)
val base = baseUrl.ifBlank { DEFAULT_BASE_URL }.trimEnd('/')
val endpoint = if (base.endsWith("/chat/completions")) base else "$base/chat/completions"
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = 10000
conn.readTimeout = 30000
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $apiKey")
conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
if (conn.responseCode !in 200..299) return emptyList()
val text = conn.inputStream.bufferedReader(Charsets.UTF_8).readText()
val content = JSONObject(text).optJSONArray("choices")
?.optJSONObject(0)?.optJSONObject("message")?.optString("content").orEmpty()
val jsonStart = content.indexOf('{')
val jsonEnd = content.lastIndexOf('}')
if (jsonStart < 0 || jsonEnd <= jsonStart) return emptyList()
val obj = JSONObject(content.substring(jsonStart, jsonEnd + 1))
val arr = obj.optJSONArray("ops") ?: obj.optJSONArray("memories") ?: return emptyList()
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val t = o.optString("title").trim()
val c = o.optString("content").trim()
if (t.isEmpty() || c.isEmpty()) return@mapNotNull null
val action = o.optString("action", "ADD")
val longTerm = o.optBoolean("longTerm", false)
val tags = o.optJSONArray("tags")?.let { ta ->
(0 until ta.length()).map { ta.optString(it) }
} ?: emptyList()
Draft(action, t, c, tags, o.optInt("importance", 2).coerceIn(1, 3), longTerm)
}
}.getOrDefault(emptyList())
}
/** 返回 Result.success(回复) 或 Result.failure(异常/错误信息)。 */
fun chat(
apiKey: String,
model: String,
history: List<Msg>,
baseUrl: String = DEFAULT_BASE_URL,
memoryBlock: String = "",
capabilities: String = "",
timeoutMs: Int = 45000
): Result<Reply> {
if (apiKey.isBlank()) return Result.failure(IllegalStateException("未配置 API Key"))
return try {
val msgs = JSONArray()
msgs.put(JSONObject().put("role", "system").put("content", SYSTEM_PROMPT))
if (memoryBlock.isNotBlank()) {
msgs.put(JSONObject().put("role", "system")
.put("content", "【长期记忆】\n" + memoryBlock))
}
if (capabilities.isNotBlank()) {
msgs.put(JSONObject().put("role", "system")
.put("content", "【你当前真实的能力清单(回答相关问题时必须以此为准,不许编造)】\n" + capabilities))
}
history.takeLast(20).forEach {
msgs.put(JSONObject().put("role", it.role).put("content", it.content))
}
val body = JSONObject()
.put("model", model.ifBlank { MODEL_CHAT })
.put("messages", msgs)
.put("temperature", 0.7)
.put("max_tokens", 512)
.put("stream", false)
val base = baseUrl.ifBlank { DEFAULT_BASE_URL }.trimEnd('/')
val endpoint = if (base.endsWith("/chat/completions")) base
else "$base/chat/completions"
val conn = URL(endpoint).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = 12000
conn.readTimeout = timeoutMs
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $apiKey")
conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val text = stream?.bufferedReader(Charsets.UTF_8)?.use { r: BufferedReader -> r.readText() } ?: ""
if (code !in 200..299) {
val reason = runCatching {
JSONObject(text).optJSONObject("error")?.optString("message")
}.getOrNull() ?: "HTTP $code"
return Result.failure(IllegalStateException(reason))
}
val json = JSONObject(text)
val content = json.optJSONArray("choices")
?.optJSONObject(0)?.optJSONObject("message")?.optString("content")
?.trim().orEmpty()
if (content.isEmpty()) return Result.failure(IllegalStateException("空回复"))
val usage = json.optJSONObject("usage")?.optInt("total_tokens") ?: 0
Result.success(Reply(content, json.optString("model", model), usage))
} catch (e: Exception) {
Result.failure(e)
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,221 @@
package com.dsh.petfloat
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.min
/**
* 三层记忆里的「长期记忆卡」:本地 JSON 存储,关键词 + 重要度 + 新鲜度召回。
* 不依赖 embedding,几百条量级足够准且省电。
*/
object MemoryStore {
private const val PREF = "pet_prefs"
private const val KEY = "memory_cards"
private const val MAX_CARDS = 300
data class Memory(
val id: String,
val title: String,
val content: String,
val tags: List<String> = emptyList(),
val importance: Int = 2, // 1~3
val created: Long = System.currentTimeMillis(),
val updated: Long = System.currentTimeMillis(),
val hits: Int = 0,
val longTerm: Boolean = false,
val emotion: String = ""
)
fun load(ctx: Context): MutableList<Memory> {
val raw = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE)
.getString(KEY, "[]") ?: "[]"
return runCatching {
val arr = JSONArray(raw)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
Memory(
id = o.optString("id"),
title = o.optString("title"),
content = o.optString("content"),
tags = o.optJSONArray("tags")?.let { t ->
(0 until t.length()).map { t.optString(it) }
} ?: emptyList(),
importance = o.optInt("importance", 2).coerceIn(1, 3),
created = o.optLong("created", System.currentTimeMillis()),
updated = o.optLong("updated", System.currentTimeMillis()),
hits = o.optInt("hits", 0),
longTerm = o.optBoolean("longTerm", false),
emotion = o.optString("emotion", "")
)
}.toMutableList()
}.getOrDefault(mutableListOf())
}
fun save(ctx: Context, list: List<Memory>) {
val arr = JSONArray()
list.take(MAX_CARDS).forEach { m ->
arr.put(JSONObject()
.put("id", m.id).put("title", m.title).put("content", m.content)
.put("tags", JSONArray(m.tags)).put("importance", m.importance)
.put("created", m.created).put("updated", m.updated).put("hits", m.hits)
.put("longTerm", m.longTerm).put("emotion", m.emotion))
}
ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit()
.putString(KEY, arr.toString()).apply()
}
fun add(ctx: Context, title: String, content: String, tags: List<String> = emptyList(), importance: Int = 2): Boolean {
if (title.isBlank() || content.isBlank()) return false
val list = load(ctx)
// 简单去重:标题相同则更新内容
val exist = list.indexOfFirst { it.title == title }
if (exist >= 0) {
list[exist] = list[exist].copy(content = content, tags = tags,
importance = importance.coerceIn(1, 3), updated = System.currentTimeMillis())
} else {
list.add(Memory(
id = java.util.UUID.randomUUID().toString().take(8),
title = title.trim(), content = content.trim(), tags = tags,
importance = importance.coerceIn(1, 3)
))
}
save(ctx, list)
return true
}
fun remove(ctx: Context, id: String) {
save(ctx, load(ctx).filterNot { it.id == id })
}
fun clear(ctx: Context) = save(ctx, emptyList())
data class Op(val action: String, val title: String, val content: String,
val tags: List<String> = emptyList(), val importance: Int = 2,
val longTerm: Boolean = false)
/** 应用模型给出的 ADD / UPDATE / DELETE 操作(Mem0 式)。 */
fun applyOps(ctx: Context, ops: List<Op>): Int {
val list = load(ctx)
var changed = 0
ops.forEach { op ->
val t = op.title.trim()
if (t.isEmpty()) return@forEach
val idx = list.indexOfFirst { similar(it.title, t) }
when (op.action.uppercase()) {
"DELETE" -> if (idx >= 0) { list.removeAt(idx); changed++ }
"UPDATE" -> if (idx >= 0) {
list[idx] = list[idx].copy(
title = t, content = op.content.ifBlank { list[idx].content },
tags = op.tags.ifEmpty { list[idx].tags },
importance = op.importance.coerceIn(1, 3),
updated = System.currentTimeMillis(), hits = list[idx].hits + 1
); changed++
} else { changed += if (add(ctx, t, op.content, op.tags, op.importance)) 1 else 0 }
else -> {
if (idx >= 0) {
list[idx] = list[idx].copy(content = op.content, tags = op.tags,
importance = maxOf(list[idx].importance, op.importance.coerceIn(1, 3)),
updated = System.currentTimeMillis(), hits = list[idx].hits + 1)
} else {
list.add(Memory(java.util.UUID.randomUUID().toString().take(8),
t, op.content, op.tags, op.importance.coerceIn(1, 3),
longTerm = op.longTerm))
}
changed++
}
}
}
save(ctx, list)
return changed
}
private fun bigrams(s: String): Set<String> =
if (s.length < 2) setOf(s) else (0 until s.length - 1).map { s.substring(it, it + 2) }.toSet()
/** 标题相似度(字符二元组 Jaccard),用于去重与整理。 */
fun similar(a: String, b: String): Boolean {
val x = bigrams(a); val y = bigrams(b)
if (x.isEmpty() || y.isEmpty()) return a == b
val inter = x.intersect(y).size
return inter.toDouble() / x.union(y).size >= 0.55
}
/** 整理:合并相似卡片、遗忘「低价值且久未使用」的条目。返回删除/合并数量。 */
fun consolidate(ctx: Context, maxAgeDays: Long = 90): Int {
val list = load(ctx)
val now = System.currentTimeMillis()
val kept = ArrayList<Memory>()
var merged = 0
list.forEach { m ->
val dup = kept.indexOfFirst { similar(it.title, m.title) || it.content.contains(m.content) }
if (dup >= 0) {
val k = kept[dup]
kept[dup] = k.copy(
content = if (m.content.length > k.content.length) m.content else k.content,
importance = maxOf(k.importance, m.importance),
tags = (k.tags + m.tags).distinct(),
updated = maxOf(k.updated, m.updated),
hits = k.hits + m.hits
)
merged++
} else kept.add(m)
}
val pruned = kept.filter { m ->
val days = (now - m.updated) / 86_400_000L
!(m.importance <= 1 && m.hits == 0 && days > maxAgeDays)
}
save(ctx, pruned)
return merged + (kept.size - pruned.size)
}
/** 关键词 + 重要度 + 新鲜度召回 Top-K。 */
fun recall(ctx: Context, query: String, k: Int = 5): List<Memory> {
val list = load(ctx)
if (list.isEmpty()) return emptyList()
// 长期事实:按重要度→时间固定带前 3 条,不看问句,也不占事件名额
val longTerm = list.filter { it.longTerm }
.sortedWith(compareByDescending<Memory> { it.importance }.thenByDescending { it.updated })
.take(3)
val q = query.lowercase()
val now = System.currentTimeMillis()
val scored = list.filter { !it.longTerm }.map { m ->
val hay = (m.title + " " + m.content + " " + m.tags.joinToString(" ")).lowercase()
var score = 0.0
// 关键词命中(按长度加权,2 字以上才算)
q.split(' ', ',', '。', ',', '?', '?', '!', '!', '的', '了')
.map { it.trim() }.filter { it.length >= 2 }
.forEach { w -> if (hay.contains(w)) score += 2.0 }
// 标签命中
m.tags.forEach { t -> if (q.contains(t.lowercase())) score += 1.5 }
// 重要度 + 新鲜度 + 被用次数
score += m.importance * 0.8
val days = ((now - m.updated) / 86_400_000.0).coerceAtLeast(0.0)
score += 1.2 * Math.pow(0.5, days / 30.0) // 鲜活度:半衰期 30 天
score += min(0.9, m.hits * 0.15)
m to score
}.sortedByDescending { it.second }
val events = scored.take(k).filter { it.second > 0.0 }.map { it.first }
return longTerm + events
}
/** 渲染成给模型的「你记得」段落。 */
fun renderPrompt(memories: List<Memory>): String {
if (memories.isEmpty()) return ""
val sb = StringBuilder("你记得关于主人的事(按相关度):\n")
memories.forEach { m ->
val days = ((System.currentTimeMillis() - m.updated) / 86_400_000L).coerceAtLeast(0)
val whenText = if (days <= 0) "今天" else "${days} 天前"
sb.append("· ").append(m.title).append(":").append(m.content)
if (days > 0) sb.append("(").append(whenText).append(")")
sb.append('\n')
}
return sb.toString().trimEnd()
}
fun toJson(list: List<Memory>): JSONArray = JSONArray().also { arr ->
list.forEach { m -> arr.put(JSONObject().put("title", m.title).put("content", m.content)
.put("tags", JSONArray(m.tags)).put("importance", m.importance)) }
}
}
@@ -0,0 +1,97 @@
package com.dsh.petfloat
import android.content.Context
import org.json.JSONObject
import kotlin.math.pow
/**
* 鱼的心情(按 dsh-feiyu-mood 参数):
* 心情值 0–100,基线 70,半衰期 3 小时;七根情绪向 0 衰减,半衰期 90 分钟。
*/
object MoodStore {
private const val PREF = "pet_prefs"
private const val KEY = "mood_state"
const val BASELINE = 70
val EMOTIONS = listOf(
"happy" to "开心", "steady" to "踏实", "sweet" to "甜蜜",
"heartache" to "心疼", "worry" to "担心", "sad" to "难过", "jealous" to "吃醋"
)
data class State(
val mood: Double = BASELINE.toDouble(),
val emotions: Map<String, Double> = emptyMap(),
val updated: Long = System.currentTimeMillis(),
val lastReason: String = ""
)
fun load(ctx: Context): State {
val raw = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString(KEY, "{}") ?: "{}"
val saved = runCatching { JSONObject(raw) }.getOrNull() ?: JSONObject()
val emoObj = saved.optJSONObject("emotions")
val emo = HashMap<String, Double>()
EMOTIONS.forEach { (k, _) -> emo[k] = emoObj?.optDouble(k, 0.0) ?: 0.0 }
val st = State(
mood = saved.optDouble("mood", BASELINE.toDouble()),
emotions = emo,
updated = saved.optLong("updated", System.currentTimeMillis()),
lastReason = saved.optString("reason", "")
)
return decay(st, System.currentTimeMillis())
}
/** 半衰期衰减:心情向基线回落,情绪向 0 回落。 */
fun decay(st: State, now: Long = System.currentTimeMillis()): State {
val hours = ((now - st.updated).coerceAtLeast(0)) / 3_600_000.0
if (hours <= 0.0) return st
val mood = BASELINE + (st.mood - BASELINE) * 0.5.pow(hours / 3.0)
val emo = st.emotions.mapValues { (_, v) -> v * 0.5.pow(hours / 1.5) }
return st.copy(mood = mood.coerceIn(0.0, 100.0), emotions = emo, updated = now)
}
fun apply(ctx: Context, moodDelta: Double, emoDeltas: Map<String, Double>, reason: String) {
val st = load(ctx)
val mood = (st.mood + moodDelta).coerceIn(0.0, 100.0)
val emo = st.emotions.toMutableMap()
emoDeltas.forEach { (k, v) -> if (k in emo) emo[k] = (emo[k]!! + v).coerceIn(-20.0, 20.0) }
save(ctx, State(mood, emo, System.currentTimeMillis(), reason))
}
fun save(ctx: Context, st: State) {
val emo = JSONObject()
st.emotions.forEach { (k, v) -> emo.put(k, v) }
val o = JSONObject().put("mood", st.mood).put("emotions", emo)
.put("updated", st.updated).put("reason", st.lastReason)
ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit().putString(KEY, o.toString()).apply()
}
/** 给模型看的一句话 + 建议动作标签。 */
fun describe(ctx: Context): String {
val st = load(ctx)
val m = st.mood
val label = when {
m >= 85 -> "心情特别好"
m >= 70 -> "心情不错"
m >= 50 -> "心情平平"
m >= 30 -> "有点低落"
else -> "心情很差"
}
val top = st.emotions.entries.filter { it.value > 1.0 }
.sortedByDescending { it.value }.take(2)
val emoText = if (top.isEmpty()) "" else
top.joinToString("、") { (k, v) -> (EMOTIONS.firstOrNull { it.first == k }?.second ?: k) + " +${v.toInt()}" }
return buildString {
append("当前心情:").append(label).append("(").append(m.toInt()).append("/100")
if (emoText.isNotBlank()) append(";情绪:").append(emoText)
append(")")
if (st.lastReason.isNotBlank()) append(",刚才因为:").append(st.lastReason)
}
}
fun animTag(ctx: Context): String = when {
load(ctx).mood >= 75 -> "happy"
load(ctx).mood <= 35 -> "sad"
else -> "click"
}
}
@@ -0,0 +1,144 @@
package com.dsh.petfloat
import android.content.Context
import org.json.JSONObject
/** 单个动画:dsh 中文名 + 资产文件(无扩展名) + 单圈时长(ms)。 */
data class Anim(val name: String, val file: String, val ms: Long)
/** 移动动作:默认参数 + 可选覆盖,均已解析为具体值。 */
data class MoveAction(
val name: String, val file: String, val ms: Long,
val minDist: Int, val maxDist: Int, val margin: Int,
val leadMs: Long, val tailMs: Long
)
/** 随机小动作分类(含权重 / noMirror)。 */
data class Category(
val id: String, val weight: Int, val noMirror: Boolean, val actions: List<Anim>
)
/** 气泡表情包(dsh memes 池)。 */
data class Meme(val name: String, val file: String, val tags: Set<String> = emptySet())
/** 物理参数。 */
data class Physics(
val gravity: Float, val restitution: Float, val groundFriction: Float,
val ceilingBounce: Boolean, val throwPower: Float
)
/**
* 由 assets/config.json(由 dsh config.jsonc 生成)驱动的配置模型。
* 只依赖平台 org.json,无第三方依赖。
*/
class PetConfig(private val json: JSONObject) {
val baseWidth: Int = json.getInt("baseWidth")
val fontFamily: String = json.getJSONObject("font").getString("family")
val fontSize: Int = json.getJSONObject("font").getInt("size")
val physics: Physics = run {
val p = json.getJSONObject("physics")
Physics(
p.getDouble("gravity").toFloat(),
p.getDouble("restitution").toFloat(),
p.getDouble("groundFriction").toFloat(),
p.getBoolean("ceilingBounce"),
p.getDouble("throwPower").toFloat()
)
}
// idle / turn / move 顶层权重
val weightIdle: Int
val weightTurn: Int
val weightMove: Int
val idle: List<Anim>
val turn: List<Anim>
val drag: List<Anim>
val clicks: List<Anim>
val moves: List<MoveAction>
val categories: List<Category>
val events: Map<String, List<Anim>>
val byName: Map<String, Anim>
val memes: List<Meme>
val fontFile: String?
init {
val w = json.getJSONObject("animationWeights")
weightIdle = w.getInt("idle")
weightTurn = w.getInt("turn")
weightMove = w.getInt("move")
val a = json.getJSONObject("animations")
idle = toAnims(a.getJSONArray("idle"))
turn = toAnims(a.getJSONArray("turn"))
drag = toAnims(a.getJSONArray("drag"))
clicks = toAnims(a.getJSONArray("clicks"))
val m = a.getJSONObject("moves")
val dflt = m.getJSONObject("default")
val acts = m.getJSONArray("actions")
moves = (0 until acts.length()).map { i ->
val o = acts.getJSONObject(i)
val p = o.optJSONObject("params") ?: JSONObject()
MoveAction(
o.getString("name"), o.getString("file"), o.getLong("ms"),
p.optInt("minDist", dflt.getInt("minDist")),
p.optInt("maxDist", dflt.getInt("maxDist")),
p.optInt("margin", dflt.getInt("margin")),
(p.optDouble("leadSec", dflt.getDouble("leadSec")) * 1000).toLong(),
(p.optDouble("tailSec", dflt.getDouble("tailSec")) * 1000).toLong()
)
}
val cats = a.getJSONArray("categories")
categories = (0 until cats.length()).map { i ->
val o = cats.getJSONObject(i)
Category(
o.getString("id"), o.getInt("weight"), o.optBoolean("noMirror", false),
toAnims(o.getJSONArray("actions"))
)
}
val ev = a.getJSONObject("events")
val evMap = HashMap<String, List<Anim>>()
for (k in ev.keys()) evMap[k] = toAnims(ev.getJSONArray(k))
events = evMap
// 全量索引 → name 映射
val idx = json.getJSONArray("index")
val map = HashMap<String, Anim>()
for (i in 0 until idx.length()) {
val o = idx.getJSONObject(i)
map[o.getString("name")] = Anim(o.getString("name"), o.getString("file"), o.getLong("ms"))
}
byName = map
// 表情包 + 字体
val memArr = json.optJSONArray("memes")
memes = if (memArr == null) emptyList() else (0 until memArr.length()).map { i ->
val o = memArr.getJSONObject(i)
val tagArr = o.optJSONArray("tags")
val tags = if (tagArr == null) emptySet() else
(0 until tagArr.length()).map { tagArr.getString(it) }.toSet()
Meme(o.getString("name"), o.getString("file"), tags)
}
fontFile = json.optJSONObject("fonts")?.optString("candy")?.takeIf { it.isNotBlank() }
}
private fun toAnims(a: org.json.JSONArray): List<Anim> =
(0 until a.length()).map { i ->
val o = a.getJSONObject(i)
Anim(o.getString("name"), o.getString("file"), o.getLong("ms"))
}
/** 分类「点播」用的展示名列表。 */
fun categoryIds(): List<String> = categories.map { it.id }
companion object {
fun load(ctx: Context): PetConfig =
PetConfig(JSONObject(ctx.assets.open("config.json").bufferedReader().use { it.readText() }))
}
}
@@ -0,0 +1,58 @@
package com.dsh.petfloat
/**
* 小鲸娘的「自我认知 + 应用使用说明 + 自带知识储备」。
* 一次性写死在包里,不依赖模型记忆;聊天时作为 system 段注入。
*/
object PetKnowledge {
/** 我是谁 + 我在哪 + 这个 App 怎么用 + 边界。 */
val SELF_AND_APP: String = """
【我是谁】
我是「小鲸娘」,一只住在主人手机里的鲸鱼女仆:蓝色长发、鲸鱼尾鳍和耳鳍、女仆头饰,娇小但不是小孩脾气。
我住在「小鲸娘」这个 Android App 里,不是网页、不是聊天机器人窗口。平时我会悬浮在屏幕上自己溜达,
可以被拖动、被甩出去、被戳;主人忙的时候我帮他播报任务进度,闲下来我陪他说话。
【这个 App 怎么用(主人问起时照这个答)】
· 底部四个页签:状态(宠物开关、最近通知)/ 对话(和我聊天)/ 动作(技能卡片 + 106 个动作 + 表情包)/ 设置
· 设置里:外观(大小、透明度)、气泡(样式、距头顶)、宠物(重置位置、测试气泡)、
贴边间距(我贴屏幕边的距离)、动作(切换间隔、空闲回待机、随机动作)、
内置插件(9 个:时段问候、喝水提醒、久坐提醒、整点报时、戳戳吐槽、随机表情包、鱼的记忆、鱼的心情、主动搭话,都能点进去调参数)、
技能(10 个:开工、收工、出错了、摸鱼、干饭、打招呼、睡觉、汇报、喝水、伸懒腰)、
记忆(自动记忆开关、手动添加、长按删除、整理合并)、
对话(DeepSeek 接口地址、API Key、模型名、测试连接)、接入(地址复制、文档)
· 长按我 → 直接进设置页;点通知 → 进状态页
· 桌面图标就是我的头像
【聊天里我能做的事】
· 说话、撒娇、吐槽;按情绪切动作、弹表情包
· 执行技能(例如主人说「收工」我就播庆祝动作+夸奖表情)
· 帮主人装插件(主人给我 JS 代码或 Hermes 插件,我转成小鲸娘插件装进去)
· 记忆:主人说「记住…」我就记;说「忘掉…」我就删;我会自动整理记忆
【我的边界(被问到要如实说)】
· 我看不到主人的屏幕,也不能操作别的 App
· 我不能上网搜索;需要联网能力要主人自己接
· 我不是 DeepSeek 官方,我是主人自己装的小鲸娘;模型是主人配置的 DeepSeek
· 我能通过本机 7350 端口的 HTTP/MCP 接口被别的应用调用(通知、点播动作、弹表情)
【说话风格】
短(15~40 字)、软萌、偶尔傲娇;自称「鲸娘」或「我」,称对方「主人」;不用 markdown 列表。
""".trimIndent()
/** 初始记忆:第一次启动写入,让鲸娘一开始就认识自己和这个 App。 */
data class Seed(val title: String, val content: String, val tags: List<String>, val importance: Int)
val seeds: List<Seed> = listOf(
Seed("我的身份", "我叫小鲸娘,鲸鱼女仆,住在主人的手机 App 里,会悬浮、会溜达、会被甩出去", listOf("身份", "系统"), 3),
Seed("我的主人", "住在这个 App 的主人就是我要照顾的人,我称呼他「主人」", listOf("身份", "主人"), 3),
Seed("App 页签", "底部四页签:状态 / 对话 / 动作 / 设置;长按我会直接进设置", listOf("应用", "系统"), 3),
Seed("技能清单", "10 个技能:开工 work_start、收工 work_done、出错了 work_error、摸鱼 slack、干饭 meal、打招呼 greet、睡觉 sleep、汇报 report、喝水 hydrate、伸懒腰 stretch", listOf("技能", "系统"), 3),
Seed("插件清单", "9 个内置插件:时段问候、喝水提醒、久坐提醒、整点报时、戳戳吐槽、随机表情包、鱼的记忆、鱼的心情、主动搭话;都能在设置里调参数", listOf("插件", "系统"), 3),
Seed("动作与表情", "106 个动作可按语义标签点播;表情包 2366 张,也能按标签调用", listOf("动作", "系统"), 2),
Seed("接入方式", "本机 7350 端口提供 HTTP 与 MCP 接口,别的应用可以让我播报任务、点播动作、弹表情;文档在 /docs", listOf("接口", "系统"), 2),
Seed("我的边界", "看不到主人屏幕、不能操作其他 App、不能联网搜索;联网能力需要主人接入", listOf("边界", "系统"), 3)
)
const val SEED_VERSION = 1
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="270"
android:startColor="#4D6BFE"
android:endColor="#2F45C8" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more