diff --git a/desktop/ARCHITECTURE.md b/desktop/ARCHITECTURE.md index d1b08480f..a71889cc5 100644 --- a/desktop/ARCHITECTURE.md +++ b/desktop/ARCHITECTURE.md @@ -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(引擎 diff --git a/desktop/PET-FEATURES.md b/desktop/PET-FEATURES.md new file mode 100644 index 000000000..3b5f87146 --- /dev/null +++ b/desktop/PET-FEATURES.md @@ -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 原生窗口验收。 diff --git a/desktop/build.rs b/desktop/build.rs index f19e328aa..01e023dc4 100644 --- a/desktop/build.rs +++ b/desktop/build.rs @@ -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", diff --git a/desktop/scripts/check_command_contract.py b/desktop/scripts/check_command_contract.py index e04c21f8f..ef7dad219 100644 --- a/desktop/scripts/check_command_contract.py +++ b/desktop/scripts/check_command_contract.py @@ -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) diff --git a/desktop/scripts/generate_pet_touch_sounds.py b/desktop/scripts/generate_pet_touch_sounds.py new file mode 100644 index 000000000..795a9a165 --- /dev/null +++ b/desktop/scripts/generate_pet_touch_sounds.py @@ -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) diff --git a/desktop/src/baizhi/monkeycode.rs b/desktop/src/baizhi/monkeycode.rs index 1f3cb86ae..f33c726d2 100644 --- a/desktop/src/baizhi/monkeycode.rs +++ b/desktop/src/baizhi/monkeycode.rs @@ -216,10 +216,41 @@ pub fn mc_host(svc: &Service) -> String { /// 钱包(积分余额 + 每日免费模型 token 额度)。官方云才有这个端点, /// 私有化部署会 404。 -async fn mc_wallet(svc: &Service) -> BzResult { +pub(crate) async fn mc_wallet(svc: &Service) -> BzResult { 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 { + 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 { + 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 { mc_call( diff --git a/desktop/src/baizhi/tests.rs b/desktop/src/baizhi/tests.rs index d1d7765e9..706b5c7e4 100644 --- a/desktop/src/baizhi/tests.rs +++ b/desktop/src/baizhi/tests.rs @@ -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()); +} diff --git a/desktop/src/config.rs b/desktop/src/config.rs index 622c58a54..b3fa8dd7a 100644 --- a/desktop/src/config.rs +++ b/desktop/src/config.rs @@ -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, @@ -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, } @@ -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 @@ -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, @@ -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", "表单字段仍应生效"); } @@ -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 里显式给出的字段不受影响"); } diff --git a/desktop/src/driver/ohmy_tests.rs b/desktop/src/driver/ohmy_tests.rs index bc621e3da..f2979bb82 100644 --- a/desktop/src/driver/ohmy_tests.rs +++ b/desktop/src/driver/ohmy_tests.rs @@ -1561,6 +1561,52 @@ fn bare_inner_events(tag: &str) -> (Arc, 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( diff --git a/desktop/src/driver/session.rs b/desktop/src/driver/session.rs index 6f20e80d5..101eee64e 100644 --- a/desktop/src/driver/session.rs +++ b/desktop/src/driver/session.rs @@ -1197,29 +1197,8 @@ impl OhmyDriver { .map(|(id, _)| id.clone()) .collect() }; - let waiting: HashSet = inner - .sess - .pending_perms - .lock_ok() - .values() - .cloned() - .chain( - inner - .sess - .pending_questions - .lock_ok() - .values() - .map(|(s, _)| s.clone()), - ) - .chain( - inner - .sess - .pending_design_selections - .lock_ok() - .keys() - .cloned(), - ) - .collect(); + let interactions = inner.waiting_interactions(); + let waiting: HashSet = interactions.keys().cloned().collect(); for e in entries { if !e.path().is_dir() { continue; @@ -1282,6 +1261,7 @@ impl OhmyDriver { // time.Time 序列化对表);sidecar 内部存毫秒,输出时转换 "updated_at": crate::config::ms_to_rfc3339(updated), "waiting_ask": waiting.contains(&id), + "waiting_items": interactions.get(&id).cloned().unwrap_or_default(), }), )); } @@ -5082,6 +5062,38 @@ impl Inner { ); } + /// 快照与事件共用类型化等待投影,桌宠无需读取审批内容或聊天正文。 + fn waiting_interactions(&self) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + for (id, sid) in self.sess.pending_perms.lock_ok().iter() { + result + .entry(sid.clone()) + .or_default() + .push(json!({"id": id, "kind": "approval"})); + } + for (id, (sid, _)) in self.sess.pending_questions.lock_ok().iter() { + result + .entry(sid.clone()) + .or_default() + .push(json!({"id": id, "kind": "question"})); + } + for (sid, pending) in self.sess.pending_design_selections.lock_ok().iter() { + result + .entry(sid.clone()) + .or_default() + .push(json!({"id": pending.request_id, "kind": "design"})); + } + for items in result.values_mut() { + items.sort_by(|a, b| { + a["kind"] + .as_str() + .cmp(&b["kind"].as_str()) + .then(a["id"].as_str().cmp(&b["id"].as_str())) + }); + } + result + } + pub(super) fn emit_session_ask(&self, sid: &str, open: bool) { let title = self .sess @@ -5090,28 +5102,12 @@ impl Inner { .get(sid) .map(|s| s.title.clone()) .unwrap_or_default(); - // 关闭一种交互卡不等于会话已无等待项;从三本 pending 重新投影。 - let open = open - || self - .sess - .pending_perms - .lock_ok() - .values() - .any(|pending_sid| pending_sid == sid) - || self - .sess - .pending_questions - .lock_ok() - .values() - .any(|(pending_sid, _)| pending_sid == sid) - || self - .sess - .pending_design_selections - .lock_ok() - .contains_key(sid); + // 同一任务的其他等待项仍存在时,关闭一张卡不清掉剩余提醒。 + let items = self.waiting_interactions().remove(sid).unwrap_or_default(); self.app.emit_json( "session-event", - json!({ "type": "session-ask", "id": sid, "title": title, "open": open }), + json!({ "type": "session-ask", "id": sid, "title": title, + "open": open || !items.is_empty(), "waiting_items": items }), ); } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 9bf597ace..7015d5da1 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -19,6 +19,8 @@ mod config; mod driver; #[cfg(target_os = "windows")] mod native_pet; +mod pet; +mod pet_audio; mod preview; mod repo; mod skill_import; @@ -195,6 +197,7 @@ struct SoundEnabled(AtomicBool); /// 托盘的提示音勾选项。设置页切换时要把托盘勾选态改过来,否则两处显示会打架; /// 托盘创建失败(无托盘宿主)时为 None,设置页照常工作。 struct TraySoundItem(Mutex>>); +struct TrayPetItem(Mutex>>); /// 桌宠位置暂存:Moved 事件在拖动中高频触发,不能逐次写盘; /// 退出与托盘开关切换时经 persist_pet_prefs 落盘。 @@ -790,23 +793,6 @@ fn window_system_menu(window: tauri::WebviewWindow) { let _ = window; } -/// Windows 隐藏状态页→原生 layered window 的视觉快照。 -/// 非 Windows 继续由 pet.html 自己渲染,命令保留为跨平台空操作, -/// 使同一份内置页不需分叉打包。 -#[tauri::command] -fn pet_native_render( - app: AppHandle, - state: String, - tone: String, - text: String, - session_id: Option, -) { - #[cfg(target_os = "windows")] - native_pet::update(&app, &state, &tone, &text, session_id.as_deref()); - #[cfg(not(target_os = "windows"))] - let _ = (app, state, tone, text, session_id); -} - /// 枚举 WSL 发行版(设置视图"运行环境"下拉用)。 /// 非 Windows、未装 WSL 或任何失败均返回空数组,UI 据此隐藏 WSL 选项。 #[tauri::command] @@ -1258,9 +1244,9 @@ fn persist_main_window_state(app: &AppHandle) { // ==================== 桌宠 ==================== -/// 桌宠窗口尺寸(逻辑像素):气泡(24)+ 吉祥物精灵图(88)的画布。 -const PET_W: f64 = 116.0; -const PET_H: f64 = 120.0; +/// 桌宠画布尺寸(逻辑像素):多行气泡 + 原始 88px 吉祥物。 +const PET_W: f64 = 180.0; +const PET_H: f64 = 164.0; /// 创建非 Windows 桌宠窗口。先隐藏创建以避免定位前在屏幕角落闪现, /// 定位完成后按用户开关显示,不受主窗口焦点影响。 @@ -1274,13 +1260,13 @@ fn ensure_pet_window(app: &AppHandle) { let saved = *app.state::().0.lock_ok(); let win = WebviewWindowBuilder::new(app, "pet", WebviewUrl::App("pet.html".into())) .title("MonkeyCode 桌宠") - .inner_size(PET_W, PET_H) + .inner_size(PET_W * pet::scale(app), PET_H * pet::scale(app)) // GTK 的不可缩放窗口按内容自然尺寸布局,resize 与几何约束全被忽略, // 实测落在 WebView 默认的 200x200。Linux 改为保留 resizable, - // 用 min=max 几何约束钉死 116x120(用户与 WM 同样无法拉伸); + // 用 min=max 几何约束钉死画布尺寸(猴子仍为 88x88)(用户与 WM 同样无法拉伸); // mac 维持原状,约束只是兜底 - .min_inner_size(PET_W, PET_H) - .max_inner_size(PET_W, PET_H) + .min_inner_size(PET_W * pet::scale(app), PET_H * pet::scale(app)) + .max_inner_size(PET_W * pet::scale(app), PET_H * pet::scale(app)) .transparent(true) .decorations(false) .always_on_top(true) @@ -1376,8 +1362,8 @@ fn ensure_pet_window(app: &AppHandle) { app, position.x, position.y, - (PET_W * scale).round() as i32, - (PET_H * scale).round() as i32, + (PET_W * pet::scale(app) * scale).round() as i32, + (PET_H * pet::scale(app) * scale).round() as i32, ) { eprintln!("[desktop] Windows 原生桌宠初始化失败: {e}"); return; @@ -1430,8 +1416,8 @@ fn pet_position(app: &AppHandle, saved: Option<(i32, i32)>) -> tauri::PhysicalPo .iter() .any(|m| { let (ax, ay, aw, ah) = monitor_usable_rect(m); - let w = (PET_W * m.scale_factor()).round() as i32; - let h = (PET_H * m.scale_factor()).round() as i32; + let w = (PET_W * pet::scale(app) * m.scale_factor()).round() as i32; + let h = (PET_H * pet::scale(app) * m.scale_factor()).round() as i32; x >= ax && y >= ay && x + w <= ax + aw && y + h <= ay + ah }); if fits { @@ -1447,8 +1433,8 @@ fn pet_position(app: &AppHandle, saved: Option<(i32, i32)>) -> tauri::PhysicalPo (ax, ay, aw, ah, m.scale_factor()) }) .unwrap_or((0, 0, 1280, 744, 1.0)); - let w = (PET_W * scale) as i32; - let h = (PET_H * scale) as i32; + let w = (PET_W * pet::scale(app) * scale) as i32; + let h = (PET_H * pet::scale(app) * scale) as i32; let margin = (24.0 * scale) as i32; tauri::PhysicalPosition::new(ax + aw - w - margin, ay + ah - h - margin) } @@ -1580,6 +1566,8 @@ fn main() { .manage(PetEnabled(AtomicBool::new(true))) .manage(SoundEnabled(AtomicBool::new(true))) .manage(TraySoundItem(Mutex::new(None))) + .manage(TrayPetItem(Mutex::new(None))) + .manage(pet::PetState::default()) .manage(PetPos(Mutex::new(None))) .manage(MainWindowRuntime(Mutex::new(None))) .manage(EngineApply(Mutex::new(()))) @@ -1597,7 +1585,22 @@ fn main() { show_main, open_devtools, window_system_menu, - pet_native_render, + pet::pet_native_render, + pet::pet_preferences, + pet::pet_account_changed, + pet::pet_set_preferences, + pet::pet_set_enabled, + pet::pet_preview_sound, + pet_audio::pet_audio_error, + pet_audio::pet_sound_list, + pet_audio::pet_sound_import, + pet_audio::pet_sound_read, + pet_audio::pet_sound_remove, + pet::pet_wallet, + pet::pet_activate, + pet::pet_menu, + pet::pet_drag_finished, + pet::pet_reset_position, sound_enabled, set_sound_enabled, update_check, @@ -1757,6 +1760,12 @@ fn main() { .0 .store(cfg.sound_enabled, Ordering::Relaxed); *app.state::().0.lock_ok() = cfg.pet_pos; + *app.state::().prefs.lock_ok() = cfg.pet_preferences.clone(); + app.on_menu_event(|app, event| { + if let Some(id) = event.id.as_ref().strip_prefix("pet:") { + pet::menu_action(app, id); + } + }); // 托盘失败只降级(无托盘宿主的桌面环境),不阻塞 if let Err(e) = setup_tray(app.handle(), cfg.pet_enabled, cfg.sound_enabled) { @@ -1921,6 +1930,7 @@ fn setup_tray(app: &AppHandle, pet_enabled: bool, sound_enabled: bool) -> tauri: )?; // 设置页切换时要回改这个勾选项(见 apply_sound_enabled) *app.state::().0.lock_ok() = Some(sound.clone()); + *app.state::().0.lock_ok() = Some(pet.clone()); // 重启引擎:引擎正常跑着时界面上原本没有任何入口(横幅只在崩溃/启动 // 失败时才出),而「改了设置外的东西要重启才生效」的提示到处都在指它。 // 托盘这一份还兼顾引擎卡死到 UI 都不响应的场景(2026-08-07 用户报障) @@ -1960,11 +1970,9 @@ fn setup_tray(app: &AppHandle, pet_enabled: bool, sound_enabled: bool) -> tauri: // 立即更新可见性并落盘。 "toggle-pet" => { let enabled = !app.state::().0.load(Ordering::Relaxed); - app.state::() - .0 - .store(enabled, Ordering::Relaxed); - persist_pet_prefs(app); - set_pet_visible(app, enabled); + if let Err(error) = pet::pet_set_enabled(app.clone(), enabled) { + eprintln!("[pet] 更新显示开关失败: {error}"); + } } // 提示音开关:CheckMenuItem 已自翻勾选态,apply 里的 set_checked 是 // 幂等回写(设置页那条路径才真正需要它) diff --git a/desktop/src/native_pet.rs b/desktop/src/native_pet.rs index 02e005122..5ad69eb01 100644 --- a/desktop/src/native_pet.rs +++ b/desktop/src/native_pet.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use crate::util::LockExt; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; use windows::core::w; use windows::Win32::Foundation::{COLORREF, HWND, LPARAM, LRESULT, POINT, RECT, SIZE, WPARAM}; use windows::Win32::Graphics::Gdi::{ @@ -37,7 +37,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP, }; -const LOGICAL_H: f64 = 120.0; +const LOGICAL_H: f64 = 164.0; const LOGICAL_SPRITE: f64 = 88.0; const SOURCE_FRAME: usize = 176; const FRAME_COUNT: usize = 52; @@ -94,7 +94,10 @@ struct VisualState { target_session_id: Option, since: Instant, generation: u64, - last_key: Option<(usize, u64, u32, u32)>, + pressed: bool, + bounce: u64, + bounce_since: Option, + last_key: Option<(usize, u64, u32, u32, u64)>, } impl Default for VisualState { @@ -106,6 +109,9 @@ impl Default for VisualState { target_session_id: None, since: Instant::now(), generation: 0, + pressed: false, + bounce: 0, + bounce_since: None, last_key: None, } } @@ -117,6 +123,9 @@ struct MouseState { dragged: bool, cursor: POINT, origin: RECT, + on_bubble: bool, + target: Option, + last_click: Option<(Instant, POINT)>, } struct NativePet { @@ -281,31 +290,39 @@ pub fn position(app: &AppHandle) -> Option<(i32, i32)> { } /// 由隐藏 pet-service WebView 推送已聚合的视觉状态。 -pub fn update(app: &AppHandle, mode: &str, tone: &str, text: &str, session_id: Option<&str>) { +pub fn update(app: &AppHandle, view: &crate::pet::View) { let pet = app.state::().get(); let Some(pet) = pet else { return }; { let mut visual = pet.visual.lock_ok(); - visual.mode = match mode { + let next_mode = match view.state.as_str() { "idle" => Mode::Idle, "running" => Mode::Running, "waiting" => Mode::Waiting, "celebrate" => Mode::Celebrate, _ => Mode::Offline, }; - visual.tone = match tone { + if visual.mode != next_mode { + visual.since = Instant::now(); + } + visual.mode = next_mode; + visual.pressed = view.pressed; + if visual.bounce != view.bounce { + visual.bounce = view.bounce; + visual.bounce_since = Some(Instant::now()); + } + visual.tone = match view.tone.as_str() { "ok" => Tone::Ok, "warn" => Tone::Warn, "err" => Tone::Error, _ => Tone::Normal, }; // IPC 载荷受壳内页控制,仍在原生边界做长度上限。 - visual.text = text.chars().take(80).collect(); - visual.target_session_id = session_id + visual.text = view.text.chars().take(80).collect(); + visual.target_session_id = Some(view.action.as_str()) .map(str::trim) .filter(|id| !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_control)) .map(str::to_string); - visual.since = Instant::now(); visual.generation = visual.generation.wrapping_add(1); } unsafe { @@ -313,6 +330,77 @@ pub fn update(app: &AppHandle, mode: &str, tone: &str, text: &str, session_id: O } } +pub fn set_geometry( + app: &AppHandle, + x: i32, + y: i32, + width: i32, + height: i32, +) -> Result<(), String> { + let Some(pet) = app.state::().get() else { + return Ok(()); + }; + app.run_on_main_thread(move || unsafe { + let _ = SetWindowPos( + pet.hwnd(), + None, + x, + y, + width, + height, + SWP_NOZORDER | SWP_NOACTIVATE, + ); + }) + .map_err(|e| e.to_string()) +} + +/// 原生桌宠没有 Tao 窗口,使用它自己的 HWND 承载菜单与焦点回收。 +pub fn popup_menu(app: &AppHandle, entries: &[crate::pet::MenuEntry]) { + use windows::core::PCWSTR; + use windows::Win32::UI::WindowsAndMessaging::{ + AppendMenuW, CreatePopupMenu, DestroyMenu, GetCursorPos, SetForegroundWindow, + TrackPopupMenu, MF_GRAYED, MF_SEPARATOR, MF_STRING, TPM_RETURNCMD, TPM_RIGHTBUTTON, + WM_NULL, + }; + let Some(pet) = app.state::().get() else { + return; + }; + unsafe { + let Ok(menu) = CreatePopupMenu() else { return }; + for (i, entry) in entries.iter().enumerate() { + let text: Vec = entry.label.encode_utf16().chain(Some(0)).collect(); + let flags = if entry.id == "separator" { + MF_SEPARATOR + } else if entry.enabled { + MF_STRING + } else { + MF_STRING | MF_GRAYED + }; + let _ = AppendMenuW(menu, flags, i + 1, PCWSTR(text.as_ptr())); + } + let mut cursor = POINT::default(); + let _ = GetCursorPos(&mut cursor); + let _ = SetForegroundWindow(pet.hwnd()); + let selected = TrackPopupMenu( + menu, + TPM_RETURNCMD | TPM_RIGHTBUTTON, + cursor.x, + cursor.y, + Some(0), + pet.hwnd(), + None, + ) + .0; + let _ = PostMessageW(Some(pet.hwnd()), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = DestroyMenu(menu); + if selected > 0 { + if let Some(entry) = entries.get(selected as usize - 1).filter(|e| e.enabled) { + crate::pet::menu_action(app, &entry.id); + } + } + } +} + pub fn shutdown(app: &AppHandle) { let pet = app.state::().take(); if let Some(pet) = pet { @@ -346,20 +434,31 @@ impl NativePet { let width = (client.right - client.left).max(1) as u32; let height = (client.bottom - client.top).max(1) as u32; - let (frame, tone, text, mode) = { + let (frame, tone, text, mode, motion) = { let mut visual = self.visual.lock_ok(); let frame = Self::frame_at(visual.mode, visual.since.elapsed()); - let key = (frame, visual.generation, width, height); + let elapsed = visual + .bounce_since + .map(|at| at.elapsed().as_millis() as u64) + .unwrap_or(480); + let motion_frame = if !visual.pressed && elapsed < 480 { + elapsed / 40 + 1 + } else { + 0 + }; + let key = (frame, visual.generation, width, height, motion_frame); if !force && visual.last_key == Some(key) { return Ok(()); } visual.last_key = Some(key); - (frame, visual.tone, visual.text.clone(), visual.mode) + let motion = touch_scale(visual.pressed, elapsed); + (frame, visual.tone, visual.text.clone(), visual.mode, motion) }; - self.render_frame(width, height, frame, mode, tone, &text) + self.render_frame(width, height, frame, mode, tone, &text, motion) } + #[allow(clippy::too_many_arguments)] fn render_frame( &self, width: u32, @@ -368,12 +467,14 @@ impl NativePet { mode: Mode, tone: Tone, text: &str, + motion: (f64, f64), ) -> Result<(), String> { let mut pixels = vec![0u8; width as usize * height as usize * 4]; let scale = (height as f64 / LOGICAL_H).max(0.5); - let sprite_size = (LOGICAL_SPRITE * scale).round().max(1.0) as u32; - let sprite_x = (width.saturating_sub(sprite_size) / 2) as i32; - let sprite_y = height.saturating_sub(sprite_size) as i32; + let sprite_w = (LOGICAL_SPRITE * scale * motion.0).round().max(1.0) as u32; + let sprite_h = (LOGICAL_SPRITE * scale * motion.1).round().max(1.0) as u32; + let sprite_x = (width.saturating_sub(sprite_w) / 2) as i32; + let sprite_y = height.saturating_sub(sprite_h) as i32; self.draw_sprite( &mut pixels, width, @@ -382,7 +483,7 @@ impl NativePet { mode, sprite_x, sprite_y, - sprite_size, + (sprite_w, sprite_h), ); let bubble = if text.is_empty() { @@ -404,19 +505,19 @@ impl NativePet { mode: Mode, dx: i32, dy: i32, - size: u32, + size: (u32, u32), ) { let src = self.sprite.rgba(); let src_w = self.sprite.width() as usize; let sx0 = frame * SOURCE_FRAME; - for y in 0..size { - let sy = ((y as usize * SOURCE_FRAME) / size as usize).min(SOURCE_FRAME - 1); + for y in 0..size.1 { + let sy = ((y as usize * SOURCE_FRAME) / size.1 as usize).min(SOURCE_FRAME - 1); let oy = dy + y as i32; if oy < 0 || oy >= dst_h as i32 { continue; } - for x in 0..size { - let sx = ((x as usize * SOURCE_FRAME) / size as usize).min(SOURCE_FRAME - 1); + for x in 0..size.0 { + let sx = ((x as usize * SOURCE_FRAME) / size.0 as usize).min(SOURCE_FRAME - 1); let ox = dx + x as i32; if ox < 0 || ox >= dst_w as i32 { continue; @@ -550,15 +651,13 @@ fn draw_bubble( tone: Tone, text: &str, ) -> BubbleSpec { - let logical_text = text - .chars() - .map(|c| if c.is_ascii() { 6.0 } else { 11.0 }) - .sum::(); - let bubble_w = ((logical_text + 18.0).clamp(42.0, 112.0) * scale).round() as i32; - let bubble_h = (24.0 * scale).round().max(16.0) as i32; + let lines = text.lines().count().max(1); + let logical_text = text.lines().map(|line| line.chars().map(|c| if c.is_ascii() { 6.0 } else { 11.0 }).sum::()).fold(0.0, f64::max); + let bubble_w = ((logical_text + 18.0).clamp(42.0, 176.0) * scale).round() as i32; + let bubble_h = ((8.0 + 15.4 * lines as f64) * scale).round().max(16.0) as i32; let radius = (9.0 * scale).round().max(4.0) as i32; let left = ((width as i32 - bubble_w) / 2).max(0); - let top = 0; + let top = (height as f64 - 90.0 * scale - f64::from(bubble_h)).round().max(0.0) as i32; let right = (left + bubble_w).min(width as i32); let bottom = (top + bubble_h).min(height as i32); let (bg, fg) = match tone { @@ -653,14 +752,16 @@ unsafe fn draw_bubble_text(dc: windows::Win32::Graphics::Gdi::HDC, bubble: &Bubb let old_font = SelectObject(dc, HGDIOBJ(font.0)); SetBkMode(dc, TRANSPARENT); SetTextColor(dc, bubble.color); - let mut rect = bubble.rect; - let mut text = bubble.text.clone(); - DrawTextW( - dc, - &mut text, - &mut rect, - DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX, - ); + let text = String::from_utf16_lossy(&bubble.text); + let lines: Vec<_> = text.lines().collect(); + let line_height = (bubble.rect.bottom - bubble.rect.top) / lines.len().max(1) as i32; + for (index, line) in lines.iter().enumerate() { + let mut rect = bubble.rect; + rect.top += index as i32 * line_height; + rect.bottom = rect.top + line_height; + let mut text: Vec = line.encode_utf16().collect(); + DrawTextW(dc, &mut text, &mut rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX); + } SelectObject(dc, old_font); let _ = DeleteObject(HGDIOBJ(font.0)); } @@ -669,6 +770,27 @@ const fn rgb(r: u8, g: u8, b: u8) -> COLORREF { COLORREF(r as u32 | ((g as u32) << 8) | ((b as u32) << 16)) } +fn touch_scale(pressed: bool, elapsed_ms: u64) -> (f64, f64) { + if pressed { + return (1.10, 0.84); + } + let t = elapsed_ms as f64 / 480.0; + let frames = [ + (0.0, 1.10, 0.84), + (0.32, 0.96, 1.06), + (0.62, 1.025, 0.97), + (1.0, 1.0, 1.0), + ]; + for pair in frames.windows(2) { + let (a, b) = (pair[0], pair[1]); + if t < b.0 { + let progress = 1.0 - (1.0 - (t - a.0) / (b.0 - a.0)).powi(2); + return (a.1 + (b.1 - a.1) * progress, a.2 + (b.2 - a.2) * progress); + } + } + (1.0, 1.0) +} + unsafe extern "system" fn window_proc( hwnd: HWND, msg: u32, @@ -723,7 +845,18 @@ unsafe extern "system" fn window_proc( mouse.dragged = false; mouse.cursor = cursor; mouse.origin = origin; + let visual = pet.visual.lock_ok(); + let scale = (origin.bottom - origin.top) as f64 / LOGICAL_H; + mouse.on_bubble = + !visual.text.is_empty() && f64::from(cursor.y - origin.top) < (LOGICAL_H - 88.0) * scale; + mouse.target = visual.target_session_id.clone(); + let on_bubble = mouse.on_bubble; + drop(visual); + drop(mouse); SetCapture(hwnd); + if !on_bubble { + let _ = pet.app.emit_to("pet-service", "pet-touch", "press"); + } } return LRESULT(0); } @@ -737,21 +870,31 @@ unsafe extern "system" fn window_proc( if GetCapture() != hwnd { mouse.down = false; mouse.dragged = false; + mouse.last_click = None; + drop(mouse); + let _ = pet.app.emit_to("pet-service", "pet-touch", "cancel"); return LRESULT(0); } let mut cursor = POINT::default(); if windows::Win32::UI::WindowsAndMessaging::GetCursorPos(&mut cursor).is_ok() { let dx = cursor.x - mouse.cursor.x; let dy = cursor.y - mouse.cursor.y; - if dx.abs() + dy.abs() > 4 { + let first_drag = !mouse.dragged && dx.abs() + dy.abs() > 4; + if first_drag { mouse.dragged = true; + mouse.last_click = None; } if mouse.dragged { + let (x, y) = (mouse.origin.left + dx, mouse.origin.top + dy); + drop(mouse); + if first_drag { + let _ = pet.app.emit_to("pet-service", "pet-touch", "cancel"); + } let _ = SetWindowPos( hwnd, None, - mouse.origin.left + dx, - mouse.origin.top + dy, + x, + y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, @@ -764,15 +907,45 @@ unsafe extern "system" fn window_proc( WM_LBUTTONUP => { let mut mouse = pet.mouse.lock_ok(); let clicked = mouse.down && !mouse.dragged; + let dragged = mouse.dragged; + let on_bubble = mouse.on_bubble; + let target = mouse.target.clone(); + let double = clicked + && !on_bubble + && mouse.last_click.is_some_and(|(at, point)| { + at.elapsed().as_millis() + <= u128::from( + windows::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime(), + ) + && (point.x - mouse.cursor.x).abs() + (point.y - mouse.cursor.y).abs() <= 8 + }); + mouse.last_click = if clicked && !on_bubble && !double { + Some((Instant::now(), mouse.cursor)) + } else { + None + }; mouse.down = false; let _ = ReleaseCapture(); drop(mouse); if clicked { - let target = pet.visual.lock_ok().target_session_id.clone(); - crate::show_main_session(&pet.app, target.as_deref()); + if on_bubble { + let _ = crate::pet::pet_activate(pet.app.clone(), target); + } else { + let _ = pet.app.emit_to("pet-service", "pet-touch", "release"); + if double { + let _ = pet.app.emit_to("pet-service", "pet-touch", "activate"); + } + } + } else if dragged { + let _ = crate::pet::pet_drag_finished(pet.app.clone()); } return LRESULT(0); } + windows::Win32::UI::WindowsAndMessaging::WM_RBUTTONUP => { + let _ = pet.app.emit_to("pet-service", "pet-touch", "cancel"); + let _ = crate::pet::pet_menu(pet.app.clone()); + return LRESULT(0); + } WM_CAPTURECHANGED => { // 捕获可被系统/其他窗口异步夺走(Win 键弹开始菜单、系统模态循环 // 触发 WM_CANCELMODE 等),之后 WM_LBUTTONUP 不再投递本窗:不在 @@ -782,8 +955,14 @@ unsafe extern "system" fn window_proc( // **同步**重入投递(文档明言自己 Release 也会收到),lock 会在 // 同线程上自锁死;那条路径 down 已复位,跳过正确。 if let Ok(mut mouse) = pet.mouse.try_lock() { + let cancelled = mouse.down; mouse.down = false; mouse.dragged = false; + if cancelled { + mouse.last_click = None; + drop(mouse); + let _ = pet.app.emit_to("pet-service", "pet-touch", "cancel"); + } } return LRESULT(0); } diff --git a/desktop/src/pet.rs b/desktop/src/pet.rs new file mode 100644 index 000000000..6d6094873 --- /dev/null +++ b/desktop/src/pet.rs @@ -0,0 +1,727 @@ +//! 桌宠偏好、精简账号数据、原生菜单与窗口几何。两个渲染后端共用。 +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +#[cfg(not(target_os = "windows"))] +use tauri::menu::{ContextMenu, Menu, MenuItem, PredefinedMenuItem}; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::{config, util::LockExt}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct Sound { + pub enabled: bool, + pub volume: u8, + pub source: String, +} +impl Default for Sound { + fn default() -> Self { + Self { + enabled: true, + volume: 70, + source: "default".into(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct Sounds { + pub press: Sound, + pub release: Sound, + pub start: Sound, + pub end: Sound, + pub error: Sound, + pub approval: Sound, + pub question: Sound, + pub idle: Sound, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct Preferences { + pub scale: u16, + pub snap: bool, + pub bubble: String, + pub click_bubble: String, + pub bubble_seconds: u16, + pub quota_alerts: bool, + pub quota_warning: u8, + pub quota_critical: u8, + pub credits_warning: u64, + pub quiet_enabled: bool, + pub quiet_start: u16, + pub quiet_end: u16, + pub muted_until: u64, + pub sounds: Sounds, +} +impl Default for Preferences { + fn default() -> Self { + Self { + scale: 100, + snap: true, + bubble: "tasks".into(), + click_bubble: "credits".into(), + bubble_seconds: 6, + quota_alerts: true, + quota_warning: 20, + quota_critical: 10, + credits_warning: 100, + quiet_enabled: false, + quiet_start: 22 * 60, + quiet_end: 8 * 60, + muted_until: 0, + sounds: Sounds::default(), + } + } +} +impl Preferences { + fn validate(&self) -> Result<(), String> { + if !(75..=150).contains(&self.scale) || self.scale % 5 != 0 { + return Err("桌宠大小需为 75%–150%,每档 5%".into()); + } + if !["tasks", "quota", "credits", "none"].contains(&self.bubble.as_str()) { + return Err("未知的气泡内容".into()); + } + if !["credits", "greeting", "none"].contains(&self.click_bubble.as_str()) + || self.bubble_seconds > 60 + { + return Err("点击气泡类型或时长无效(0–60 秒)".into()); + } + if self.quota_critical == 0 + || self.quota_critical >= self.quota_warning + || self.quota_warning > 100 + { + return Err("额度阈值需满足 0 < 紧急阈值 < 提醒阈值 ≤ 100".into()); + } + if self.quiet_start >= 1440 + || self.quiet_end >= 1440 + || self.credits_warning > 1_000_000_000 + { + return Err("勿扰时间或积分阈值无效".into()); + } + if [ + &self.sounds.press, + &self.sounds.release, + &self.sounds.start, + &self.sounds.end, + &self.sounds.error, + &self.sounds.approval, + &self.sounds.question, + &self.sounds.idle, + ] + .iter() + .any(|s| s.volume > 100 || !crate::pet_audio::valid_source(&s.source)) + { + return Err("音量需为 0–100,音效来源必须有效".into()); + } + Ok(()) + } +} + +fn merge_patch(base: &mut Value, patch: Value) -> Result<(), String> { + let (Some(dst), Value::Object(src)) = (base.as_object_mut(), patch) else { + return Err("偏好必须是对象".into()); + }; + for (key, value) in src { + let target = dst + .get_mut(&key) + .ok_or_else(|| format!("未知偏好: {key}"))?; + if target.is_object() { + merge_patch(target, value)?; + } else { + *target = value; + } + } + Ok(()) +} + +fn patched(prefs: &Preferences, patch: Value) -> Result { + let mut value = serde_json::to_value(prefs).map_err(|e| e.to_string())?; + merge_patch(&mut value, patch)?; + let next: Preferences = serde_json::from_value(value).map_err(|e| e.to_string())?; + next.validate()?; + Ok(next) +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct Task { + pub id: String, + pub title: String, + pub label: String, +} + +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct View { + pub state: String, + pub tone: String, + pub text: String, + pub detail: String, + pub action: String, + pub tasks: Vec, + pub pressed: bool, + pub bounce: u64, +} + +#[derive(Clone, Default, Serialize)] +pub struct Wallet { + pub scope: String, + pub credits: Option, + pub used_today: Option, + pub usage_day: i64, + pub remaining: Option, + pub total: Option, + pub updated_at: u64, + pub stale: bool, + pub status: String, +} + +#[derive(Default)] +struct WalletCache { + scope: String, + checked: Option, + value: Wallet, +} + +#[derive(Default)] +pub struct PetState { + pub prefs: Mutex, + pub view: Mutex, + wallet: tokio::sync::Mutex, + #[cfg(not(target_os = "windows"))] + menu: Mutex>>, +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub fn scale(app: &AppHandle) -> f64 { + f64::from(app.state::().prefs.lock_ok().scale) / 100.0 +} + +#[tauri::command] +pub fn pet_preferences(app: AppHandle) -> Value { + json!({ "preferences": *app.state::().prefs.lock_ok(), "defaults": Preferences::default(), + "enabled": app.state::().0.load(std::sync::atomic::Ordering::Relaxed) }) +} + +#[tauri::command] +pub fn pet_set_preferences(app: AppHandle, patch: Value) -> Result { + let state = app.state::(); + let (next, geometry_changed) = { + let mut prefs = state.prefs.lock_ok(); + let next = patched(&prefs, patch)?; + config::update_config_json(&app, |cfg| cfg.pet_preferences = next.clone())?; + let geometry_changed = next.scale != prefs.scale || next.snap != prefs.snap; + *prefs = next.clone(); + (next, geometry_changed) + }; + let _ = app.emit("pet-preferences", pet_preferences(app.clone())); + // 几何更新不持偏好锁:窗口调用会同步泵消息。 + if geometry_changed { + fit_window(&app, false)?; + } + Ok(next) +} + +#[tauri::command] +pub fn pet_account_changed(app: AppHandle) -> Result<(), String> { + app.emit("pet-wallet-invalidated", ()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn pet_preview_sound(app: AppHandle, sound: String) -> Result<(), String> { + if ![ + "press", "release", "start", "end", "error", "approval", "question", "idle", + ] + .contains(&sound.as_str()) + { + return Err("未知音效".into()); + } + app.emit("pet-preview-sound", sound) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn pet_set_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { + config::update_config_json(&app, |cfg| cfg.pet_enabled = enabled)?; + app.state::() + .0 + .store(enabled, std::sync::atomic::Ordering::Relaxed); + if let Some(item) = app.state::().0.lock_ok().as_ref() { + let _ = item.set_checked(enabled); + } + crate::set_pet_visible(&app, enabled); + let _ = app.emit("pet-preferences", pet_preferences(app.clone())); + Ok(()) +} + +fn wallet_scope(svc: &crate::baizhi::Service) -> String { + let Ok(url) = reqwest::Url::parse(&format!("{}/api/v1/users/wallet", svc.ep.monkeycode)) else { + return String::new(); + }; + let Some(cookie) = svc.mc.header(&url) else { + return String::new(); + }; + // 仅用于壳内缓存隔离;Cookie 从不离开壳。 + format!( + "{:x}", + Sha256::digest(format!("{}\0{}", svc.ep.monkeycode, cookie).as_bytes()) + ) +} + +fn account_scope(base_url: &str, user: &Value) -> String { + let identity = ["id", "user_id", "email", "username"] + .iter() + .filter_map(|key| user.get(key)) + .find(|v| v.as_str().is_some_and(|s| !s.is_empty()) || v.is_number()); + identity + .map(|id| { + format!( + "{:x}", + Sha256::digest(format!("{base_url}\0{id}").as_bytes()) + ) + }) + .unwrap_or_default() +} + +fn finite_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_f64) + .filter(|v| v.is_finite()) + .map(|v| v.max(0.0)) +} + +#[tauri::command] +pub async fn pet_wallet(app: AppHandle, force: Option) -> Result { + let state = app.state::(); + let mut cache = state.wallet.lock().await; + let bz = app.state::(); + let svc = bz.service(); + let scope = wallet_scope(&svc); + if scope.is_empty() { + *cache = WalletCache::default(); + return Ok(Wallet { + status: "signed_out".into(), + ..Default::default() + }); + } + if cache.scope != scope { + *cache = WalletCache { + scope: scope.clone(), + ..Default::default() + }; + } + let usage_day = (now_ms() as i64 / 1000 + 8 * 3600) / 86400; + let ttl = if force.unwrap_or(false) { 5 } else { 60 }; + if cache + .checked + .is_some_and(|at| at.elapsed() < Duration::from_secs(ttl)) + && (cache.value.used_today.is_none() || cache.value.usage_day == usage_day) + { + return Ok(cache.value.clone()); + } + // 账号摘要不随 Cookie 续期/重新登录改变,确保同一账号的每日阈值能去重。 + let identity = async { + if !cache.value.scope.is_empty() { + return cache.value.scope.clone(); + } + match crate::baizhi::monkeycode::mc_status(&svc).await { + Ok((true, user)) => account_scope(&svc.ep.monkeycode, &user), + _ => String::new(), + } + }; + let start = usage_day * 86400 - 8 * 3600; + let (result, account, usage) = tokio::join!( + crate::baizhi::monkeycode::mc_wallet(&svc), + identity, + tokio::time::timeout( + Duration::from_secs(10), + crate::baizhi::monkeycode::mc_pet_used_credits(&svc, start, now_ms() / 1000) + ) + ); + let current = bz.service(); + if !Arc::ptr_eq(&svc, ¤t) || wallet_scope(¤t) != scope { + *cache = WalletCache::default(); + return Ok(Wallet { + status: "changed".into(), + ..Default::default() + }); + } + cache.checked = Some(Instant::now()); + match result { + Ok(value) => { + let total = finite_field(&value, "daily_token_limit"); + let remaining = finite_field(&value, "daily_token_balance") + .map(|n| total.filter(|t| *t > 0.0).map(|t| n.min(t)).unwrap_or(n)); + cache.value = Wallet { + scope: account, + credits: finite_field(&value, "balance").map(|v| v / 1000.0), + used_today: usage.ok().and_then(Result::ok), + usage_day, + remaining, + total, + updated_at: now_ms(), + stale: false, + status: "ready".into(), + }; + } + Err(crate::baizhi::BzErr::Unauthorized(_)) => { + cache.value = Wallet { + status: "signed_out".into(), + ..Default::default() + }; + } + Err(_) => { + cache.value.scope = account; + cache.value.stale = true; + cache.value.status = "unavailable".into(); + } + } + Ok(cache.value.clone()) +} + +#[tauri::command] +pub fn pet_native_render(app: AppHandle, mut view: View) { + view.text = view.text.chars().take(120).collect(); + view.detail = view.detail.chars().take(500).collect(); + view.tasks.truncate(100); + for task in &mut view.tasks { + task.title = task.title.chars().take(100).collect(); + task.label = task.label.chars().take(30).collect(); + } + #[cfg(target_os = "windows")] + crate::native_pet::update(&app, &view); + *app.state::().view.lock_ok() = view; +} + +pub fn open_settings(app: &AppHandle, section: &str) { + app.state::() + .0 + .lock_ok() + .replace(format!("open-settings:{section}")); + crate::show_any_window(app); + let _ = app.emit_to("main", "open-settings", section); +} + +#[tauri::command] +pub fn pet_activate(app: AppHandle, action: Option) -> Result<(), String> { + let action = action.unwrap_or_else(|| app.state::().view.lock_ok().action.clone()); + if let Some(id) = action.strip_prefix("session:") { + if crate::open_session_intent(id).is_none() { + return Err("任务标识无效".into()); + } + crate::show_main_session(&app, Some(id)); + } else { + match action.as_str() { + "menu" => return pet_menu(app), + "account" => open_settings(&app, "account"), + "settings" => open_settings(&app, "pet"), + _ => crate::show_any_window(&app), + } + } + Ok(()) +} + +pub struct MenuEntry { + pub id: String, + pub label: String, + pub enabled: bool, +} + +pub fn menu_entries(app: &AppHandle) -> Vec { + let state = app.state::(); + let view = state.view.lock_ok().clone(); + let prefs = state.prefs.lock_ok().clone(); + let mut entries = Vec::new(); + let mut add = |id: &str, label: String, enabled| { + entries.push(MenuEntry { + id: id.into(), + label, + enabled, + }) + }; + if !view.detail.is_empty() { + add("", view.detail.chars().take(180).collect(), false); + } + if view.tasks.is_empty() { + add("", "暂无待处理任务".into(), false); + } + for task in &view.tasks { + add( + &format!("session:{}", task.id), + format!("{} · {}", task.label, task.title), + true, + ); + } + add("separator", String::new(), false); + add("main", "打开主窗口(或双击猴子)".into(), true); + add("account", "查看账号额度与积分".into(), true); + add("refresh", "刷新额度".into(), true); + add("settings", "萌宠设置…".into(), true); + add( + "mute", + if prefs.muted_until > now_ms() { + "结束临时勿扰" + } else { + "勿扰 1 小时" + } + .into(), + true, + ); + add( + "snap", + format!("{}边缘吸附", if prefs.snap { "✓ " } else { "" }), + true, + ); + add( + "smaller", + format!("缩小(当前 {}%)", prefs.scale), + prefs.scale > 75, + ); + add( + "larger", + format!("放大(当前 {}%)", prefs.scale), + prefs.scale < 150, + ); + add("reset", "重置位置".into(), true); + add("hide", "隐藏萌宠(可从托盘恢复)".into(), true); + entries +} + +pub fn menu_action(app: &AppHandle, id: &str) { + let prefs = app.state::().prefs.lock_ok().clone(); + let result = match id { + "refresh" => app.emit("pet-refresh-wallet", ()).map_err(|e| e.to_string()), + "mute" => pet_set_preferences(app.clone(), json!({"muted_until": if prefs.muted_until > now_ms() { 0 } else { now_ms() + 3_600_000 }})).map(|_| ()), + "snap" => pet_set_preferences(app.clone(), json!({"snap": !prefs.snap})).map(|_| ()), + "smaller" => pet_set_preferences(app.clone(), json!({"scale": prefs.scale.saturating_sub(5).max(75)})).map(|_| ()), + "larger" => pet_set_preferences(app.clone(), json!({"scale": (prefs.scale + 5).min(150)})).map(|_| ()), + "reset" => pet_reset_position(app.clone()), + "hide" => pet_set_enabled(app.clone(), false), + _ => pet_activate(app.clone(), Some(id.into())), + }; + if let Err(error) = result { + eprintln!("[pet] 菜单操作失败: {error}"); + let _ = app.emit("pet-error", error); + } +} + +#[tauri::command] +pub fn pet_menu(app: AppHandle) -> Result<(), String> { + let entries = menu_entries(&app); + #[cfg(target_os = "windows")] + { + let handle = app.clone(); + app.run_on_main_thread(move || crate::native_pet::popup_menu(&handle, &entries)) + .map_err(|e| e.to_string())?; + } + #[cfg(not(target_os = "windows"))] + { + let menu = Menu::new(&app).map_err(|e| e.to_string())?; + for (index, entry) in entries.iter().enumerate() { + if entry.id == "separator" { + menu.append(&PredefinedMenuItem::separator(&app).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + } else { + let id = if entry.enabled { + format!("pet:{}", entry.id) + } else { + format!("pet:label:{index}") + }; + menu.append( + &MenuItem::with_id(&app, id, &entry.label, entry.enabled, None::<&str>) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + } + let win = app.get_webview_window("pet").ok_or("桌宠窗口不可用")?; + *app.state::().menu.lock_ok() = Some(menu.clone()); + menu.popup(win.as_ref().window()) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +// Clamp and snap in physical coordinates; negative monitor origins are valid. +pub fn fit_rect( + pos: (i32, i32), + size: (i32, i32), + area: (i32, i32, i32, i32), + threshold: i32, +) -> (i32, i32) { + let (left, top, width, height) = area; + let right = (left + width - size.0).max(left); + let bottom = (top + height - size.1).max(top); + let (mut x, mut y) = (pos.0.clamp(left, right), pos.1.clamp(top, bottom)); + if x - left <= threshold { + x = left; + } else if right - x <= threshold { + x = right; + } + if y - top <= threshold { + y = top; + } else if bottom - y <= threshold { + y = bottom; + } + (x, y) +} + +pub fn fit_window(app: &AppHandle, reset: bool) -> Result<(), String> { + let prefs = app.state::().prefs.lock_ok().clone(); + let factor = f64::from(prefs.scale) / 100.0; + #[cfg(target_os = "windows")] + let pos = crate::native_pet::position(app); + #[cfg(not(target_os = "windows"))] + let pos = app + .get_webview_window("pet") + .and_then(|w| w.outer_position().ok()) + .map(|p| (p.x, p.y)); + let Some(pos) = pos else { return Ok(()) }; + let monitors = app.available_monitors().map_err(|e| e.to_string())?; + let monitor = if reset { + app.primary_monitor().map_err(|e| e.to_string())? + } else { + monitors.into_iter().min_by_key(|m| { + let (x, y, w, h) = crate::monitor_usable_rect(m); + let dx = i64::from(pos.0) - i64::from(pos.0.clamp(x, x + w)); + let dy = i64::from(pos.1) - i64::from(pos.1.clamp(y, y + h)); + dx * dx + dy * dy + }) + }; + let Some(monitor) = monitor else { + return Ok(()); + }; + let area = crate::monitor_usable_rect(&monitor); + let dpi = monitor.scale_factor(); + let size = ( + (crate::PET_W * factor * dpi).round() as i32, + (crate::PET_H * factor * dpi).round() as i32, + ); + let pos = if reset { + ( + area.0 + area.2 - size.0 - (24.0 * dpi) as i32, + area.1 + area.3 - size.1 - (24.0 * dpi) as i32, + ) + } else { + pos + }; + let (x, y) = fit_rect( + pos, + size, + area, + if prefs.snap && !reset { + (20.0 * dpi) as i32 + } else { + 0 + }, + ); + #[cfg(target_os = "windows")] + crate::native_pet::set_geometry(app, x, y, size.0, size.1)?; + #[cfg(not(target_os = "windows"))] + if let Some(win) = app.get_webview_window("pet") { + let logical = tauri::LogicalSize::new(crate::PET_W * factor, crate::PET_H * factor); + win.set_min_size(None::>) + .map_err(|e| e.to_string())?; + win.set_max_size(None::>) + .map_err(|e| e.to_string())?; + win.set_size(logical).map_err(|e| e.to_string())?; + win.set_min_size(Some(logical)).map_err(|e| e.to_string())?; + win.set_max_size(Some(logical)).map_err(|e| e.to_string())?; + win.set_position(tauri::PhysicalPosition::new(x, y)) + .map_err(|e| e.to_string())?; + } + *app.state::().0.lock_ok() = Some((x, y)); + config::update_config_json(app, |cfg| cfg.pet_pos = Some((x, y)))?; + Ok(()) +} + +#[tauri::command] +pub fn pet_drag_finished(app: AppHandle) -> Result<(), String> { + fit_window(&app, false) +} + +#[tauri::command] +pub fn pet_reset_position(app: AppHandle) -> Result<(), String> { + fit_window(&app, true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn partial_sound_patch_preserves_other_preferences() { + let p = patched( + &Preferences::default(), + json!({"sounds":{"question":{"volume":35}}}), + ) + .unwrap(); + assert_eq!(p.sounds.question.volume, 35); + assert!(p.sounds.question.enabled); + assert_eq!(p.sounds.end.volume, 70); + assert!(patched(&p, json!({"scale":0})).is_err()); + assert!(patched(&p, json!({"quota_critical":30})).is_err()); + assert!(patched(&p, json!({"api_key":"no"})).is_err()); + } + #[test] + fn existing_sound_settings_gain_touch_sounds_without_overwriting_mutes() { + let p: Preferences = + serde_json::from_value(json!({"sounds":{"end":{"enabled":false,"volume":25}}})) + .unwrap(); + assert!(p.sounds.press.enabled); + assert!(p.sounds.release.enabled); + assert!(!p.sounds.end.enabled); + assert_eq!(p.sounds.end.volume, 25); + let next = patched( + &p, + json!({"sounds":{"press":{"volume":40},"release":{"enabled":false}}}), + ) + .unwrap(); + assert_eq!(next.sounds.press.volume, 40); + assert!(!next.sounds.release.enabled); + assert!(!next.sounds.end.enabled); + } + #[test] + fn snaps_inside_work_area_and_supports_negative_origins() { + assert_eq!( + fit_rect((-1910, 110), (116, 120), (-1920, 0, 1920, 1040), 20), + (-1920, 110) + ); + assert_eq!( + fit_rect((1810, 1000), (174, 180), (0, 0, 1920, 1040), 20), + (1746, 860) + ); + assert_eq!( + fit_rect((500, 400), (116, 120), (0, 0, 1920, 1040), 20), + (500, 400) + ); + } + #[test] + fn missing_wallet_fields_are_unknown_not_zero() { + assert_eq!(finite_field(&json!({}), "balance"), None); + assert_eq!(finite_field(&json!({"balance":0}), "balance"), Some(0.0)); + } + #[test] + fn account_identity_is_stable_and_isolated_by_server() { + let scope = account_scope("https://a.example", &json!({"id":42,"email":"old"})); + assert_eq!( + scope, + account_scope("https://a.example", &json!({"id":42,"email":"new"})) + ); + assert_ne!(scope, account_scope("https://a.example", &json!({"id":43}))); + assert_ne!(scope, account_scope("https://b.example", &json!({"id":42}))); + assert_eq!(account_scope("https://a.example", &json!({})), ""); + } +} diff --git a/desktop/src/pet_audio.rs b/desktop/src/pet_audio.rs new file mode 100644 index 000000000..5819b78d0 --- /dev/null +++ b/desktop/src/pet_audio.rs @@ -0,0 +1,226 @@ +//! Desktop-owned audio library. Import only bounded PCM WAV converted by the settings page. +use crate::{config, util::LockExt}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, + sync::Mutex, +}; +use tauri::AppHandle; + +const MAX_BYTES: usize = 5_760_044; // 30 s, stereo, 48 kHz, PCM16 +static LIBRARY: Mutex<()> = Mutex::new(()); +#[derive(Clone, Serialize, Deserialize)] +pub struct Asset { + pub id: String, + pub name: String, +} +fn valid_id(id: &str) -> bool { + id.len() == 64 + && id + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} +pub fn valid_source(source: &str) -> bool { + ["default", "soft", "toy", "bell"].contains(&source) + || source.strip_prefix("custom:").is_some_and(valid_id) +} +fn directory(app: &AppHandle) -> Result { + let dir = config::local_data_dir(app)?.join("pet-sounds"); + if dir.exists() + && !fs::symlink_metadata(&dir) + .map_err(|e| e.to_string())? + .is_dir() + { + return Err("音效目录无效".into()); + } + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} +fn read_file(path: &Path, limit: usize) -> Result, String> { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > limit as u64 { + return Err("音效文件无效或过大".into()); + } + let mut data = Vec::new(); + fs::File::open(path) + .map_err(|e| e.to_string())? + .take(limit as u64 + 1) + .read_to_end(&mut data) + .map_err(|e| e.to_string())?; + if data.len() > limit { + return Err("音效文件过大".into()); + } + Ok(data) +} +fn validate_wav(b: &[u8]) -> Result<(), String> { + let bad = || "音效需为 30 秒以内的 PCM16 WAV".to_string(); + if b.len() < 46 + || b.len() > MAX_BYTES + || &b[..4] != b"RIFF" + || &b[8..16] != b"WAVEfmt " + || &b[36..40] != b"data" + { + return Err(bad()); + } + let u16at = |i| u16::from_le_bytes(b[i..i + 2].try_into().unwrap()); + let u32at = |i| u32::from_le_bytes(b[i..i + 4].try_into().unwrap()); + let channels = u32::from(u16at(22)); + let rate = u32at(24); + let block = channels * 2; + if u32at(4) as usize != b.len() - 8 + || u32at(16) != 16 + || u16at(20) != 1 + || !(1..=2).contains(&channels) + || !(8000..=48000).contains(&rate) + || u16at(34) != 16 + || u32::from(u16at(32)) != block + || u32at(28) != rate * block + || u32at(40) as usize != b.len() - 44 + || (b.len() - 44) % block as usize != 0 + || b.len() - 44 > (rate * block * 30) as usize + { + return Err(bad()); + } + Ok(()) +} +fn list(dir: &Path) -> Result, String> { + let mut assets = Vec::new(); + for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { + let path = entry.map_err(|e| e.to_string())?.path(); + if path.extension().and_then(|v| v.to_str()) != Some("json") { + continue; + } + if let Ok(bytes) = read_file(&path, 4096) { + if let Ok(asset) = serde_json::from_slice::(&bytes) { + if valid_id(&asset.id) + && path.file_stem().and_then(|v| v.to_str()) == Some(&asset.id) + { + assets.push(asset); + } + } + } + } + assets.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(assets) +} +#[tauri::command] +pub async fn pet_sound_list(app: AppHandle) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let _lock = LIBRARY.lock_ok(); + list(&directory(&app)?) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn pet_sound_import(app: AppHandle, name: String, data: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if data.len() > (MAX_BYTES + 2) / 3 * 4 { + return Err("音效文件过大".into()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| e.to_string())?; + validate_wav(&bytes)?; + let asset = Asset { + id: format!("{:x}", Sha256::digest(&bytes)), + name: name.chars().filter(|c| !c.is_control()).take(80).collect(), + }; + if asset.name.trim().is_empty() { + return Err("音效名称不能为空".into()); + } + let _lock = LIBRARY.lock_ok(); + let dir = directory(&app)?; + let assets = list(&dir)?; + if assets.len() >= 20 && !assets.iter().any(|a| a.id == asset.id) { + return Err("最多保存 20 个音效,请先移除不使用的音效".into()); + } + config::atomic_write_private(&dir.join(format!("{}.wav", asset.id)), &bytes)?; + config::atomic_write_private( + &dir.join(format!("{}.json", asset.id)), + &serde_json::to_vec(&asset).map_err(|e| e.to_string())?, + )?; + Ok(asset) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn pet_sound_read(app: AppHandle, id: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + if !valid_id(&id) { + return Err("音效标识无效".into()); + } + let _lock = LIBRARY.lock_ok(); + let bytes = read_file(&directory(&app)?.join(format!("{id}.wav")), MAX_BYTES)?; + validate_wav(&bytes)?; + if format!("{:x}", Sha256::digest(&bytes)) != id { + return Err("音效文件损坏".into()); + } + Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) + }) + .await + .map_err(|e| e.to_string())? +} +#[tauri::command] +pub async fn pet_sound_remove(app: AppHandle, id: String) -> Result<(), String> { + use tauri::Manager; + tauri::async_runtime::spawn_blocking(move || { + if !valid_id(&id) { + return Err("音效标识无效".into()); + } + let state = app.state::(); + let prefs = state.prefs.lock_ok(); + let used = serde_json::to_value(&prefs.sounds).map_err(|e| e.to_string())?; + if used + .as_object() + .unwrap() + .values() + .any(|s| s["source"] == format!("custom:{id}")) + { + return Err("请先为使用此音效的事件选择其他音效".into()); + } + let _lock = LIBRARY.lock_ok(); + let dir = directory(&app)?; + for ext in ["json", "wav"] { + match fs::remove_file(dir.join(format!("{id}.{ext}"))) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bounded_wav_and_source_validation() { + let good = include_bytes!("../ui-next/public/sound-pet-press.wav"); + assert!(validate_wav(good).is_ok()); + assert!(validate_wav(&good[..good.len() - 2]).is_err()); + let mut broken = good.to_vec(); + broken[22] = 0; + assert!(validate_wav(&broken).is_err()); + assert!(!valid_source("custom:../../secret")); + assert!(!valid_source("https://example.com/sound.wav")); + assert!(valid_source(&format!("custom:{}", "a".repeat(64)))); + } +} + +#[tauri::command] +pub fn pet_audio_error(app: AppHandle, message: String) { + use tauri::Emitter; + let _ = app.emit_to( + "main", + "pet-audio-error", + message.chars().take(240).collect::(), + ); +} diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index c7b16c51c..5739f3b78 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -167,7 +167,19 @@ "allow-background-confirm", "allow-background-discard", "allow-background-read", - "allow-background-clear" + "allow-background-clear", + "allow-pet-preferences", + "allow-pet-set-preferences", + "allow-pet-set-enabled", + "allow-pet-wallet", + "allow-pet-activate", + "allow-pet-reset-position", + "allow-pet-preview-sound", + "allow-pet-sound-list", + "allow-pet-sound-import", + "allow-pet-sound-read", + "allow-pet-sound-remove", + "allow-pet-account-changed" ] }, { @@ -187,7 +199,14 @@ "allow-sessions-list", "allow-show-main", "allow-pet-native-render", - "allow-sound-enabled" + "allow-pet-audio-error", + "allow-pet-sound-read", + "allow-sound-enabled", + "allow-pet-preferences", + "allow-pet-wallet", + "allow-pet-activate", + "allow-pet-menu", + "allow-pet-drag-finished" ] } ] diff --git a/desktop/ui-next/public/pet-audio.mjs b/desktop/ui-next/public/pet-audio.mjs new file mode 100644 index 000000000..283a36893 --- /dev/null +++ b/desktop/ui-next/public/pet-audio.mjs @@ -0,0 +1,74 @@ +// A decoded-buffer player shared by both desktop renderers. Never seek/restart a media element. +const defaults = { start: 'sound-app-start.mp3', end: 'sound-task-end.mp3', error: 'sound-task-error.mp3', + approval: 'sound-permission.mp3', question: 'sound-permission.mp3', idle: 'sound-idle.mp3' }; +export function soundPath(key, source = 'default') { + if (source.startsWith('custom:')) return source; + const touch = key === 'press' || key === 'release'; + if (source === 'default' && !touch) return defaults[key]; + if (source === 'toy') return `sound-pet-${key === 'press' ? 'press' : 'release'}.wav`; + if (source === 'bell') return 'sound-pet-bell.wav'; + return `sound-pet-soft-${key === 'press' ? 'press' : 'release'}.wav`; +} +export class PetAudio { + constructor({ readCustom, context, fetchAudio = path => fetch(path).then(r => { if (!r.ok) throw new Error('音效加载失败'); return r.arrayBuffer(); }), report = console.warn } = {}) { + this.context = context || new (globalThis.AudioContext || globalThis.webkitAudioContext)({ latencyHint: 'interactive' }); + this.readCustom = readCustom; this.fetchAudio = fetchAudio; this.report = report; + this.buffers = new Map(); this.voices = new Map(); this.tickets = new Map(); this.epoch = 0; + } + load(path) { + if (!this.buffers.has(path)) { + const pending = (async () => { + let bytes; + if (path.startsWith('custom:')) { + const base64 = await this.readCustom(path.slice(7)); + bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)).buffer; + } else bytes = await this.fetchAudio(path); + return this.context.decodeAudioData(bytes); + })(); + this.buffers.set(path, pending); + pending.catch(() => { if (this.buffers.get(path) === pending) this.buffers.delete(path); }); + } + return this.buffers.get(path); + } + preload(settings) { + const paths = new Set(Object.entries(settings).map(([key, value]) => soundPath(key, value.source))); + for (const path of this.buffers.keys()) if (!paths.has(path)) this.buffers.delete(path); + for (const path of paths) void this.load(path).catch(error => this.report(error)); + } + retire(group) { + const voice = this.voices.get(group); + if (!voice) return; + this.voices.delete(group); + const now = this.context.currentTime; + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setValueAtTime(voice.volume, now); + voice.gain.gain.linearRampToValueAtTime(0, now + 0.008); + voice.source.stop(now + 0.01); + } + async play(key, setting, preview = false) { + const group = preview ? 'preview' : ['press', 'release'].includes(key) ? 'touch' : 'notice'; + const ticket = (this.tickets.get(group) || 0) + 1; + this.tickets.set(group, ticket); + const epoch = this.epoch, requested = Date.now(); + try { + // resume is called synchronously inside the user gesture; async decodes cannot queue old taps. + const resumed = this.context.state !== 'running' ? this.context.resume() : Promise.resolve(); + const [buffer] = await Promise.all([this.load(soundPath(key, setting.source)), resumed]); + if (epoch !== this.epoch || this.tickets.get(group) !== ticket || Date.now() - requested > (group === 'touch' ? 200 : 2000)) return; + this.retire(group); + const source = this.context.createBufferSource(), gain = this.context.createGain(); + const now = this.context.currentTime, volume = setting.volume / 100; + source.buffer = buffer; source.connect(gain); gain.connect(this.context.destination); + gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(volume, now + 0.004); + const end = now + Math.min(buffer.duration, 30); + gain.gain.setValueAtTime(volume, Math.max(now + 0.004, end - 0.01)); gain.gain.linearRampToValueAtTime(0, end); + const voice = { source, gain, volume }; this.voices.set(group, voice); + source.onended = () => { source.disconnect(); gain.disconnect(); if (this.voices.get(group) === voice) this.voices.delete(group); }; + source.start(now); source.stop(end); + } catch (error) { this.report(error); } + } + stop() { + this.epoch++; + for (const group of this.voices.keys()) this.retire(group); + } +} diff --git a/desktop/ui-next/public/pet-core.mjs b/desktop/ui-next/public/pet-core.mjs new file mode 100644 index 000000000..dc96a014c --- /dev/null +++ b/desktop/ui-next/public/pet-core.mjs @@ -0,0 +1,244 @@ +// 与 DOM/音频/窗口无关的桌宠状态;macOS/Linux 与 Windows 状态页共用。 +export const DEFAULTS = { + scale: 100, snap: true, bubble: 'tasks', click_bubble: 'credits', bubble_seconds: 6, quota_alerts: true, + quota_warning: 20, quota_critical: 10, credits_warning: 100, + quiet_enabled: false, quiet_start: 1320, quiet_end: 480, muted_until: 0, + sounds: Object.fromEntries(['press', 'release', 'start', 'end', 'error', 'approval', 'question', 'idle'] + .map(key => [key, { enabled: true, volume: 70, source: 'default' }])), +}; + +export function isQuiet(prefs, now = Date.now()) { + if (prefs.muted_until > now) return true; + if (!prefs.quiet_enabled) return false; + const d = new Date(now), minute = d.getHours() * 60 + d.getMinutes(); + const a = prefs.quiet_start, b = prefs.quiet_end; + return a === b || (a < b ? minute >= a && minute < b : minute >= a || minute < b); +} + +const waitLabel = { approval: '等待审批', question: '等待回答', design: '等待选择方案' }; +const terminalLabel = { idle: '已回复', finished: '完成 ✓', error: '出错 ✗', interrupted: '已中断' }; +const cleanTitle = value => (value || '任务').replace(/[\r\n\t]/g, ' ').slice(0, 100); +const creditsNumber = value => value == null ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 2 }); +export function creditsText(wallet, now = Date.now()) { + if (!wallet || wallet.status === 'signed_out') return '登录后查看积分'; + const sameDay = wallet.usage_day === Math.floor((now / 1000 + 28800) / 86400); + return `积分余额 ${creditsNumber(wallet.credits)}\n今日已用 ${creditsNumber(sameDay ? wallet.used_today : null)}${wallet.stale ? '\n数据为缓存' : ''}`; +} +const number = value => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}M` : Math.floor(value).toLocaleString('zh-CN'); + +function waits(session) { + if (!session.waiting_ask) return []; + return session.waiting_items?.length ? session.waiting_items : [{ id: session.id, kind: 'approval' }]; +} + +export function walletText(wallet, mode) { + if (!wallet || wallet.status === 'signed_out') return '登录后查看额度'; + const suffix = wallet.stale ? '(缓存)' : ''; + if (mode === 'credits' && wallet.credits != null) return `积分 ${number(wallet.credits)}${suffix}`; + if (mode === 'quota' && wallet.total > 0 && wallet.remaining != null) return `今日剩余 ${number(wallet.remaining)}${suffix}`; + if (mode === 'quota' && wallet.total === 0) return '暂无每日免费额度'; + return '额度暂不可用'; +} + +export class PetModel { + constructor(storage = null) { + this.storage = storage; + this.memory = new Map(); + this.sessions = new Map(); + this.versions = new Map(); + this.revision = 0; + this.notices = []; + this.online = false; + this.idleSince = null; + this.idlePlayed = false; + this.wallet = null; + this.alert = null; + this.pressed = false; + this.touchBubble = null; + this.touchCount = 0; + this.bounce = 0; + this.prefs = JSON.parse(JSON.stringify(DEFAULTS)); + this.seen = new Set(this.read('pet.seen-interactions', [])); + } + read(key, fallback) { + if (this.memory.has(key)) return this.memory.get(key); + try { return JSON.parse(this.storage?.getItem(key) || 'null') ?? fallback; } catch { return fallback; } + } + write(key, value) { this.memory.set(key, value); try { this.storage?.setItem(key, JSON.stringify(value)); } catch { /* 本轮去重仍有效 */ } } + remember(key) { + if (this.seen.has(key)) return false; + this.seen.add(key); + if (this.seen.size > 512) this.seen.delete(this.seen.values().next().value); + this.write('pet.seen-interactions', [...this.seen]); + return true; + } + snapshot(list, startedAtRevision = this.revision) { + const ids = new Set(list.map(s => s.id)); + for (const [id, s] of this.sessions) { + if (!ids.has(id) && s.revision <= startedAtRevision) this.sessions.delete(id); + } + for (const s of list) { + if ((this.versions.get(s.id) ?? -1) > startedAtRevision) continue; + if (s.archived) { this.sessions.delete(s.id); continue; } + this.sessions.set(s.id, { ...s, title: cleanTitle(s.title), revision: startedAtRevision }); + // 重连快照恢复待处理状态,但不再播放历史请求的声音。 + for (const item of waits(s)) this.remember(`${s.id}:${item.kind}:${item.id}`); + } + this.notices = this.notices.filter(n => this.sessions.has(n.id)); + for (const [id, revision] of this.versions) if (revision <= startedAtRevision) this.versions.delete(id); + this.online = true; + } + event(e, now = Date.now()) { + if (!e?.id) return []; + const old = this.sessions.get(e.id); + const next = { ...old, id: e.id, title: cleanTitle(e.title || old?.title), revision: ++this.revision }; + this.versions.set(e.id, next.revision); + if (e.archived || e.status === 'deleted' || e.type === 'session-deleted') { + this.sessions.delete(e.id); + this.notices = this.notices.filter(n => n.id !== e.id); + return []; + } + const sounds = []; + if (e.type === 'session-status') { + next.status = e.status; + if (e.status === 'running') this.notices = this.notices.filter(n => n.id !== e.id); + if (terminalLabel[e.status] && old?.status !== e.status) { + const failed = e.status === 'error' || e.status === 'interrupted'; + if (!isQuiet(this.prefs, now)) { + this.notices = this.notices.filter(n => n.id !== e.id); + this.notices.push({ id: e.id, text: `「${next.title}」${terminalLabel[e.status]}`, title: next.title, + label: terminalLabel[e.status], tone: failed ? 'err' : 'ok', cheer: e.status === 'finished', expires: null }); + this.notices = this.notices.slice(-100); + this.notices.sort((a, b) => Number(b.tone === 'err') - Number(a.tone === 'err')); + } + sounds.push(failed ? 'error' : 'end'); + } + } else if (e.type === 'session-ask') { + next.waiting_ask = !!e.open; + next.waiting_items = e.waiting_items || []; + for (const item of waits(next)) { + if (this.remember(`${e.id}:${item.kind}:${item.id}`)) sounds.push(item.kind === 'approval' ? 'approval' : 'question'); + } + } else if (e.type !== 'session-summary') return []; + this.sessions.set(e.id, next); + this.online = true; + return [...new Set(sounds)]; + } + updateWallet(wallet, now = Date.now()) { + this.wallet = wallet; + if (!wallet?.scope || wallet.status !== 'ready' || wallet.stale) { + this.alert = null; + return; + } + if (!this.prefs.quota_alerts) { this.alert = null; return; } + const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now); + const key = `pet.alerts:${wallet.scope}`; + let seen = this.read(key, { day, levels: [] }); + if (seen.day !== day || !Array.isArray(seen.levels)) seen = { day, levels: [] }; + const levels = []; + const ratio = wallet.total > 0 && wallet.remaining != null ? wallet.remaining / wallet.total * 100 : null; + if (ratio != null) { + for (const threshold of [this.prefs.quota_warning, this.prefs.quota_critical]) { + if (ratio <= threshold) levels.push([`quota:${threshold}`, `今日免费额度剩余 ${Math.floor(ratio)}%`]); + } + } + if (this.prefs.credits_warning > 0 && wallet.credits != null && wallet.credits <= this.prefs.credits_warning) { + levels.push([`credits:${this.prefs.credits_warning}`, `积分剩余 ${number(wallet.credits)}`]); + } + const fresh = levels.filter(([level]) => !seen.levels.includes(level)); + if (fresh.length) { + const quota = fresh.filter(([level]) => level.startsWith('quota:')).slice(-1); + const credits = fresh.filter(([level]) => level.startsWith('credits:')); + this.alert = isQuiet(this.prefs, now) ? null : { scope: wallet.scope, levels: fresh.map(([l]) => l), + text: [...quota, ...credits].map(([, text]) => text).join(' · '), until: null }; + seen.levels.push(...fresh.map(([l]) => l)); + this.write(key, seen); + } else if (this.alert && (this.alert.scope !== wallet.scope || !levels.some(([l]) => this.alert.levels.includes(l)))) this.alert = null; + } + press() { + if (this.pressed) return false; + this.pressed = true; + return true; + } + release(now = Date.now()) { + if (!this.pressed) return false; + this.pressed = false; + const replies = ['我在呢,摸摸~', '嘿嘿,有点痒!', '收到你的鼓励啦', '陪你一起加油!', '记得喝口水呀']; + this.touchBubble = { text: replies[this.touchCount++ % replies.length], until: this.prefs.bubble_seconds === 0 ? Infinity : now + this.prefs.bubble_seconds * 1000 }; + this.bounce++; + return true; + } + cancelPress() { this.pressed = false; } + view(now = Date.now(), hovered = false) { + const base = this.statusView(now, hovered); + const important = base.state === 'waiting' || base.tone === 'err' || base.tone === 'warn'; + let extra = {}; + if (this.touchBubble?.until > now && this.prefs.click_bubble !== 'none') { + if (this.prefs.click_bubble === 'credits') { + const text = creditsText(this.wallet, now); + extra = { text: `${important ? base.text + '\n' : ''}${text}`, action: important ? base.action : 'account', + detail: `${base.detail}\n${text}\n今日按北京时间汇总模型、主机、工具的积分消费,不含免费 Token。\n双击猴子打开任务,右键查看更多` }; + } else if (!important) extra = { text: this.touchBubble.text, tone: '', detail: `${this.touchBubble.text}\n双击猴子打开任务,右键查看更多` }; + } + return { ...base, ...extra, pressed: this.pressed, bounce: this.bounce }; + } + + statusView(now = Date.now(), hovered = false) { + const pending = [], running = []; + for (const s of this.sessions.values()) { + if (s.archived) continue; + if (s.waiting_ask) { + const labels = [...new Set(waits(s).map(w => waitLabel[w.kind] || '等待处理'))]; + pending.push({ id: s.id, title: s.title, label: labels.join(' / ') }); + } else if (s.status === 'running') running.push(s); + } + const quiet = isQuiet(this.prefs, now); + if (quiet) { this.notices = []; this.alert = null; } + const idle = this.online && !pending.length && !running.length; + if (!idle) { this.idleSince = null; this.idlePlayed = false; } + else if (this.idleSince === null) this.idleSince = now; + if (!pending.length) { + while (this.notices.length && this.notices[0].expires != null && this.notices[0].expires <= now) this.notices.shift(); + } + const tasks = [...pending]; + for (const n of this.notices) if (!tasks.some(t => t.id === n.id)) tasks.push({ id: n.id, title: n.title, label: n.label }); + for (const s of running) if (!tasks.some(t => t.id === s.id)) tasks.push({ id: s.id, title: s.title, label: '运行中' }); + const view = { state: pending.length ? 'waiting' : running.length ? 'running' : 'idle', tone: '', text: '', detail: '', action: 'main', tasks }; + if (!this.online) return { ...view, state: 'offline', text: '内核休息中 Zzz', detail: '内核连接暂不可用,右键可查看设置', action: 'settings', tasks: [] }; + if (pending.length) { + for (const n of this.notices) n.expires = null; + if (this.alert) this.alert.until = null; + const first = pending[0]; + return { ...view, state: quiet ? 'idle' : 'waiting', tone: 'warn', text: pending.length > 1 ? `${pending.length} 个任务等你处理` : first.label, + detail: pending.map(t => `${t.title} · ${t.label}`).join('\n'), action: pending.length > 1 ? 'menu' : `session:${first.id}` }; + } + // 等待操作时不消耗其他提示的显示时间;新一轮开始时会清掉旧通知。 + const notice = this.notices[0]; + if (notice?.tone === 'err' && this.alert) this.alert.until = null; + if (this.alert && notice?.tone !== 'err') { + if (this.alert.until == null) this.alert.until = now + 8000; + if (this.alert.until > now) { + if (notice) notice.expires = null; + return { ...view, tone: 'warn', text: this.alert.text, detail: `${this.alert.text},点击查看账号权益`, action: 'account' }; + } + } + if (notice) { + if (notice.expires == null) notice.expires = now + 4000; + return { ...view, state: notice.cheer ? 'celebrate' : view.state, tone: notice.tone, text: notice.text, + detail: this.notices.map(n => n.text).join('\n'), action: `session:${notice.id}` }; + } + if (this.prefs.bubble === 'quota' || this.prefs.bubble === 'credits') { + const text = walletText(this.wallet, this.prefs.bubble); + return { ...view, text, detail: `${text}${this.wallet?.updated_at ? `\n更新于 ${new Date(this.wallet.updated_at).toLocaleTimeString()}` : ''}`, action: 'account' }; + } + if (this.prefs.bubble === 'none' && !hovered) return view; + if (running.length) return { ...view, tone: 'ok', text: hovered && running.length === 1 ? running[0].title : `${running.length} 个任务运行中`, + detail: running.map(s => `「${s.title}」运行中`).join('\n'), action: running.length === 1 ? `session:${running[0].id}` : 'menu' }; + return { ...view, text: hovered ? '点我摸摸~' : '', detail: '单击摸摸,双击打开主窗口,右键查看任务、额度和萌宠设置' }; + } + takeIdleSound(now = Date.now()) { + if (this.idleSince === null || this.idlePlayed || now - this.idleSince < 600_000) return false; + this.idlePlayed = true; + return !isQuiet(this.prefs, now); + } +} diff --git a/desktop/ui-next/public/pet-runtime.mjs b/desktop/ui-next/public/pet-runtime.mjs new file mode 100644 index 000000000..f6c3ae870 --- /dev/null +++ b/desktop/ui-next/public/pet-runtime.mjs @@ -0,0 +1,339 @@ +import { PetModel, isQuiet } from './pet-core.mjs'; +import { PetAudio } from './pet-audio.mjs'; +const invoke = window.__TAURI__.core.invoke; +const listen = window.__TAURI__.event.listen; +const bubble = document.getElementById('bubble'); +const sprite = document.getElementById('sprite'); +const wrap = document.getElementById('wrap'); +let storage; +try { storage = window.localStorage; } catch { /* 存储关闭时使用内存状态 */ } +const model = new PetModel(storage); +let currentView = model.view(), hovered = false, petScale = 1; +let soundEnabled = false, started = false, lastNative = '', snapshotBusy = false; +let walletBusy = false, walletRevision = 0, lastWalletRefresh = 0; +let retryTimer; +let wasQuiet = false; +let lastBounce = 0, bounceAnimation; +let nativeBusy = false, pendingNative; +async function flushNative() { + if (nativeBusy || !pendingNative) return; + nativeBusy = true; + const view = pendingNative; pendingNative = null; + try { await invoke('pet_native_render', { view }); } catch (error) { lastNative = ''; reportError(error); } + finally { nativeBusy = false; if (pendingNative) void flushNative(); } +} +let player; +try { player = new PetAudio({ readCustom: id => invoke('pet_sound_read', { id }), report: error => { + reportError(error); void invoke('pet_audio_error', { message: String(error) }).catch(() => {}); +} }); } catch (error) { console.warn('[pet] 音频初始化失败', error); } +const lastPlay = new Map(); +function play(key, preview = false) { + const setting = model.prefs.sounds[key], now = Date.now(); + const cooldown = key === 'press' || key === 'release' ? 80 : 2000; + if (!soundEnabled || !setting?.enabled || (!preview && (isQuiet(model.prefs, now) || now - (lastPlay.get(key) ?? -Infinity) < cooldown))) return; + lastPlay.set(key, now); + void player?.play(key, setting, preview); +} +function stopSounds() { player?.stop(); } +function reportError(error) { console.warn('[pet]', error); bubble.title = String(error); } +function applyPreferences(payload) { + if (!payload?.preferences) return; + stopSounds(); + model.prefs = payload.preferences; + player?.preload(model.prefs.sounds); + if (model.wallet) model.updateWallet(model.wallet); + petScale = model.prefs.scale / 100; + document.body.style.transform = `scale(${petScale})`; + if (isQuiet(model.prefs)) stopSounds(); + render(); +} +function render() { + const quiet = isQuiet(model.prefs); + if (quiet && !wasQuiet) stopSounds(); + wasQuiet = quiet; + const view = model.view(Date.now(), hovered); + currentView = view; + wrap.classList.toggle('pressed', view.pressed); + if (view.pressed) bounceAnimation?.cancel(); + if (view.bounce !== lastBounce) { + lastBounce = view.bounce; + bounceAnimation?.cancel(); + if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + bounceAnimation = wrap.animate?.([ + { transform: 'scale(1.10,.84)' }, { transform: 'scale(.96,1.06)', offset: .32 }, + { transform: 'scale(1.025,.97)', offset: .62 }, { transform: 'scale(1,1)' }, + ], { duration: 480, easing: 'ease-out' }); + } + } + // 同状态不重写 class,避免不断重启动画。 + const classes = 'st-' + view.state + (view.text ? ' has-bubble' : ''); + if (document.body.className !== classes) document.body.className = classes; + bubble.className = view.tone; + bubble.textContent = view.text; + bubble.title = view.detail; + sprite.title = `单击摸摸,双击打开任务,右键打开菜单\n${view.detail}`; + const serialized = JSON.stringify(view); + if (serialized !== lastNative) { + lastNative = serialized; + pendingNative = view; void flushNative(); + } + if (model.takeIdleSound()) play('idle'); +} +function touch(phase) { + if (phase === 'press' && model.press()) play('press'); + if (phase === 'release' && model.release()) { play('release'); if (model.prefs.click_bubble === 'credits') void refreshWallet(true); } + if (phase === 'cancel') model.cancelPress(); + if (phase === 'activate') invoke('pet_activate', { action: model.statusView().action }).catch(reportError); + render(); +} +async function snapshot() { + if (snapshotBusy) return; + snapshotBusy = true; + const revision = model.revision; + try { + const list = await invoke('sessions_list'); + model.snapshot(list, revision); + if (!started) { started = true; play('start'); } + } catch { + if (revision === model.revision) model.online = false; + clearTimeout(retryTimer); + retryTimer = setTimeout(snapshot, 3000); + } finally { snapshotBusy = false; render(); } +} +async function refreshWallet(force = false) { + if (walletBusy || (force && Date.now() - lastWalletRefresh < 5000)) return; + walletBusy = true; + const revision = walletRevision; + try { + const value = await invoke('pet_wallet', { force }); + if (revision === walletRevision) { model.updateWallet(value); lastWalletRefresh = Date.now(); } + } catch { if (revision === walletRevision && model.wallet) model.wallet = { ...model.wallet, stale: true }; } + finally { + walletBusy = false; render(); + if (revision !== walletRevision) void refreshWallet(); + } +} +function invalidateWallet() { + walletRevision++; model.wallet = null; model.alert = null; lastWalletRefresh = 0; + render(); void refreshWallet(); +} +async function boot() { + await Promise.all([ + listen('session-event', ({ payload }) => { + for (const sound of model.event(payload)) play(sound); + render(); + if (['idle','finished','error','interrupted'].includes(payload?.status)) void refreshWallet(true); + }), + listen('sound-enabled', ({ payload }) => { soundEnabled = payload !== false; if (!soundEnabled) stopSounds(); }), + listen('pet-preferences', ({ payload }) => applyPreferences(payload)), + listen('pet-refresh-wallet', () => void refreshWallet(true)), + listen('pet-error', ({ payload }) => reportError(payload)), + listen('pet-preview-sound', ({ payload }) => play(payload, true)), + // Windows 原生可见窗口把手势交给同一状态页,保证触摸、气泡和声音一致。 + listen('pet-touch', ({ payload }) => touch(payload)), + listen('monkeycode-transport-changed', invalidateWallet), + listen('pet-wallet-invalidated', invalidateWallet), + ]); + await Promise.all([ + invoke('pet_preferences').then(applyPreferences).catch(reportError), + invoke('sound_enabled').then(on => { soundEnabled = on !== false; }).catch(reportError), + ]); + void snapshot(); void refreshWallet(); + setInterval(snapshot, 10000); + setInterval(() => void refreshWallet(), 60000); + setInterval(render, 500); +} +void boot().catch(reportError); + + // ---- 交互:拖动(位移阈值)与点击(唤回主窗口) ---- + // macOS 不能走原生 startDragging:tao 在 mac 上用 performWindowDragWithEvent, + // 它只认"当下正在处理的 mousedown 事件"——阈值方案到 mousemove 才发起, + // 事件已过期,原生拖拽静默失败(窗口不动,松手还被判成点击)。 + // mac 改为手动移窗(setPosition 跟随鼠标);Windows/Linux 保留原生拖拽 + // (WM_NCLBUTTONDOWN/合成器语义,按住期间任何时刻发起都有效,且更顺滑)。 + const isMac = /Mac/i.test(navigator.platform || navigator.userAgent); + const petWin = () => window.__TAURI__.window.getCurrentWindow(); + let down = null, dragged = false, winPos = null; + let nativeDrag = false, moveTimer, lastMoved = ''; + // Linux 的 startDragging 只投递窗口消息,不等待松手;以移动停止兜底收口。 + if (!isMac && /Linux/i.test(navigator.platform || navigator.userAgent)) { + petWin().onMoved(({ payload }) => { + if (!nativeDrag) return; + const position = `${payload.x},${payload.y}`; + if (position === lastMoved) return; + lastMoved = position; + clearTimeout(moveTimer); + moveTimer = setTimeout(() => invoke('pet_drag_finished').catch(reportError), 600); + }).catch(reportError); + } + + // macOS 的透明 NSPanel 默认仍按完整矩形吃鼠标。按精灵图当前帧的 alpha + // 和气泡实际形状做命中检测;光标落在透明像素时临时开启穿透。 + // 穿透后页面收不到 mousemove,所以用全局光标位置低频探测重新进入热区。 + const MAC_HIT_POLL_MS = 50; + const SPRITE_CSS_SIZE = 88; + const SPRITE_SOURCE_SIZE = 176; + const SPRITE_FRAMES = 52; + const SPRITE_ALPHA_HIT = 20; + let macSpriteMasks = null; + let macWindowOrigin = null; + let macCursorScale = window.devicePixelRatio || 1; + let macOriginUpdatedAt = 0; + let macIgnoring = null; + let macHitPollBusy = false; + + function buildMacSpriteMasks() { + if (!isMac) return; + const image = new Image(); + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = SPRITE_CSS_SIZE; + canvas.height = SPRITE_CSS_SIZE; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + const masks = []; + for (let frame = 0; frame < SPRITE_FRAMES; frame++) { + ctx.clearRect(0, 0, SPRITE_CSS_SIZE, SPRITE_CSS_SIZE); + ctx.drawImage(image, + frame * SPRITE_SOURCE_SIZE, 0, SPRITE_SOURCE_SIZE, SPRITE_SOURCE_SIZE, + 0, 0, SPRITE_CSS_SIZE, SPRITE_CSS_SIZE); + const rgba = ctx.getImageData(0, 0, SPRITE_CSS_SIZE, SPRITE_CSS_SIZE).data; + const alpha = new Uint8Array(SPRITE_CSS_SIZE * SPRITE_CSS_SIZE); + for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3]; + masks.push(alpha); + } + macSpriteMasks = masks; + }; + image.src = 'pet-sprite.webp'; + } + + function inRoundedRect(x, y, rect, radius) { + if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) return false; + const cx = Math.max(rect.left + radius, Math.min(x, rect.right - radius)); + const cy = Math.max(rect.top + radius, Math.min(y, rect.bottom - radius)); + const dx = x - cx, dy = y - cy; + return dx * dx + dy * dy <= radius * radius; + } + + function inBubble(x, y) { + if (!bubble.textContent) return false; + const rect = bubble.getBoundingClientRect(); + if (inRoundedRect(x, y, rect, 9 * petScale)) return true; + // ::after 的 8×4 三角尾巴不在 getBoundingClientRect 内。 + const tailY = y - rect.bottom; + return tailY >= 0 && tailY < 4 * petScale && Math.abs(x - (rect.left + rect.right) / 2) <= 4 * petScale - tailY; + } + + function inSpritePixel(x, y) { + const rect = sprite.getBoundingClientRect(); + if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) return false; + // 图片尚未解码时先以 88×88 兜底,避免启动瞬间宠物完全点不到。 + if (!macSpriteMasks) return true; + const backgroundX = Number.parseFloat(getComputedStyle(sprite).backgroundPositionX) || 0; + const frame = Math.max(0, Math.min(SPRITE_FRAMES - 1, + Math.round(-backgroundX / SPRITE_CSS_SIZE))); + const px = Math.max(0, Math.min(SPRITE_CSS_SIZE - 1, Math.floor((x - rect.left) / (rect.width / SPRITE_CSS_SIZE)))); + const py = Math.max(0, Math.min(SPRITE_CSS_SIZE - 1, Math.floor((y - rect.top) / (rect.height / SPRITE_CSS_SIZE)))); + return macSpriteMasks[frame][py * SPRITE_CSS_SIZE + px] >= SPRITE_ALPHA_HIT; + } + + function inMacPetHotRegion(x, y) { + return inBubble(x, y) || inSpritePixel(x, y); + } + + async function setMacMouseIgnoring(ignore) { + if (!isMac || down || macIgnoring === ignore) return; + macIgnoring = ignore; + try { + await petWin().setIgnoreCursorEvents(ignore); + } catch { + macIgnoring = null; + } + } + + async function pollMacHotRegion() { + if (!isMac || down || macHitPollBusy) return; + macHitPollBusy = true; + try { + const now = Date.now(); + if (!macWindowOrigin || now - macOriginUpdatedAt > 1000) { + const [origin, primary] = await Promise.all([ + petWin().outerPosition(), + window.__TAURI__.window.primaryMonitor(), + ]); + macWindowOrigin = origin; + macCursorScale = primary && primary.scaleFactor || 1; + macOriginUpdatedAt = now; + } + const cursor = await window.__TAURI__.window.cursorPosition(); + // Tao 的全局光标按主屏 scaleFactor 返回,窗口位置按当前屏幕返回; + // 分别还原为逻辑坐标,兼容 Retina + 普通屏混用。 + const windowScale = window.devicePixelRatio || 1; + const x = cursor.x / macCursorScale - macWindowOrigin.x / windowScale; + const y = cursor.y / macCursorScale - macWindowOrigin.y / windowScale; + await setMacMouseIgnoring(!inMacPetHotRegion(x, y)); + } catch { + // 旧系统偶发取不到全局光标时保持上次状态,下一轮重试。 + } finally { + macHitPollBusy = false; + } + } + + document.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + const onBubble = inBubble(e.clientX, e.clientY); + if (!onBubble && !inSpritePixel(e.clientX, e.clientY)) return; + nativeDrag = false; + clearTimeout(moveTimer); + down = { x: e.screenX, y: e.screenY, onBubble, action: currentView.action }; + dragged = false; + if (!onBubble) touch('press'); + winPos = null; + if (isMac) petWin().outerPosition().then((p) => { + winPos = p; + macWindowOrigin = p; + macOriginUpdatedAt = Date.now(); + }).catch(() => {}); + }); + document.addEventListener('mousemove', (e) => { + if (isMac && !down) setMacMouseIgnoring(!inMacPetHotRegion(e.clientX, e.clientY)); + if (!down) return; + const dx = e.screenX - down.x, dy = e.screenY - down.y; + if (!dragged && Math.abs(dx) + Math.abs(dy) <= 4) return; + if (!dragged) touch('cancel'); + dragged = true; + if (isMac) { + if (!winPos) return; // outerPosition 未返回前先不动 + const s = window.devicePixelRatio || 1; // screenX 是逻辑坐标,窗口位置是物理像素 + const x = Math.round(winPos.x + dx * s), y = Math.round(winPos.y + dy * s); + macWindowOrigin = { x, y }; + macOriginUpdatedAt = Date.now(); + petWin().setPosition(new window.__TAURI__.dpi.PhysicalPosition(x, y)); + } else { + down = null; // OS 接管拖动后 mouseup 不可靠,直接终结本次手势 + nativeDrag = true; + petWin().startDragging().catch(reportError); + } + }); + document.addEventListener('mouseup', (e) => { + if (e.button !== 0) return; + if (down && !dragged) { + if (down.onBubble) invoke('pet_activate', { action: down.action }).catch(reportError); + else { touch('release'); if (e.detail === 2) touch('activate'); } + } + else if (down && dragged) invoke('pet_drag_finished').catch(reportError); + down = null; + }); + function cancelGesture() { down = null; touch('cancel'); } + document.addEventListener('contextmenu', (e) => { e.preventDefault(); cancelGesture(); invoke('pet_menu').catch(reportError); }); + window.addEventListener('blur', cancelGesture); + document.addEventListener('pointercancel', cancelGesture); + document.addEventListener('mouseenter', () => { hovered = true; render(); }); + document.addEventListener('mouseleave', () => { hovered = false; render(); }); + if (isMac) { + buildMacSpriteMasks(); + pollMacHotRegion(); + setInterval(pollMacHotRegion, MAC_HIT_POLL_MS); + } diff --git a/desktop/ui-next/public/pet.html b/desktop/ui-next/public/pet.html index 50d4c155e..16eaaafab 100644 --- a/desktop/ui-next/public/pet.html +++ b/desktop/ui-next/public/pet.html @@ -4,7 +4,7 @@ 形象:官方吉祥物 52 帧精灵图(pet-sprite.webp,88px@2x), CSS steps 逐帧播放——待机慢速打盹、运行中正面敲键盘、等审批定格 摇晃、离线定格在低头睡着帧并灰化。 - 数据流:sessions_list 命令取快照 → session-event 壳事件持续更新 + 数据流:pet-core.mjs 聚合会话与提醒 → pet-runtime.mjs 展示/音效 → 原生窗口同步 (壳聚合引擎状态,引擎重启对本页透明);快照失败视为引擎离线, 3s 重试,另有 10s 周期性快照兜底探活。 --> @@ -15,18 +15,19 @@ * { box-sizing:border-box; user-select:none; -webkit-user-select:none; } html, body { margin:0; background:transparent; overflow:hidden; } body { - width:116px; height:120px; display:flex; flex-direction:column; + width:180px; height:164px; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; font:11px/1.4 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; cursor:grab; } body:active { cursor:grabbing; } + body { transform-origin:top left; } /* ---- 气泡 ---- */ #bubble { - max-width:112px; padding:4px 9px; border-radius:9px; margin-bottom:2px; + max-width:176px; flex-shrink:0; padding:4px 9px; border-radius:9px; margin-bottom:2px; background:rgba(19,19,22,.88); color:#d8d8de; text-align:center; - white-space:nowrap; overflow:hidden; text-overflow:ellipsis; position:relative; + white-space:pre; overflow:hidden; text-overflow:ellipsis; position:relative; opacity:0; transform:translateY(4px); transition:opacity .2s, transform .2s; } #bubble::after { /* 小尾巴 */ @@ -44,7 +45,13 @@ /* ---- 吉祥物精灵图:52 帧横条。 帧段:1-7 无所事事 / 8-37 正面敲键盘 / 38-44 看通知 / 45-51 庆祝 / 52 睡着。 各状态在自己的帧段内 steps 循环;庆祝只播一遍后定格末姿势 ---- */ - #wrap { width:88px; height:88px; } + #wrap { width:88px; height:88px; flex-shrink:0; } + /* 只变换原图的外层,按压始终以脚底为锚点,不打断精灵帧动画。 */ + #wrap { transform-origin:center bottom; } + #wrap.pressed { transform:scale(1.10,.84); } + @media (prefers-reduced-motion:reduce) { + #wrap.pressed { transform:scale(1.025,.97); } + } #sprite { width:88px; height:88px; background:url(pet-sprite.webp) 0 0 / 4576px 88px no-repeat; @@ -71,356 +78,6 @@
- + diff --git a/desktop/ui-next/public/sound-pet-bell.wav b/desktop/ui-next/public/sound-pet-bell.wav new file mode 100644 index 000000000..ec0842de7 Binary files /dev/null and b/desktop/ui-next/public/sound-pet-bell.wav differ diff --git a/desktop/ui-next/public/sound-pet-press.wav b/desktop/ui-next/public/sound-pet-press.wav new file mode 100644 index 000000000..45ae4309d Binary files /dev/null and b/desktop/ui-next/public/sound-pet-press.wav differ diff --git a/desktop/ui-next/public/sound-pet-release.wav b/desktop/ui-next/public/sound-pet-release.wav new file mode 100644 index 000000000..b28c533a4 Binary files /dev/null and b/desktop/ui-next/public/sound-pet-release.wav differ diff --git a/desktop/ui-next/public/sound-pet-soft-press.wav b/desktop/ui-next/public/sound-pet-soft-press.wav new file mode 100644 index 000000000..a58f1f795 Binary files /dev/null and b/desktop/ui-next/public/sound-pet-soft-press.wav differ diff --git a/desktop/ui-next/public/sound-pet-soft-release.wav b/desktop/ui-next/public/sound-pet-soft-release.wav new file mode 100644 index 000000000..ec7e02c62 Binary files /dev/null and b/desktop/ui-next/public/sound-pet-soft-release.wav differ diff --git a/desktop/ui-next/src/app/App.tsx b/desktop/ui-next/src/app/App.tsx index f15bdfa5f..be91246d6 100644 --- a/desktop/ui-next/src/app/App.tsx +++ b/desktop/ui-next/src/app/App.tsx @@ -1,3 +1,4 @@ +import { notifyPetAccountChanged } from "@/lib/ipc/pet"; // 壳层拼装:标题栏 + **工作台主壳**(2026-08-18 用户终案「工作台升级为 // 主界面」:旧 rail/侧栏/单会话主区三列壳退役——导航收进工作台任务列的 // 三 tab,设置/新建仍是覆盖视图,云端任务与本地任务/会话一样入格)。 @@ -164,6 +165,7 @@ function AppShell({ onTransportChanged }: { onTransportChanged: (generation: num clearCloud(); setCloudIdentityRevision((n) => n + 1); reloadCloud(); + if (inDesktopShell()) void notifyPetAccountChanged().catch(() => {}); }, [clearCloud, reloadCloud]); // 云端数据源(任务列云端 tab 与格内 CloudTaskView 同一份;工作台即主壳, // 恒启用) @@ -178,7 +180,7 @@ function AppShell({ onTransportChanged }: { onTransportChanged: (generation: num const focusSeqRef = useRef(0); const requestComposerFocus = () => setComposerFocusRequest(++focusSeqRef.current); const openSettings = useCallback((section: SettingsSection = "account") => { - if (settingsOpenRef.current) return; + if (settingsOpenRef.current) { settingsRef.current?.openSection(section); return; } settingsOpenRef.current = true; setSettingsInitialSection(section); const active = document.activeElement; @@ -389,17 +391,17 @@ function AppShell({ onTransportChanged }: { onTransportChanged: (generation: num // 壳意图:启动补取一次(窗口唤起前托盘/桌宠塞的),再听后续推送 void takeUiIntent().then((intent) => { if (!alive) return; - if (intent === "open-settings") { - openSettings(); + if (intent === "open-settings" || intent === "open-settings:pet" || intent === "open-settings:account") { + openSettings(intent === "open-settings:pet" ? "pet" : "account"); return; } const id = sessionIdFromUiIntent(intent); if (id) void openSessionByIdRef.current(id); }); // H9:事件送达立即消费壳侧意图副本——不消费的话整页刷新会重放同一意图 - const offOpenSettings = listen("open-settings", () => { + const offOpenSettings = listen("open-settings", (section) => { void takeUiIntent(); - openSettings(); + openSettings(section === "pet" ? "pet" : "account"); }); const offOpenSession = listen("open-session", (id) => { void takeUiIntent(); diff --git a/desktop/ui-next/src/features/chat/composer/Composer.test.tsx b/desktop/ui-next/src/features/chat/composer/Composer.test.tsx index 729382b67..84fc5e10a 100644 --- a/desktop/ui-next/src/features/chat/composer/Composer.test.tsx +++ b/desktop/ui-next/src/features/chat/composer/Composer.test.tsx @@ -1001,6 +1001,7 @@ describe("会话技能 server revision", () => { it("同会话快速点击严格按意图顺序调用,逆序延迟 mock 下后端最终仍是最新选择", async () => { const invocations: string[][] = []; + let completeFirst: () => void = () => { throw new Error("第一笔技能请求尚未发出"); }; let activeCalls = 0; let maxActiveCalls = 0; let backendSkills: string[] = []; @@ -1013,13 +1014,17 @@ describe("会话技能 server revision", () => { invocations.push(next); activeCalls += 1; maxActiveCalls = Math.max(maxActiveCalls, activeCalls); - // 若并发,第二次会先完成,随后旧调用覆盖后端;只有 invocation - // 本身串行,最终后端才会保持最后一次用户意图。 - return new Promise((resolve) => window.setTimeout(() => { - backendSkills = next; - activeCalls -= 1; - resolve({ result: { skills: next, skills_revision: callIndex === 0 ? 3 : 4 } }); - }, callIndex === 0 ? 30 : 0)); + // 第一笔由测试显式放行,避免真实计时在 CI 负载下先于点击结束。 + // 若并发,第二笔会先完成并暴露旧调用覆盖最新选择的问题。 + return new Promise((resolve) => { + const complete = () => { + backendSkills = next; + activeCalls -= 1; + resolve({ result: { skills: next, skills_revision: callIndex === 0 ? 3 : 4 } }); + }; + if (callIndex === 0) completeFirst = complete; + else complete(); + }); }, }); render(); @@ -1032,6 +1037,7 @@ describe("会话技能 server revision", () => { await userEvent.click(within(menu).getByRole("checkbox", { name: "a" })); // op2(latest): b expect(invocations).toEqual([["a", "b"]]); expect(within(menu).getByRole("checkbox", { name: "a" }).getAttribute("aria-checked")).toBe("false"); + await act(async () => completeFirst()); await waitFor(() => expect(invocations).toEqual([["a", "b"], ["b"]])); await waitFor(() => expect(backendSkills).toEqual(["b"])); expect(maxActiveCalls).toBe(1); diff --git a/desktop/ui-next/src/features/settings/PetSection.test.tsx b/desktop/ui-next/src/features/settings/PetSection.test.tsx new file mode 100644 index 000000000..db649eb00 --- /dev/null +++ b/desktop/ui-next/src/features/settings/PetSection.test.tsx @@ -0,0 +1,111 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { setLocale } from "@/lib/i18n"; +import type { PetPreferences, PetSettings } from "@/lib/ipc/pet"; +import { PetSection } from "./PetSection"; +vi.mock("@/lib/pet-audio-import", () => ({ preparePetAudio: vi.fn(async () => "test-audio") })); + +const defaults: PetPreferences = { + scale: 100, snap: true, bubble: "tasks", click_bubble: "credits", bubble_seconds: 6, quota_alerts: true, quota_warning: 20, quota_critical: 10, + credits_warning: 100, quiet_enabled: false, quiet_start: 1320, quiet_end: 480, muted_until: 0, + sounds: Object.fromEntries(["press", "release", "start", "end", "error", "approval", "question", "idle"].map(k => [k, { enabled: true, volume: 70, source: "default" }])) as PetPreferences["sounds"], +}; + +function shell(extra: Record) => unknown> = {}) { + let settings: PetSettings = { preferences: structuredClone(defaults), defaults, enabled: true }; + const listeners: Record void> = {}; + const invoke = vi.fn(async (cmd: string, args?: Record) => { + if (extra[cmd]) return extra[cmd](args); + if (cmd === "pet_preferences") return settings; + if (cmd === "sound_enabled") return true; + if (cmd === "pet_wallet") return { status: "signed_out", credits: null, remaining: null, total: null }; + if (cmd === "pet_set_preferences") { + const patch = args?.patch as Partial; + const sounds = { ...settings.preferences.sounds }; + for (const [key, value] of Object.entries(patch.sounds || {})) sounds[key as keyof typeof sounds] = { ...sounds[key as keyof typeof sounds], ...value }; + settings = { ...settings, preferences: { ...settings.preferences, ...patch, sounds } }; + return settings.preferences; + } + return null; + }); + Object.assign(window, { __TAURI__: { core: { invoke }, event: { + listen: async (name: string, cb: (e: { payload: unknown }) => void) => { + listeners[name] = cb; + return () => { delete listeners[name]; }; + }, + } } }); + return { invoke, emit: (name: string, payload?: unknown) => act(() => { listeners[name]?.({ payload }); }) }; +} + +beforeEach(() => setLocale("zh-CN")); +afterEach(() => { Reflect.deleteProperty(window, "__TAURI__"); }); + +it("额度未知显示占位,滑块松手才持久化,菜单变更同步到设置页", async () => { + const host = shell(); render(); + const scale = await screen.findByRole("slider", { name: "大小" }); + expect(screen.getByText("登录 MonkeyCode 账号后可查看")).toBeDefined(); + fireEvent.pointerDown(scale); fireEvent.change(scale, { target: { value: "125" } }); + expect(host.invoke.mock.calls.some(([cmd]) => cmd === "pet_set_preferences")).toBe(false); + fireEvent.pointerUp(scale); + await waitFor(() => expect(host.invoke).toHaveBeenCalledWith("pet_set_preferences", { patch: { scale: 125 } })); + host.emit("pet-preferences", { preferences: { ...defaults, scale: 80, snap: false }, defaults, enabled: false }); + expect((screen.getByRole("slider", { name: "大小" }) as HTMLInputElement).value).toBe("80"); + expect((screen.getByRole("checkbox", { name: "显示萌宠" }) as HTMLInputElement).checked).toBe(false); +}); + +it("保存失败保留原值并显示错误,非法额度阈值不发给壳", async () => { + const host = shell({ pet_set_preferences: () => Promise.reject(new Error("磁盘不可写")) }); + render(); + await userEvent.click(await screen.findByRole("checkbox", { name: "边缘吸附" })); + expect(await screen.findByRole("alert")).toHaveProperty("textContent", "Error: 磁盘不可写"); + expect((screen.getByRole("checkbox", { name: "边缘吸附" }) as HTMLInputElement).checked).toBe(true); + host.invoke.mockClear(); + const threshold = screen.getByRole("spinbutton", { name: "额度紧急阈值(剩余 %)" }); + fireEvent.change(threshold, { target: { value: "30" } }); fireEvent.blur(threshold); + expect(host.invoke).not.toHaveBeenCalled(); + expect((threshold as HTMLInputElement).value).toBe("10"); +}); + +it("账号切换后立即清空旧余额,过期查询不能覆盖新账号", async () => { + let finishOld: (v: unknown) => void = () => {}; + let count = 0; + const host = shell({ pet_wallet: () => ++count === 1 ? new Promise(resolve => { finishOld = resolve; }) + : { status: "ready", credits: 22, remaining: 500, total: 1000 } }); + render(); await screen.findByRole("slider", { name: "大小" }); + host.emit("pet-wallet-invalidated"); + expect(await screen.findByText("22")).toBeDefined(); + await act(async () => finishOld({ status: "ready", credits: 999, remaining: 1000, total: 1000 })); + expect(screen.queryByText("999")).toBeNull(); + expect(screen.getByText("22")).toBeDefined(); +}); + +it("跨午夜勿扰、恢复默认和试听通过独立命令立即生效", async () => { + const host = shell(); render(); + await userEvent.click(await screen.findByRole("checkbox", { name: "定时勿扰" })); + expect(await screen.findByLabelText("勿扰开始时间")).toHaveProperty("value", "22:00"); + expect(screen.getByLabelText("勿扰结束时间")).toHaveProperty("value", "08:00"); + await userEvent.click(screen.getByRole("button", { name: "试听等待审批" })); + expect(host.invoke).toHaveBeenCalledWith("pet_preview_sound", { sound: "approval" }); + await userEvent.click(screen.getByRole("button", { name: "恢复默认萌宠设置" })); + expect(host.invoke).toHaveBeenCalledWith("pet_set_preferences", { patch: defaults }); + await waitFor(() => expect(screen.queryByLabelText("勿扰开始时间")).toBeNull()); +}); + +it("导入成功后可给事件选择自定义声音,失败不留下虚假的音效选项", async () => { + const assets: {id: string; name: string}[] = []; + let fail = false; + const host = shell({ pet_sound_list: () => [...assets], pet_sound_import: args => { + if (fail) throw new Error("音效无法保存"); + const asset = { id: "a".repeat(64), name: String(args?.name) }; assets.push(asset); return asset; + } }); + render(); await screen.findByRole("slider", { name: "大小" }); + fireEvent.change(screen.getByLabelText("导入音效", { selector: "input" }), { target: { files: [new File(["audio"], "小铃声.wav", { type: "audio/wav" })] } }); + await waitFor(() => expect(screen.getByLabelText("摸摸:按下音效选择").textContent).toContain("小铃声.wav")); + await userEvent.selectOptions(screen.getByLabelText("摸摸:按下音效选择"), `custom:${"a".repeat(64)}`); + expect(host.invoke).toHaveBeenCalledWith("pet_set_preferences", { patch: { sounds: { press: { source: `custom:${"a".repeat(64)}` } } } }); + fail = true; + fireEvent.change(screen.getByLabelText("导入音效", { selector: "input" }), { target: { files: [new File(["audio"], "失败.wav")] } }); + expect(await screen.findByRole("alert")).toHaveProperty("textContent", "Error: 音效无法保存"); + expect(screen.getByLabelText("摸摸:按下音效选择").textContent).not.toContain("失败.wav"); +}); diff --git a/desktop/ui-next/src/features/settings/PetSection.tsx b/desktop/ui-next/src/features/settings/PetSection.tsx new file mode 100644 index 000000000..cfe18d4b5 --- /dev/null +++ b/desktop/ui-next/src/features/settings/PetSection.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { preparePetAudio } from "@/lib/pet-audio-import"; +import { useI18n } from "@/lib/i18n"; +import { getSoundEnabled, onSoundEnabled, setSoundEnabled } from "@/lib/ipc/config"; +import { inDesktopShell } from "@/lib/ipc/ipc"; +import { + activatePet, getPetPreferences, getPetWallet, onPetPreferences, onPetWalletInvalidated, previewPetSound, + resetPetPosition, setPetEnabled, setPetPreferences, listPetSounds, importPetSound, removePetSound, onPetAudioError, + type PetPreferencesPatch, type PetSettings, type PetSound, type PetWallet, type PetAudioAsset, +} from "@/lib/ipc/pet"; + +function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return
+
{label} + {hint && {hint}}
+ {children} +
; +} + +// 拖动期间只更新读数,松手/键盘完成后提交一次,避免音量拖动写几十次配置。 +function Slider({ label, value, min = 0, max = 100, step = 1, disabled, onCommit }: { + label: string; value: number; min?: number; max?: number; step?: number; disabled: boolean; onCommit: (v: number) => void; +}) { + const [draft, setDraft] = useState(value); + const editing = useRef(false); + useEffect(() => { if (!disabled && !editing.current) setDraft(value); }, [value, disabled]); + const commit = () => { + if (!editing.current) return; + editing.current = false; + if (draft !== value) onCommit(draft); + }; + return
+ { editing.current = true; }} + onChange={e => { editing.current = true; setDraft(Number(e.target.value)); }} + onPointerUp={commit} onKeyUp={commit} onBlur={commit} /> + {draft}% +
; +} + +const clockText = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; +const clockValue = (text: string) => { const [h, m] = text.split(":").map(Number); return (h ?? 0) * 60 + (m ?? 0); }; + +function Toggle({ label, checked, disabled, onChange }: { + label: string; checked: boolean; disabled: boolean; onChange: (v: boolean) => void; +}) { + return onChange(e.target.checked)} />; +} + +function NumberInput({ label, value, min, max, disabled, onChange }: { + label: string; value: number; min: number; max: number; disabled: boolean; onChange: (v: number) => void; +}) { + return { + const next = Number(e.target.value); + if (!e.target.value || !Number.isInteger(next) || next < min || next > max) { e.target.value = String(value); return; } + if (next !== value) onChange(next); + }} onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }} />; +} + +export function PetSection() { + const { t } = useI18n(); + const [settings, setSettings] = useState(null); + const [wallet, setWallet] = useState(null); + const [sound, setSound] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [assets, setAssets] = useState([]); + const importInput = useRef(null); + const [now, setNow] = useState(Date.now); + const mounted = useRef(true); + const walletRevision = useRef(0); + useEffect(() => { + mounted.current = true; + if (!inDesktopShell()) return; + let alive = true; + let revision = 0; + const off = onPetPreferences(v => { revision++; if (mounted.current) setSettings(v); }); + const offSound = onSoundEnabled(v => { if (mounted.current) setSound(v); }); + const offAudio = onPetAudioError(message => { if (alive) setError(message); }); + void listPetSounds().then(value => { if (alive) setAssets(value || []); }).catch(e => { if (alive) setError(String(e)); }); + const version = revision; + void getPetPreferences().then(v => { if (alive && revision === version) setSettings(v); }) + .catch(e => { if (alive) setError(String(e)); }); + const refreshWallet = () => { + const version = ++walletRevision.current; + setWallet(null); + void getPetWallet().then(v => { if (alive && version === walletRevision.current) setWallet(v); }).catch(() => {}); + }; + const offWallet = onPetWalletInvalidated(refreshWallet); + refreshWallet(); + void getSoundEnabled().then(v => { if (alive) setSound(v); }).catch(() => {}); + const timer = window.setInterval(() => setNow(Date.now()), 15000); + return () => { alive = false; mounted.current = false; off(); offSound(); offWallet(); offAudio(); window.clearInterval(timer); }; + }, []); + const run = async (operation: () => Promise) => { + if (busy) return; + setBusy(true); setError(""); + try { await operation(); } catch (e) { if (mounted.current) setError(String(e)); } + finally { if (mounted.current) setBusy(false); } + }; + const save = (patch: PetPreferencesPatch) => void run(async () => { + const preferences = await setPetPreferences(patch); + if (mounted.current) setSettings(old => old ? { ...old, preferences } : old); + }); + if (!inDesktopShell()) return

{t("settings.browserReadonly")}

; + if (!settings) return
{error || t("pet.loading")}
; + const p = settings.preferences; + const fmt = (v: number | null | undefined) => v == null ? "—" : v.toLocaleString(undefined, { maximumFractionDigits: 2 }); + return
+

{t("pet.hint")}

+ {error &&
{error}
} +
+ void run(async () => { + await setPetEnabled(enabled); if (mounted.current) setSettings(old => old ? { ...old, enabled } : old); + })} /> + save({ scale })} /> + save({ snap })} /> + + + save({ bubble_seconds })} /> + +
+ +
+ + + +
+ {t("pet.credits")} {fmt(wallet?.credits)} + {t("pet.usedToday")} {fmt(wallet?.usage_day === Math.floor((now / 1000 + 28800) / 86400) ? wallet.used_today : null)} + {t("pet.remaining")} {fmt(wallet?.remaining)} / {fmt(wallet?.total)} + {wallet?.updated_at ? {t("pet.updated", { time: new Date(wallet.updated_at).toLocaleTimeString() })} : null} + +
+ save({ quota_alerts })} /> + {p.quota_alerts && <> + save({ quota_warning })} /> + save({ quota_critical })} /> + save({ credits_warning })} /> + } +
+ +
+ void run(async () => { + await setSoundEnabled(enabled); if (mounted.current) setSound(enabled); + })} /> + + { const file = e.target.files?.[0]; e.target.value = ""; if (file) void run(async () => { + const data = await preparePetAudio(file); await importPetSound(file.name, data); + const next = await listPetSounds(); if (mounted.current) setAssets(next); + }); }} /> + + + {assets.length > 0 &&
{t("pet.importedSounds", { count: assets.length })} +
{assets.map(asset =>
+ {asset.name} +
)}
} + {(["press", "release", "start", "end", "error", "approval", "question", "idle"] as PetSound[]).map(key => +
+ save({ sounds: { [key]: { enabled } } })} /> + + save({ sounds: { [key]: { volume } } })} /> + +
+
)} + save({ quiet_enabled })} /> + {p.quiet_enabled && +
{ if (e.target.value) save({ quiet_start: clockValue(e.target.value) }); }} /> + – { if (e.target.value) save({ quiet_end: clockValue(e.target.value) }); }} />
+
} + now ? t("pet.mutedUntil", { time: new Date(p.muted_until).toLocaleTimeString() }) : t("pet.temporaryQuiet")}> + + +
+
+
; +} diff --git a/desktop/ui-next/src/features/settings/SettingsView.tsx b/desktop/ui-next/src/features/settings/SettingsView.tsx index 957ed5af3..3c6fa7bac 100644 --- a/desktop/ui-next/src/features/settings/SettingsView.tsx +++ b/desktop/ui-next/src/features/settings/SettingsView.tsx @@ -6,7 +6,7 @@ // sound-enabled 事件与托盘/桌宠双向同步); // - models/mcp/kernel_env 走保存条:save_config 全量写回(表单外字段从载入 // 配置透传),壳保存后重启引擎——重启过程由全局引擎横幅外显,这里不管。 -import { IconAdjustmentsHorizontal, IconAlertTriangle, IconDice5, IconPhoto, IconRotate, IconTrash, IconWand, IconBrain, IconCheck, IconChevronDown, IconInfoCircle, IconServer, IconSparkles, IconTerminal2, IconUser, IconWorld, type TablerIcon } from "@tabler/icons-react"; +import { IconPaw, IconAdjustmentsHorizontal, IconAlertTriangle, IconDice5, IconPhoto, IconRotate, IconTrash, IconWand, IconBrain, IconCheck, IconChevronDown, IconInfoCircle, IconServer, IconSparkles, IconTerminal2, IconUser, IconWorld, type TablerIcon } from "@tabler/icons-react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react"; import { resolveShortcut } from "@/app/shortcuts"; @@ -59,6 +59,7 @@ import { useMcTransport } from "@/lib/mcTransport"; import { baizhiStatus } from "@/lib/ipc/account"; import { AccountSection, type SyncApplied } from "@/features/account/AccountSection"; import { engineCaps } from "@/lib/ipc/approvals"; +import { PetSection } from "./PetSection"; import { AboutSection } from "./AboutSection"; import { BrowserSection } from "./BrowserSection"; import { McpSection } from "./McpSection"; @@ -78,7 +79,7 @@ import { type SettingsDraft, } from "./settingsForm"; -export type SettingsSection = "general" | "account" | "models" | "mcp" | "skills" | "browser" | "env" | "about"; +export type SettingsSection = "pet" | "general" | "account" | "models" | "mcp" | "skills" | "browser" | "env" | "about"; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -849,6 +850,7 @@ function EnvSection({ export interface SettingsViewHandle { /** 所有外部跳转都经这里请求关闭,避免绕过未保存修改确认。 */ requestClose(afterApproved?: () => void): void; + openSection(section: SettingsSection): void; } const FOCUSABLE = @@ -959,7 +961,7 @@ export const SettingsView = forwardRef ({ requestClose })); + useImperativeHandle(ref, () => ({ requestClose, openSection: setSection })); const trapTab = (e: ReactKeyboardEvent) => { if (e.key !== "Tab") return; @@ -1158,6 +1160,7 @@ export const SettingsView = forwardRef = [ { id: "general", label: t("settings.nav.general"), desc: t("settings.desc.general"), icon: IconAdjustmentsHorizontal }, + ...(inDesktopShell() ? [{ id: "pet" as const, label: t("settings.nav.pet"), desc: t("settings.desc.pet"), icon: IconPaw }] : []), { id: "account", label: t("settings.nav.account"), desc: t("settings.desc.account"), icon: IconUser }, { id: "models", label: t("settings.nav.models"), desc: t("settings.desc.models"), icon: IconBrain }, { id: "mcp", label: t("settings.nav.mcp"), desc: t("settings.desc.mcp"), icon: IconServer }, @@ -1185,6 +1188,8 @@ export const SettingsView = forwardRef { switch (section) { + case "pet": + return ; case "general": return ; case "account": diff --git a/desktop/ui-next/src/lib/i18n/en.ts b/desktop/ui-next/src/lib/i18n/en.ts index ebbe227b7..aa143349b 100644 --- a/desktop/ui-next/src/lib/i18n/en.ts +++ b/desktop/ui-next/src/lib/i18n/en.ts @@ -1282,4 +1282,76 @@ export const en: Record = { "ctx.copyImage": "Copy image", "ctx.copyImageUnsupported": "Copying images is not supported in this environment", + "settings.nav.pet": "Desktop pet", + "settings.desc.pet": "Task alerts, quota, position and sounds", + "pet.loading": "Loading pet settings…", + "pet.restore": "Restore default pet settings", + "pet.clickBubble": "Click bubble", + "pet.clickBubble.credits": "Credits and today’s usage", + "pet.clickBubble.greeting": "Petting greetings", + "pet.clickBubble.none": "Off", + "pet.bubbleSeconds": "Click bubble duration (seconds)", + "pet.bubbleSecondsHint": "0 keeps it visible; tasks and credits continue updating", + "pet.usedToday": "Credits used today", + "pet.usedHint": "Model, host and tool credits spent today (Asia/Shanghai). Excludes free tokens, top-ups, subscriptions and fines. — means unavailable.", + "pet.audioLibrary": "Sound library", + "pet.importHint": "MP3 / WAV / M4A and other supported audio, up to 30 s and 10 MB. Select imported sounds per event; unassign before removing.", + "pet.importSound": "Import sound", + "pet.importedSounds": "{count} imported sounds", + "pet.removeSound": "Remove", + "pet.sourceLabel": "Sound for {event}", + "pet.source.default": "Event default", + "pet.source.soft": "Soft pop", + "pet.source.toy": "Toy squeak", + "pet.source.bell": "Bell", + "pet.sourceMissing": "Sound missing; choose another", + "pet.hint": "Click the monkey to pet it with sound, double-click to open a task, or right-click for the menu. Click a task bubble to view its task.", + "pet.visible": "Show desktop pet", + "pet.visibleHint": "Task sounds are controlled separately, even when the pet is hidden", + "pet.scale": "Size", + "pet.snap": "Snap to screen edges", + "pet.bubble": "Default bubble", + "pet.bubble.tasks": "Task status", + "pet.bubble.quota": "Remaining daily quota", + "pet.bubble.credits": "Credits", + "pet.bubble.none": "Event alerts only", + "pet.position": "Desktop position", + "pet.resetPosition": "Reset position", + "pet.account": "Account quota", + "pet.signIn": "Sign in to a MonkeyCode account to view quota", + "pet.stale": "Latest data is unavailable. Any displayed values are cached.", + "pet.accountHint": "Account totals include other clients; they are not local task costs", + "pet.refresh": "Refresh", + "pet.credits": "Credits", + "pet.remaining": "Today: remaining / total", + "pet.updated": "Updated at {time}", + "pet.openAccount": "Account benefits", + "pet.alerts": "Quota and credit alerts", + "pet.warning": "Quota warning (remaining %)", + "pet.critical": "Critical quota (remaining %)", + "pet.creditWarning": "Credit warning threshold", + "pet.creditWarningHint": "0 disables credit alerts. Each threshold is reported once per day.", + "pet.soundMaster": "All notification sounds", + "pet.sound.press": "Petting: press", + "pet.sound.release": "Petting: release", + "pet.sound.start": "App start", + "pet.sound.end": "Task reply and completion", + "pet.sound.error": "Task error and interruption", + "pet.sound.approval": "Approval needed", + "pet.sound.question": "Answer or selection needed", + "pet.sound.idle": "Idle reminder", + "pet.volume": "{event} volume", + "pet.preview": "Preview", + "pet.previewLabel": "Preview {event}", + "pet.quiet": "Scheduled quiet hours", + "pet.quietHint": "Mute sounds and suppress pop-ups; pending tasks remain accessible", + "pet.quietTime": "Quiet hours", + "pet.quietTimeHint": "Uses local time. Identical start and end times mean all day.", + "pet.quietStart": "Quiet hours start", + "pet.quietEnd": "Quiet hours end", + "pet.mutedUntil": "Quiet until {time}", + "pet.temporaryQuiet": "Temporary quiet mode", + "pet.unmute": "End temporary quiet mode", + "pet.muteHour": "Quiet for 1 hour", + }; diff --git a/desktop/ui-next/src/lib/i18n/zh.ts b/desktop/ui-next/src/lib/i18n/zh.ts index e9c0eddbf..50a56b971 100644 --- a/desktop/ui-next/src/lib/i18n/zh.ts +++ b/desktop/ui-next/src/lib/i18n/zh.ts @@ -1308,6 +1308,78 @@ export const zh = { "ctx.copyImage": "复制图片", "ctx.copyImageUnsupported": "当前环境不支持复制图片", + "settings.nav.pet": "萌宠", + "settings.desc.pet": "任务提醒、额度、摆放与声音", + "pet.loading": "正在读取萌宠设置…", + "pet.restore": "恢复默认萌宠设置", + "pet.clickBubble": "点击气泡", + "pet.clickBubble.credits": "积分余额与今日已用", + "pet.clickBubble.greeting": "摸摸回应", + "pet.clickBubble.none": "关闭", + "pet.bubbleSeconds": "点击气泡时长(秒)", + "pet.bubbleSecondsHint": "0 表示保持显示;仍会更新任务与积分信息", + "pet.usedToday": "今日已用积分", + "pet.usedHint": "北京时间当天的模型、主机和工具消费,仅统计积分余额扣减;不含免费 Token、充值、订阅和罚款。流水不可用时显示 —。", + "pet.audioLibrary": "音效库", + "pet.importHint": "MP3 / WAV / M4A 等,最长 30 秒、最大 10 MB。导入后可在各事件中选择;移除前需先取消引用。", + "pet.importSound": "导入音效", + "pet.importedSounds": "已导入 {count} 个音效", + "pet.removeSound": "移除", + "pet.sourceLabel": "{event}音效选择", + "pet.source.default": "事件默认音效", + "pet.source.soft": "轻柔弹音", + "pet.source.toy": "玩具吱吱", + "pet.source.bell": "清脆铃音", + "pet.sourceMissing": "音效文件缺失,请重选", + "pet.hint": "单击猴子显示积分气泡并播放轻柔音效,双击回到任务,右键打开菜单。下方可切换气泡内容和音效。", + "pet.visible": "显示萌宠", + "pet.visibleHint": "隐藏萌宠后,任务提示音仍由下方开关控制", + "pet.scale": "大小", + "pet.snap": "边缘吸附", + "pet.bubble": "日常气泡", + "pet.bubble.tasks": "任务状态", + "pet.bubble.quota": "今日剩余额度", + "pet.bubble.credits": "积分余额", + "pet.bubble.none": "仅事件提醒", + "pet.position": "桌面位置", + "pet.resetPosition": "重置位置", + "pet.account": "账号额度", + "pet.signIn": "登录 MonkeyCode 账号后可查看", + "pet.stale": "暂时无法获取最新数据;有数值时为缓存", + "pet.accountHint": "账号数据包含其他客户端的使用,不是本机费用", + "pet.refresh": "刷新", + "pet.credits": "积分余额", + "pet.remaining": "今日剩余 / 总额度", + "pet.updated": "更新于 {time}", + "pet.openAccount": "查看账号权益", + "pet.alerts": "额度与积分提醒", + "pet.warning": "额度提醒阈值(剩余 %)", + "pet.critical": "额度紧急阈值(剩余 %)", + "pet.creditWarning": "积分提醒阈值", + "pet.creditWarningHint": "0 表示关闭积分提醒;每个阈值每天提醒一次", + "pet.soundMaster": "提示音总开关", + "pet.sound.press": "摸摸:按下", + "pet.sound.release": "摸摸:松开", + "pet.sound.start": "应用启动", + "pet.sound.end": "任务回复与完成", + "pet.sound.error": "任务错误与中断", + "pet.sound.approval": "等待审批", + "pet.sound.question": "等待回答与选择", + "pet.sound.idle": "持续空闲提醒", + "pet.volume": "{event}音量", + "pet.preview": "试听", + "pet.previewLabel": "试听{event}", + "pet.quiet": "定时勿扰", + "pet.quietHint": "静音并暂停主动提示,待处理任务仍可查看", + "pet.quietTime": "勿扰时段", + "pet.quietTimeHint": "按电脑本地时间;起止相同表示全天", + "pet.quietStart": "勿扰开始时间", + "pet.quietEnd": "勿扰结束时间", + "pet.mutedUntil": "临时勿扰至 {time}", + "pet.temporaryQuiet": "临时勿扰", + "pet.unmute": "结束临时勿扰", + "pet.muteHour": "勿扰 1 小时", + } as const; export type MessageKey = keyof typeof zh; diff --git a/desktop/ui-next/src/lib/ipc/pet.ts b/desktop/ui-next/src/lib/ipc/pet.ts new file mode 100644 index 000000000..61f85de3a --- /dev/null +++ b/desktop/ui-next/src/lib/ipc/pet.ts @@ -0,0 +1,55 @@ +import { invoke, listen } from "./ipc"; + +export type PetSound = "press" | "release" | "start" | "end" | "error" | "approval" | "question" | "idle"; +export interface PetPreferences { + scale: number; + snap: boolean; + bubble: "tasks" | "quota" | "credits" | "none"; + click_bubble: "credits" | "greeting" | "none"; + bubble_seconds: number; + quota_alerts: boolean; + quota_warning: number; + quota_critical: number; + credits_warning: number; + quiet_enabled: boolean; + quiet_start: number; + quiet_end: number; + muted_until: number; + sounds: Record; +} +export type PetPreferencesPatch = Partial> & { + sounds?: Partial>; +}; +export interface PetSettings { preferences: PetPreferences; defaults: PetPreferences; enabled: boolean } +export interface PetWallet { + scope: string; + credits: number | null; + used_today: number | null; + usage_day: number; + remaining: number | null; + total: number | null; + updated_at: number; + stale: boolean; + status: "ready" | "signed_out" | "unavailable" | "changed"; +} +export const getPetPreferences = () => invoke("pet_preferences"); +export const setPetPreferences = (patch: PetPreferencesPatch) => invoke("pet_set_preferences", { patch }); +export const setPetEnabled = (enabled: boolean) => invoke("pet_set_enabled", { enabled }); +export const getPetWallet = (force = false) => invoke("pet_wallet", { force }); +export const resetPetPosition = () => invoke("pet_reset_position"); +export const activatePet = (action: string) => invoke("pet_activate", { action }); +export const previewPetSound = (sound: PetSound) => invoke("pet_preview_sound", { sound }); +export const onPetPreferences = (cb: (v: PetSettings) => void) => listen("pet-preferences", cb); + +export const notifyPetAccountChanged = () => invoke("pet_account_changed"); +export const onPetWalletInvalidated = (cb: () => void) => { + const offAccount = listen("pet-wallet-invalidated", cb); + const offTransport = listen("monkeycode-transport-changed", cb); + return () => { offAccount(); offTransport(); }; +}; + +export interface PetAudioAsset { id: string; name: string } +export const listPetSounds = () => invoke("pet_sound_list"); +export const importPetSound = (name: string, data: string) => invoke("pet_sound_import", { name, data }); +export const removePetSound = (id: string) => invoke("pet_sound_remove", { id }); +export const onPetAudioError = (cb: (message: string) => void) => listen("pet-audio-error", cb); diff --git a/desktop/ui-next/src/lib/pet-audio-import.test.ts b/desktop/ui-next/src/lib/pet-audio-import.test.ts new file mode 100644 index 000000000..e6eb38186 --- /dev/null +++ b/desktop/ui-next/src/lib/pet-audio-import.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { preparePetAudio } from './pet-audio-import'; +afterEach(() => vi.unstubAllGlobals()); +it('导入解码为可移植 PCM16,保留音频而不保存用户原路径', async () => { + const close = vi.fn(async () => {}); + vi.stubGlobal('AudioContext', class { + close = close; + decodeAudioData = async () => ({ duration: 2 / 48000, numberOfChannels: 1, length: 2, sampleRate: 48000, getChannelData: () => new Float32Array([-1, 1]) }); + }); + const data = await preparePetAudio({ size: 4, arrayBuffer: async () => new ArrayBuffer(4) } as File); + const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); const view = new DataView(bytes.buffer); + expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe('RIFF'); + expect(view.getUint32(40, true)).toBe(4); expect(view.getInt16(44, true)).toBe(-32768); expect(view.getInt16(46, true)).toBe(32767); + expect(close).toHaveBeenCalledOnce(); +}); +it('拒绝超长、超大、损坏音频并关闭解码上下文', async () => { + const close = vi.fn(async () => {}); + vi.stubGlobal('AudioContext', class { close = close; decodeAudioData = async () => ({ duration: 31 }); }); + await expect(preparePetAudio({size: 4, arrayBuffer: async () => new ArrayBuffer(4)} as File)).rejects.toThrow('30'); + expect(close).toHaveBeenCalledOnce(); + await expect(preparePetAudio({size: 11 * 1024 * 1024} as File)).rejects.toThrow('10 MB'); + vi.stubGlobal('AudioContext', class { close = close; decodeAudioData = async () => { throw new Error('损坏音频'); }; }); + await expect(preparePetAudio({size: 4, arrayBuffer: async () => new ArrayBuffer(4)} as File)).rejects.toThrow('损坏'); + expect(close).toHaveBeenCalledTimes(2); +}); diff --git a/desktop/ui-next/src/lib/pet-audio-import.ts b/desktop/ui-next/src/lib/pet-audio-import.ts new file mode 100644 index 000000000..9539327c8 --- /dev/null +++ b/desktop/ui-next/src/lib/pet-audio-import.ts @@ -0,0 +1,26 @@ +/** Decode once on import, then persist a bounded portable PCM WAV for all desktop backends. */ +export async function preparePetAudio(file: File): Promise { + if (!file.size || file.size > 10 * 1024 * 1024) throw new Error('请选择 10 MB 以内的音频 / Choose audio under 10 MB'); + const context = new AudioContext({ sampleRate: 48000 }); + try { + const buffer = await context.decodeAudioData(await file.arrayBuffer()); + if (!Number.isFinite(buffer.duration) || buffer.duration <= 0 || buffer.duration > 30) { + throw new Error('音效需在 30 秒以内,请先裁剪 / Trim audio to 30 seconds or less'); + } + const channels = Math.min(buffer.numberOfChannels, 2), frames = buffer.length; + const bytes = new ArrayBuffer(44 + frames * channels * 2), view = new DataView(bytes); + const text = (offset: number, value: string) => { for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i)); }; + text(0, 'RIFF'); view.setUint32(4, bytes.byteLength - 8, true); text(8, 'WAVEfmt '); + view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true); + view.setUint32(24, buffer.sampleRate, true); view.setUint32(28, buffer.sampleRate * channels * 2, true); + view.setUint16(32, channels * 2, true); view.setUint16(34, 16, true); text(36, 'data'); view.setUint32(40, bytes.byteLength - 44, true); + const samples = Array.from({ length: channels }, (_, channel) => buffer.getChannelData(channel)); + for (let frame = 0; frame < frames; frame++) for (let channel = 0; channel < channels; channel++) { + const sample = Math.max(-1, Math.min(1, (samples[channel]?.[frame] ?? 0))); + view.setInt16(44 + (frame * channels + channel) * 2, Math.round(sample * (sample < 0 ? 32768 : 32767)), true); + } + const array = new Uint8Array(bytes); const parts = []; + for (let i = 0; i < array.length; i += 8192) parts.push(String.fromCharCode(...array.subarray(i, i + 8192))); + return btoa(parts.join('')); + } finally { await context.close(); } +} diff --git a/desktop/ui-next/tests/pet-audio.test.mjs b/desktop/ui-next/tests/pet-audio.test.mjs new file mode 100644 index 000000000..f7616f3e3 --- /dev/null +++ b/desktop/ui-next/tests/pet-audio.test.mjs @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PetAudio, soundPath } from '../public/pet-audio.mjs'; +function context() { + const sources = []; + const param = () => ({ setValueAtTime: vi.fn(), linearRampToValueAtTime: vi.fn(), cancelScheduledValues: vi.fn() }); + return { sources, state: 'running', currentTime: 0, destination: {}, resume: vi.fn(async () => {}), + decodeAudioData: vi.fn(async () => ({ duration: .12 })), + createGain: () => ({ gain: param(), connect: vi.fn(), disconnect: vi.fn() }), + createBufferSource: () => { const s = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn() }; sources.push(s); return s; } }; +} +const setting = { source: 'soft', volume: 35 }; +describe('decoded sound playback', () => { + it('rapid taps reuse decoded buffers and retire the previous touch voice without seeking', async () => { + const ctx = context(), fetchAudio = vi.fn(async () => new ArrayBuffer(4)); + const player = new PetAudio({ context: ctx, fetchAudio }); + for (let i = 0; i < 60; i++) await player.play(i % 2 ? 'release' : 'press', setting); + expect(fetchAudio).toHaveBeenCalledTimes(2); expect(ctx.decodeAudioData).toHaveBeenCalledTimes(2); + expect(player.voices.size).toBe(1); + expect(ctx.sources.slice(0, -1).every(s => s.stop.mock.calls.length === 2)).toBe(true); + for (const source of ctx.sources.slice(0,-1)) source.onended(); + expect(player.voices.size).toBe(1); // old ended callback must not clear the newer voice + }); + it('a delayed decode cannot queue obsolete clicks or play after mute', async () => { + const ctx = context(); let decode; + ctx.decodeAudioData = vi.fn(() => new Promise(resolve => { decode = resolve; })); + const player = new PetAudio({ context: ctx, fetchAudio: async () => new ArrayBuffer(4) }); + const taps = Array.from({ length: 40 }, () => player.play('press', setting)); + await Promise.resolve(); await Promise.resolve(); + decode({ duration: .1 }); await Promise.all(taps); + expect(ctx.sources).toHaveLength(1); + const late = player.play('release', setting); await Promise.resolve(); await Promise.resolve(); + player.stop(); decode({ duration: .1 }); await late; + expect(ctx.sources).toHaveLength(1); + }); + it('custom audio loads through the controlled command; failures can be retried', async () => { + const ctx = context(), readCustom = vi.fn().mockRejectedValueOnce(new Error('missing')).mockResolvedValue('AAAA'); + const report = vi.fn(), player = new PetAudio({ context: ctx, readCustom, report }); + const custom = { ...setting, source: `custom:${'a'.repeat(64)}` }; + await player.play('press', custom); expect(report).toHaveBeenCalledOnce(); + await player.play('press', custom); expect(ctx.sources).toHaveLength(1); + expect(readCustom).toHaveBeenCalledTimes(2); expect(soundPath('press')).toContain('soft-press'); + }); +}); diff --git a/desktop/ui-next/tests/pet-core.test.mjs b/desktop/ui-next/tests/pet-core.test.mjs new file mode 100644 index 000000000..5d0158870 --- /dev/null +++ b/desktop/ui-next/tests/pet-core.test.mjs @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { PetModel, DEFAULTS, isQuiet, walletText } from '../public/pet-core.mjs'; + +const session = (id, status = 'running') => ({ id, title: `任务 ${id}`, status, waiting_ask: false }); +const finish = id => ({ type: 'session-status', id, title: `任务 ${id}`, status: 'idle' }); +const ask = (id, kind = 'approval', req = 'r1') => ({ type: 'session-ask', id, open: true, waiting_items: [{ id: req, kind }] }); +const wallet = patch => ({ scope: 'account-a', status: 'ready', stale: false, credits: 200, total: 1000, remaining: 500, updated_at: 1000, ...patch }); +const date = new Date(2026, 8, 28, 12).getTime(); + +describe('桌宠任务通知', () => { + it('并发结束不覆盖,且点击目标随正在展示的通知切换', () => { + const m = new PetModel(); m.snapshot([session('a'), session('b')]); + m.event(finish('a'), 1000); m.event(finish('b'), 1000); + expect(m.view(1000).action).toBe('session:a'); + expect(m.view(1000).tasks).toHaveLength(2); + expect(m.view(5001).action).toBe('session:b'); + expect(m.view(9002).text).toBe(''); + }); + it('提问优先于完成,处理后继续展示其他通知', () => { + const m = new PetModel(); m.snapshot([session('a'), session('b')]); + m.event(finish('a'), 1000); m.view(1000); + expect(m.event(ask('b', 'question'), 2000)).toEqual(['question']); + expect(m.view(2000)).toMatchObject({ text: '等待回答', action: 'session:b' }); + m.view(10000); + m.event({ type: 'session-ask', id: 'b', open: false, waiting_items: [] }, 12000); + expect(m.view(12000).action).toBe('session:a'); + }); + it('多个等待任务打开选择菜单;同任务并存的交互不会被错误清空', () => { + const m = new PetModel(); m.snapshot([session('a'), session('b')]); + m.event(ask('a')); m.event(ask('b', 'design')); + expect(m.view().action).toBe('menu'); + expect(m.view().text).toBe('2 个任务等你处理'); + m.event({ ...ask('a'), waiting_items: [{ id: 'q', kind: 'question' }, { id: 'p', kind: 'approval' }] }); + expect(m.view().tasks[0].label).toBe('等待回答 / 等待审批'); + }); + it('重复状态、重连快照和同一请求不会重复提示', () => { + const store = new Map(); const storage = { getItem: k => store.get(k), setItem: (k, v) => store.set(k, v) }; + const m = new PetModel(storage); m.snapshot([session('a')]); + expect(m.event(finish('a'))).toEqual(['end']); + expect(m.event(finish('a'))).toEqual([]); + expect(m.event(ask('a'))).toEqual(['approval']); + expect(m.event(ask('a'))).toEqual([]); + const restored = new PetModel(storage); + restored.snapshot([{ ...session('a'), waiting_ask: true, waiting_items: ask('a').waiting_items }]); + expect(restored.notices).toHaveLength(0); + expect(restored.event(ask('a'))).toEqual([]); + expect(restored.event(ask('a', 'question', 'r2'))).toEqual(['question']); + }); + it('旧快照不能覆盖途中发生的状态变化或复活已删除的任务', () => { + const m = new PetModel(); m.snapshot([session('a'), session('b')]); + const rev = m.revision; + m.event(ask('a')); m.event({ type: 'session-deleted', id: 'b' }); + m.snapshot([session('a'), session('b')], rev); + expect(m.view().action).toBe('session:a'); + expect(m.sessions.has('b')).toBe(false); + }); + it('新一轮开始会移除上一轮提示;任务归档后不再出现在队列', () => { + const m = new PetModel(); m.snapshot([session('a')]); m.event(finish('a')); + m.event({ type: 'session-status', id: 'a', status: 'running' }); + expect(m.notices).toHaveLength(0); + m.event(ask('a')); m.event({ type: 'session-status', id: 'a', archived: true }); + expect(m.view().tasks).toHaveLength(0); + }); + it('周期快照不会延后持续空闲计时,只在进入新一次空闲后重新计时', () => { + const m = new PetModel(); m.snapshot([]); m.view(0); + for (let now = 10000; now <= 600000; now += 10000) { m.snapshot([]); m.view(now); } + expect(m.takeIdleSound(600000)).toBe(true); + expect(m.takeIdleSound(600001)).toBe(false); + m.snapshot([session('a')]); m.view(600002); + m.event(finish('a'), 600003); m.view(600003); + expect(m.takeIdleSound(1200003)).toBe(true); + }); +}); + +describe('勿扰与额度口径', () => { + it('跨午夜、全天和临时勿扰正确计算', () => { + const p = { ...DEFAULTS, quiet_enabled: true }; + expect(isQuiet(p, new Date(2026, 8, 28, 23).getTime())).toBe(true); + expect(isQuiet(p, new Date(2026, 8, 28, 7).getTime())).toBe(true); + expect(isQuiet(p, date)).toBe(false); + expect(isQuiet({ ...p, quiet_start: 0, quiet_end: 0 }, date)).toBe(true); + expect(isQuiet({ ...DEFAULTS, muted_until: date + 1 }, date)).toBe(true); + }); + it('勿扰抑制完成弹出与动画,仍保留待处理任务', () => { + const m = new PetModel(); m.prefs.muted_until = date + 100000; + m.snapshot([session('a'), session('b')]); m.event(finish('a'), date); m.event(ask('b'), date); + expect(m.notices).toHaveLength(0); + expect(m.view(date)).toMatchObject({ state: 'idle', text: '等待审批', action: 'session:b' }); + }); + it('同一账号同一日同阈值只提醒一次,进程重载也不重复', () => { + const store = new Map(); const storage = { getItem: k => store.get(k), setItem: (k, v) => store.set(k, v) }; + const m = new PetModel(storage); m.snapshot([]); + m.updateWallet(wallet({ remaining: 190 }), date); + expect(m.view(date).text).toContain('19%'); + m.updateWallet(wallet({ remaining: 180 }), date + 9000); + expect(m.view(date + 9000).text).toBe(''); + const restored = new PetModel(storage); restored.snapshot([]); + restored.updateWallet(wallet({ remaining: 180 }), date + 10000); + expect(restored.view(date + 10000).text).toBe(''); + restored.updateWallet(wallet({ remaining: 90 }), date + 11000); + expect(restored.view(date + 11000).text).toContain('9%'); + restored.updateWallet(wallet({ remaining: 80 }), date + 86400000); + expect(restored.view(date + 86400000).text).toContain('8%'); + }); + it('存储不可写时内存仍去重;切账号独立提醒', () => { + const m = new PetModel(); m.snapshot([]); + m.updateWallet(wallet({ remaining: 80 }), date); m.view(date); + m.updateWallet(wallet({ remaining: 80 }), date + 10000); + expect(m.view(date + 10000).text).toBe(''); + m.updateWallet(wallet({ scope: 'account-b', remaining: 80 }), date + 11000); + expect(m.view(date + 11000).text).toContain('8%'); + }); + it('等待与错误优先,额度随后展示,完成通知不会丢失', () => { + const m = new PetModel(); m.snapshot([session('a'), session('b'), session('c')]); + m.event(finish('a'), date); + m.event({ type: 'session-status', id: 'b', status: 'error' }, date); + m.event(ask('c'), date); + m.updateWallet(wallet({ remaining: 90, credits: 80 }), date); + expect(m.view(date).action).toBe('session:c'); + m.event({ type: 'session-ask', id: 'c', open: false }, date + 20000); + expect(m.view(date + 20000).action).toBe('session:b'); + expect(m.view(date + 24001)).toMatchObject({ action: 'account', text: '今日免费额度剩余 9% · 积分剩余 80' }); + expect(m.view(date + 32002).action).toBe('session:a'); + }); + it('缓存、缺失值和零额度分别表达,不虚构零余额或人民币费用', () => { + const m = new PetModel(); m.snapshot([]); + m.updateWallet(wallet({ remaining: 0, stale: true }), date); + expect(m.alert).toBeNull(); + expect(walletText(wallet({ credits: null }), 'credits')).toBe('额度暂不可用'); + expect(walletText(wallet({ credits: 0 }), 'credits')).toBe('积分 0'); + expect(walletText(wallet({ total: 0 }), 'quota')).toBe('暂无每日免费额度'); + expect(walletText(wallet({ stale: true }), 'quota')).toContain('缓存'); + m.updateWallet({ status: 'signed_out' }, date); + expect(m.wallet.scope).toBeUndefined(); + }); +}); + +it('点击显示准确积分余额与今日消费,不以免费 tokens 代替;跨日与缺失显示未知', () => { + const m = new PetModel(); m.snapshot([]); m.prefs.quota_alerts = false; + const usage_day = Math.floor((date / 1000 + 28800) / 86400); + m.updateWallet(wallet({ credits: 12345.67, used_today: 12.5, usage_day }), date); + m.press(); m.release(date); + expect(m.view(date)).toMatchObject({ text: '积分余额 12,345.67\n今日已用 12.5', action: 'account' }); + expect(m.view(date + 6001).text).toBe(''); + m.prefs.bubble_seconds = 0; m.press(); m.release(date); + expect(m.view(date + 86400000).text).toContain('今日已用 —'); + m.event(ask('a'), date); + expect(m.view(date)).toMatchObject({ action: 'session:a' }); + expect(m.view(date).text).toContain('等待审批\n积分余额'); + m.updateWallet(wallet({ used_today: null, usage_day }), date); + expect(m.view(date).text).toContain('今日已用 —'); +}); diff --git a/desktop/ui-next/tests/pet-runtime.test.mjs b/desktop/ui-next/tests/pet-runtime.test.mjs new file mode 100644 index 000000000..cd0510806 --- /dev/null +++ b/desktop/ui-next/tests/pet-runtime.test.mjs @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { DEFAULTS } from '../public/pet-core.mjs'; +vi.mock('../public/pet-audio.mjs', () => ({ PetAudio: class { + preload(settings) { for (const key of Object.keys(settings)) if (!audio.some(a => a.key === key)) audio.push({ key, play: vi.fn(), pause: vi.fn(), volume: 0 }); } + play(key, setting) { const a = audio.find(a => a.key === key); a.volume = setting.volume / 100; a.play(); } + stop() { for (const a of audio) a.pause(); } +} })); + +let dom, audio, listeners, invoke, dragging, prefs, master, sessions; +beforeEach(() => { + vi.resetModules(); vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 8, 28, 12)); + dom = new JSDOM('
', { url: 'http://localhost/pet.html' }); + Object.defineProperty(dom.window.navigator, 'platform', { value: 'Win32' }); + vi.stubGlobal('window', dom.window); vi.stubGlobal('document', dom.window.document); vi.stubGlobal('navigator', dom.window.navigator); + audio = []; listeners = {}; prefs = globalThis.structuredClone(DEFAULTS); master = true; sessions = []; prefs.click_bubble = 'greeting'; prefs.bubble_seconds = 3; + dragging = vi.fn(async () => {}); + invoke = vi.fn(async cmd => { + if (cmd === 'pet_preferences') return { preferences: prefs }; + if (cmd === 'sound_enabled') return master; + if (cmd === 'sessions_list') return sessions; + if (cmd === 'pet_wallet') return { status: 'signed_out' }; + }); + dom.window.__TAURI__ = { core: { invoke }, event: { listen: async (name, cb) => { listeners[name] = cb; return () => {}; } }, + window: { getCurrentWindow: () => ({ startDragging: dragging }) } }; + dom.window.document.getElementById('sprite').getBoundingClientRect = () => ({ left: 14, top: 32, right: 102, bottom: 120, width: 88, height: 88 }); + dom.window.document.getElementById('bubble').getBoundingClientRect = () => ({ left: 2, top: 2, right: 114, bottom: 26, width: 112, height: 24 }); +}); +afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); dom.window.close(); vi.unstubAllGlobals(); }); +const boot = async () => { await import('../public/pet-runtime.mjs'); await vi.advanceTimersByTimeAsync(0); }; +const mouse = (type, extra = {}) => dom.window.document.dispatchEvent(new dom.window.MouseEvent(type, { + bubbles: true, button: 0, clientX: 58, clientY: 74, screenX: 58, screenY: 74, ...extra, +})); +const sound = name => audio.find(a => a.key === name); +const petting = () => dom.window.document.getElementById('bubble').textContent; + +describe('真实桌宠页面手势与音效接线', () => { + it('单击按下/松开分别发声与回弹,不唤起主窗口;下一次点击换回应', async () => { + await boot(); mouse('mousedown'); + expect(sound('press').play).toHaveBeenCalledOnce(); + expect(dom.window.document.getElementById('wrap').classList.contains('pressed')).toBe(true); + mouse('mouseup'); + expect(sound('release').play).toHaveBeenCalledOnce(); + expect(petting()).toBe('我在呢,摸摸~'); + expect(invoke.mock.calls.some(([cmd]) => cmd === 'pet_activate')).toBe(false); + await vi.advanceTimersByTimeAsync(600); + mouse('mousedown'); mouse('mouseup'); + expect(petting()).toBe('嘿嘿,有点痒!'); + await vi.advanceTimersByTimeAsync(3500); + expect(petting()).toBe(''); + }); + it('双击打开任务,点击任务气泡保持精确目标且不播放摸摸音', async () => { + sessions = [{ id: 'task-a', title: '测试任务', status: 'running', waiting_ask: true, waiting_items: [{id:'p1',kind:'approval'}] }]; + await boot(); mouse('mousedown'); mouse('mouseup', { detail: 2 }); + expect(invoke).toHaveBeenCalledWith('pet_activate', { action: 'session:task-a' }); + expect(petting()).toBe('等待审批'); + sound('press').play.mockClear(); sound('release').play.mockClear(); invoke.mockClear(); + mouse('mousedown', { clientY: 14 }); mouse('mouseup', { clientY: 14 }); + expect(invoke).toHaveBeenCalledWith('pet_activate', { action: 'session:task-a' }); + expect(sound('press').play).not.toHaveBeenCalled(); + expect(sound('release').play).not.toHaveBeenCalled(); + }); + it('拖动和丢失焦点取消按压,不误发松开音、点击泡泡或跳转', async () => { + await boot(); mouse('mousedown'); mouse('mousemove', { screenX: 90 }); mouse('mouseup'); + expect(dragging).toHaveBeenCalledOnce(); + expect(sound('release').play).not.toHaveBeenCalled(); + expect(petting()).toBe(''); + expect(dom.window.document.getElementById('wrap').classList.contains('pressed')).toBe(false); + mouse('mousedown'); dom.window.dispatchEvent(new dom.window.Event('blur')); mouse('mouseup'); + expect(sound('release').play).not.toHaveBeenCalled(); + expect(invoke.mock.calls.some(([cmd]) => cmd === 'pet_activate')).toBe(false); + }); + it.each(['master', 'quiet', 'events'])('尊重 %s 静音,仍显示用户主动点击反馈', async mode => { + if (mode === 'master') master = false; + if (mode === 'quiet') prefs.muted_until = Date.now() + 10000; + if (mode === 'events') { prefs.sounds.press.enabled = false; prefs.sounds.release.enabled = false; } + await boot(); mouse('mousedown'); mouse('mouseup'); + expect(sound('press').play).not.toHaveBeenCalled(); expect(sound('release').play).not.toHaveBeenCalled(); + expect(petting()).toBe('我在呢,摸摸~'); + }); + it('Windows 原生窗口的手势使用同一音效与互动状态,重复松手不会重复发声', async () => { + prefs.sounds.press.volume = 30; prefs.sounds.release.volume = 45; + await boot(); + for (const phase of ['press', 'release', 'release']) listeners['pet-touch']({ payload: phase }); + expect(sound('press').volume).toBe(0.3); expect(sound('release').volume).toBe(0.45); + expect(sound('release').play).toHaveBeenCalledOnce(); + expect(petting()).toBe('我在呢,摸摸~'); + }); +}); diff --git a/desktop/ui-next/vitest.config.ts b/desktop/ui-next/vitest.config.ts index e11030e96..310dc934c 100644 --- a/desktop/ui-next/vitest.config.ts +++ b/desktop/ui-next/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ name: "unit", environment: "node", include: [ + "tests/pet-*.test.mjs", "src/{lib,gen,app}/**/*.test.ts", "src/features/{chat/composer,cloud}/**/*.test.ts", ],