Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions desktop/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,51 @@ folded.jsonl",TS 钉住 `reduceBatch(raw) ≡ reduceBatch(folded)`
`sound-enabled` 事件广播,音效本身由桌宠页 pet.html 播放(桌宠隐藏后
webview 仍在跑,所以"关桌宠"不等于"静音",两个开关独立)。

桌宠扩展偏好位于 `pet_preferences`(`src/pet.rs::Preferences`),独立于
模型配置表单,用字段补丁原子合并;旧配置自动采用默认值,主设置保存也须
保留它。`pet-preferences` 广播同步设置页、托盘和桌宠;大小默认 100%,
不更换既有精灵图及逐帧动画,猴子保持 88×88;透明画布扩大至 180×164 以容纳多行气泡。`public/pet-core.mjs` 是无 DOM
状态模型,`pet-runtime.mjs` 负责事件、音效及窗口交互。Windows 的隐藏
`pet-service` 共用该模型,通过 `pet_native_render` 驱动原生透明窗口,
右键菜单在两种窗口后端使用同一命令列表。

单击猴子是摸摸互动:按下短暂压缩原图外层、松开回弹,默认显示积分余额与今日消费(可改为循环短句或关闭,可设 0–60 秒,0 常驻);
双击进入当前任务/主窗口,点击任务气泡仍使用该条提示的目标,右键保留菜单。
拖动、失焦及取消手势不触发点击。两种触摸音效分别位于
`sounds.press/release`,沿用音量、总静音与勿扰设置;旧配置自动补齐这两项。
播放由 `pet-audio.mjs` 的 Web Audio 解码缓存驱动;触摸、通知、试听各最多一个当前声部,
替换时短淡出。异步加载只保留最新有效点击,静音取消待播请求;不反复 seek。
回弹使用 Web Animations API,不强制布局;原生绘制 IPC 保持最多一个在途请求。
默认触摸改为轻柔弹音,另有玩具音和铃音;内置 WAV 由
`scripts/generate_pet_touch_sounds.py` 生成。每个事件有独立 `source/enabled/volume`。
设置页通过文件选择器导入音频,最大 10 MB / 30 秒,Web Audio 解码并转换为
PCM16 WAV;`pet_audio.rs` 再校验通道、采样率、长度和头部,复制到私有
`pet-sounds` 目录,以 SHA-256 标识访问。库上限 20 个,只能移除未被引用的音效。
外部原文件不会改动;桌宠页只有受控读取权限,无导入/删除权限。
Windows 原生窗口经 `pet-touch` 把按下/松开/取消/双击传给状态页,回传的
`pressed/bounce` 驱动原图变换;交互反馈不修改任务状态,不遮住待处理与错误提醒。

会话快照和 `session-ask` 事件均带 `waiting_items: [{id,kind}]`,`kind`
为 approval/question/design;关闭一个交互不能清除同一任务的其他等待项。
桌宠按请求 ID 去重声音,事件修订号防止慢快照覆盖新事件。提醒优先级为
待处理 → 错误/中断 → 额度 → 回复/完成,队列逐条展示并保留各自点击目标;
重连快照不重放历史音效。勿扰按电脑本地时间抑制声音和主动提示,仍可查看
待处理任务。`bubble=none` 只关闭日常信息,不屏蔽任务事件。

`pet_wallet` 从既有 MonkeyCode 钱包读取账号积分及每日免费 Token 额度,
普通刷新缓存 60 秒,主动刷新最低间隔 5 秒,并合并并发请求。首次识别
账号时查询用户身份,以站点+用户 ID 的摘要隔离每日提醒(按北京时间),
Cookie 仅留壳内用于缓存身份校验,账号切换期间的旧响应会丢弃。主界面
账号会话改变后调用 `pet_account_changed`,广播 `pet-wallet-invalidated`
清空旧数据;服务地址变化走既有 `monkeycode-transport-changed`。
缺失数据展示未知值,失败可展示带标记的缓存;缓存不触发低额度提醒。
今日消费额从 `/api/v1/users/wallet/transaction` 按北京时间固定区间完整翻页汇总
模型/主机/MCP 工具消费的 `amount_balance`,同余额除以 1000 转积分;不把
`amount_daily` 或免费 Token、充值、罚款和订阅购买计入。查询最多 50 页、
10 秒,字段不完整/接口缺失/中途失败均返回未知,跨日不复用昨日消费。
该接口不把上下文 Token 当成计费用量,也不估算人民币费用。
历史累计到账积分暂无聚合字段,UI 明确标注“积分余额”,不称作“累计总积分”。

