小鲸娘 v1.0:悬浮宠物完整工程
- 106 动画 + 2366 表情包 + 语义标签 - 三层记忆 / 心情系统 / 9 可配置插件 / 10 技能 - DeepSeek 对话直连 + MCP/HTTP 接入 + 接入文档 - 动画随机链 + 物理引擎 + 贴边碰撞校准 - 大资源(动画webp/表情图)由 scripts/ 再生成,不入库
No files matched your search
@@ -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/
|
||||
@@ -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' });
|
||||
```
|
||||
@@ -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 = {...}` 前缀即可。
|
||||
@@ -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 膨胀
|
||||
@@ -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 二进制。
|
||||
@@ -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),无需第三方依赖。
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||