模型物化按**别名**作键,每条恒写 8 键:`type/model/base_url/api_key/
context_window/supports_images/max_output/thinking`。桌面缺省显式压过
引擎兜底:context_window 200000(引擎 128000)、max_output 32768(引擎
Expand Down
34 changes: 34 additions & 0 deletions desktop/PET-FEATURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 萌宠能力对照与后续建议

参考:[DeepSeek Balance Whale Widget](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget),2026-09-28 查看 main 分支 README「交互」「泡泡系统」「音效」配置。

约束:保留当前猴子精灵图、配色和逐帧动画,只扩展功能和触摸反馈。

| 能力 | 状态 | 说明 / 后续方案 |
| --- | --- | --- |
| 按压与松开交互、独立音效 | 已实现并优化 | 解码缓存、快速点击淘汰过时请求、短淡出;动画不强制布局 |
| 内置音效切换 | 本轮新增 | 默认轻柔弹音,另可选原玩具吱吱、清脆铃音,各事件独立音量、静音和试听 |
| 本地音效导入 | 本轮新增 | 系统支持的 MP3/WAV/M4A/OGG 等,10 MB / 30 秒以内;转换为 PCM WAV 保存,最多 20 个,不修改原文件 |
| 点击积分气泡 | 本轮新增 | 余额与今日已用,可改短句或关闭;时长 0–60 秒,0 常驻;保持待处理任务提示和点击目标 |
| 待审批 / 提问 / 完成提醒 | 已实现 | 多任务队列、去重、精确打开任务,错误优先 |
| 低额度提醒、静音、勿扰 | 已实现 | 积分余额阈值、免费额度分级阈值、每账号每日去重、跨午夜勿扰和临时静音 |
| 大小、拖动、吸附、位置恢复 | 已实现基础版 | 大小 75%–150%,吸附开关,位置记忆及重置 |
| 自定义短句、点击轮播顺序 | 建议下一步,高优先级 | 自行维护短句,积分/任务/短句轮播,顺序和随机去重 |
| 今日积分预算 | 建议下一步,高优先级 | 复用真实当日积分消费,增加预算及百分比提醒,每日去重;流水缺失不提醒 |
| 音频裁剪预览 | 建议下一步,中优先级 | 导入前选择开始/结束位置及片段试听,减少预先裁剪的步骤 |
| 四边吸附距离 | 建议下一步,中优先级 | 分边设置像素距离;镜像会影响猴子姿态,暂不自动迁移 |
| 气泡字体 / 透明度 / 每事件开关 | 建议后续,中优先级 | 保持默认视觉,作为可选配置;审批始终可从菜单找回 |
| 模块化气泡、图片、加权随机 | 暂缓 | 涉及编辑器、素材库和 Windows 原生图片渲染,先验证简单轮播的需求 |
| 角色换肤、精灵替换、自动翻转 | 不纳入当前范围 | 用户要求保留猴子样式 |
| DeepSeek 峰谷价 / API Key 余额 / 人民币估算 | 不直接迁移 | MonkeyCode 积分数据源不同,不能把上下文 Token 当收费记录 |

## 积分口径

- 「积分余额」为现有钱包 `balance / 1000`,是当前可用积分;不是历史累计到账积分,接口暂无后者的聚合字段。
- 「今日已用」按北京时间当天,从积分流水接口完整翻页汇总模型、主机和 MCP 工具消费的 `amount_balance / 1000`。不计免费 Token、每日免费部分、充值/赠送、订阅购买和罚款,包含同账号其他客户端的使用。
- 每次固定查询起止时间,最多 50 页、10 秒;接口不可用、字段缺失或翻页未完成显示「—」,不显示部分结果或假 0。
- 普通刷新 60 秒,点击可刷新(最低间隔 5 秒);跨日不沿用昨日消费,账号切换清除旧数据,缓存有明确标记。

## 验证范围

测试覆盖播放缓存、快速触摸、异步播放取消、导入限制/失败、偏好迁移、提醒优先级、积分消费分页和跨日显示。浏览器验证采用真实前端和音频,账号及文件存储桥接使用测试数据;不替代真实云端账号及 Windows 原生窗口验收。
15 changes: 15 additions & 0 deletions desktop/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ fn main() {
"host_info",
"show_main",
"pet_native_render",
"pet_preferences",
"pet_account_changed",
"pet_set_preferences",
"pet_set_enabled",
"pet_preview_sound",
"pet_audio_error",
"pet_sound_list",
"pet_sound_import",
"pet_sound_read",
"pet_sound_remove",
"pet_wallet",
"pet_activate",
"pet_menu",
"pet_drag_finished",
"pet_reset_position",
"sound_enabled",
"set_sound_enabled",
"update_check",
Expand Down
1 change: 1 addition & 0 deletions desktop/scripts/check_command_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def check(root: pathlib.Path = ROOT) -> list[str]:
pet = root / name / "public/pet.html"
if pet.is_file():
pet_paths.append(pet)
pet_paths += sorted(pet.parent.glob("pet-*.mjs"))
if not ui_paths:
raise ValueError(f"未找到任何 UI 源码目录({'/'.join(UI_ROOTS)})")
main_invokes = literal_invokes(ui_paths)
Expand Down
34 changes: 34 additions & 0 deletions desktop/scripts/generate_pet_touch_sounds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Generate original, short toy squeaks for pet press/release (no external samples)."""
import math
from pathlib import Path
import struct
import wave

OUTPUT = Path(__file__).resolve().parents[1] / "ui-next" / "public"
RATE = 44100


def squeak(name, duration, start, end):
phase = 0.0
samples = []
count = round(duration * RATE)
for index in range(count):
progress = index / (count - 1)
# Smooth chirp, quiet harmonics and a zero-ended envelope avoid clicks.
frequency = start + (end - start) * math.sin(progress * math.pi / 2)
phase += math.tau * frequency / RATE
envelope = math.sin(math.pi * progress) ** 1.7
tone = math.sin(phase) + 0.22 * math.sin(2 * phase) + 0.08 * math.sin(3 * phase)
samples.append(round(32767 * 0.30 * envelope * tone))
with wave.open(str(OUTPUT / name), "wb") as audio:
audio.setparams((1, 2, RATE, count, "NONE", "not compressed"))
audio.writeframes(struct.pack(f"<{count}h", *samples))


if __name__ == "__main__":
squeak("sound-pet-press.wav", 0.12, 640, 390)
squeak("sound-pet-release.wav", 0.19, 480, 920)

squeak("sound-pet-soft-press.wav", 0.055, 300, 180)
squeak("sound-pet-soft-release.wav", 0.085, 430, 600)
squeak("sound-pet-bell.wav", 0.22, 880, 880)
33 changes: 32 additions & 1 deletion desktop/src/baizhi/monkeycode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,41 @@ pub fn mc_host(svc: &Service) -> String {

/// 钱包(积分余额 + 每日免费模型 token 额度)。官方云才有这个端点,
/// 私有化部署会 404。
async fn mc_wallet(svc: &Service) -> BzResult<Value> {
pub(crate) async fn mc_wallet(svc: &Service) -> BzResult<Value> {
mc_call(svc, reqwest::Method::GET, "/api/v1/users/wallet", None).await
}

/// 当天的真实积分消费:只计模型、主机和工具消费的余额部分,不计免费额度、充值或罚款。
/// 固定查询结束时间,完整翻页后才返回数值;不完整/不支持的结果保持未知。
pub(crate) async fn mc_pet_used_credits(svc: &Service, start: i64, end: u64) -> BzResult<f64> {
let mut total = 0.0;
for page in 1..=50 {
let value = mc_call(svc, reqwest::Method::GET,
&format!("/api/v1/users/wallet/transaction?start={start}&end={end}&page={page}&size=100&sort=asc"), None).await?;
total += pet_consumption_page(&value, start, end).ok_or_else(|| other("积分流水字段不完整"))?;
match value.pointer("/page/has_next_page").and_then(Value::as_bool) {
Some(false) => return Ok(total / 1000.0),
Some(true) => {},
None => return Err(other("积分流水分页信息缺失")),
}
}
Err(other("今日流水过多,暂无法完整汇总"))
}

fn pet_consumption_page(value: &Value, start: i64, end: u64) -> Option<f64> {
let mut sum = 0.0;
for row in value.get("transactions")?.as_array()? {
let at = row.get("created_at")?.as_i64()?;
if at < start || at as u64 > end { continue; }
if !["model_consumption", "vm_consumption", "mcp_tool_consumption"].contains(&row.get("kind")?.as_str()?) { continue; }
if row.get("inout_type").and_then(Value::as_str) == Some("in") { continue; }
let amount = row.get("amount_balance")?.as_f64()?;
if !amount.is_finite() { return None; }
sum += amount.abs();
}
Some(sum)
}

/// 会员订阅(等级/到期/续费来源)。开源版后端固定返回基础状态。
async fn mc_subscription(svc: &Service) -> BzResult<Value> {
mc_call(
Expand Down
27 changes: 27 additions & 0 deletions desktop/src/baizhi/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2550,3 +2550,30 @@ async fn wechat_start_guards() {
.unwrap();
assert!(err.contains("没有进行中的扫码会话"), "{err}");
}

#[tokio::test(flavor = "multi_thread")]
async fn pet_credits_use_full_transaction_pagination_not_free_tokens_or_topups() {
let fail = Arc::new(AtomicBool::new(false)); let flag = fail.clone();
let (url, _stop) = serve(Arc::new(move |req: Req| {
assert!(req.path.contains("start=100&end=200"));
if req.path.contains("page=1&") {
Resp::json(200, json!({"code":0,"data":{"page":{"has_next_page":true},"transactions":[
{"created_at":101,"kind":"model_consumption","amount":10000,"amount_balance":1500,"amount_daily":8500},
{"created_at":102,"kind":"top_up","amount_balance":900000},
{"created_at":99,"kind":"vm_consumption","amount_balance":7000}
]}}))
} else if flag.load(Ordering::Relaxed) {
Resp::json(500, json!({"code":500,"message":"unavailable"}))
} else {
Resp::json(200, json!({"code":0,"data":{"page":{"has_next_page":false},"transactions":[
{"created_at":150,"kind":"mcp_tool_consumption","amount_balance":-250},
{"created_at":180,"kind":"model_consumption","amount_balance":0,"amount_daily":100000},
{"created_at":181,"kind":"violation_fine","amount_balance":50000}
]}}))
}
}));
let svc = Service::test_service(Endpoints { account:url.clone(), model_gateway:url.clone(), mcp_gateway:url.clone(), monkeycode:url });
assert_eq!(super::monkeycode::mc_pet_used_credits(&svc, 100, 200).await.map_err(|e| e.msg()).unwrap(), 1.75);
fail.store(true, Ordering::Relaxed);
assert!(super::monkeycode::mc_pet_used_credits(&svc, 100, 200).await.is_err());
}
11 changes: 11 additions & 0 deletions desktop/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ pub struct DesktopConfig {
/// 桌宠窗口位置(物理像素;拖动后记忆)
#[serde(default)]
pub pet_pos: Option<(i32, i32)>,
#[serde(default)]
pub pet_preferences: crate::pet::Preferences,
/// 主窗口正常态的位置、大小与最大化状态。
#[serde(default)]
pub main_window_state: Option<MainWindowState>,
Expand All @@ -139,6 +141,7 @@ impl Default for DesktopConfig {
pet_enabled: true,
sound_enabled: true,
pet_pos: None,
pet_preferences: crate::pet::Preferences::default(),
main_window_state: None,
telemetry_enabled: true,
}
Expand Down Expand Up @@ -548,6 +551,7 @@ fn merge_shell_prefs(incoming: DesktopConfig, disk: &DesktopConfig) -> DesktopCo
pet_enabled: disk.pet_enabled,
sound_enabled: disk.sound_enabled,
pet_pos: disk.pet_pos,
pet_preferences: disk.pet_preferences.clone(),
main_window_state: disk.main_window_state,
telemetry_enabled: disk.telemetry_enabled,
..incoming
Expand Down Expand Up @@ -1126,6 +1130,11 @@ mod tests {
pet_enabled: false,
sound_enabled: false,
pet_pos: Some((12, 34)),
pet_preferences: crate::pet::Preferences {
scale: 125,
quiet_enabled: true,
..Default::default()
},
main_window_state: Some(MainWindowState {
x: 56,
y: 78,
Expand All @@ -1152,6 +1161,7 @@ mod tests {
"提示音开关同样不在表单里,必须保留关闭态"
);
assert_eq!(merged.pet_pos, Some((12, 34)));
assert_eq!(merged.pet_preferences, disk.pet_preferences);
assert_eq!(merged.main_window_state, disk.main_window_state);
assert_eq!(merged.kernel_env, "wsl:Ubuntu", "表单字段仍应生效");
}
Expand All @@ -1163,6 +1173,7 @@ mod tests {
serde_json::from_str(r#"{"models":[],"pet_enabled":false}"#).unwrap();
assert!(cfg.telemetry_enabled);
assert!(cfg.sound_enabled, "升级到带提示音开关的版本不该静音");
assert_eq!(cfg.pet_preferences, crate::pet::Preferences::default());
assert!(!cfg.pet_enabled, "同一份 JSON 里显式给出的字段不受影响");
}

Expand Down
46 changes: 46 additions & 0 deletions desktop/src/driver/ohmy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,52 @@ fn bare_inner_events(tag: &str) -> (Arc<Inner>, EmittedEvents) {
(inner, events)
}

#[tokio::test]
async fn pet_waiting_projection_preserves_other_interactions_when_one_closes() {
let (inner, events) = bare_inner_events("pet-waiting-projection");
inner.write_sidecar("s1", |meta| meta["title"] = json!("测试任务"));
inner
.sess
.pending_perms
.lock()
.unwrap()
.insert("p1".into(), "s1".into());
inner
.sess
.pending_questions
.lock()
.unwrap()
.insert("q1".into(), ("s1".into(), json!([])));
inner.sess.pending_design_selections.lock().unwrap().insert(
"s1".into(),
crate::driver::session::PendingDesignSelection {
request_id: "d1".into(),
previews: HashMap::new(),
responding: false,
},
);
let expected = json!([
{"id":"p1","kind":"approval"}, {"id":"d1","kind":"design"}, {"id":"q1","kind":"question"}
]);
let list = OhmyDriver(inner.clone()).sessions_list().await.unwrap();
assert_eq!(list[0]["waiting_ask"], true);
assert_eq!(list[0]["waiting_items"], expected);
inner.emit_session_ask("s1", true);
assert_eq!(
events.lock().unwrap().last().unwrap().1["waiting_items"],
expected
);
inner.sess.pending_perms.lock().unwrap().clear();
inner.emit_session_ask("s1", false);
let payload = events.lock().unwrap().last().unwrap().1.clone();
assert_eq!(payload["open"], true);
assert_eq!(payload["waiting_items"].as_array().unwrap().len(), 2);
inner.sess.pending_questions.lock().unwrap().clear();
inner.sess.pending_design_selections.lock().unwrap().clear();
inner.emit_session_ask("s1", false);
assert_eq!(events.lock().unwrap().last().unwrap().1["open"], false);
}

/// 同 bare_inner_events,并保留 RPC 出站接收端,供请求/事件竞态测试
/// 精确控制 Agent 应答时序。
fn bare_inner_rpc(
Expand Down
Loading
Loading