From 22427ef26a2acac51e78751a1ae7fe3d31bbbb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Wed, 29 Apr 2026 14:31:28 +0800 Subject: [PATCH 01/45] =?UTF-8?q?feat(macos):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=A4=9A=E5=B1=8F=E5=B9=95=E8=B7=9F=E9=9A=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 主窗口由 NSPanel 创建,CollectionBehavior 含 .stationary(), 导致面板被钉死在创建时所在屏幕,多显示器用户无法把猫咪带到当前工作的屏幕。 - 偏好设置「通用 → 应用设置」新增「跟随当前屏幕」开关,仅 macOS 显示, 默认关闭,行为保持现状 - Rust 端新增 set_multi_screen_follow command,使用 AtomicBool 共享开关状态, show/hide 路径同步读取;开启时去掉 .stationary() 让 NSPanel 可跨 Space/屏幕 - 新增 useMultiScreenFollow composable,开关启用时 800ms 轮询 cursorPosition + monitorFromPoint,按相对偏移把窗口平移到鼠标所在显示器 并裁剪到边界 - Windows / Linux 端提供同名 no-op,保持 generate_handler! 跨平台符号一致 - 5 个 locale (en-US / zh-CN / zh-TW / pt-BR / vi-VN) 全量补齐文案 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + src-tauri/src/plugins/window/Cargo.toml | 1 + src-tauri/src/plugins/window/build.rs | 1 + .../plugins/window/permissions/default.toml | 2 +- .../src/plugins/window/src/commands/linux.rs | 9 ++ .../src/plugins/window/src/commands/macos.rs | 74 +++++++++++++---- .../plugins/window/src/commands/windows.rs | 9 ++ src-tauri/src/plugins/window/src/lib.rs | 1 + src/composables/useMultiScreenFollow.ts | 83 +++++++++++++++++++ src/locales/en-US.json | 2 + src/locales/pt-BR.json | 2 + src/locales/vi-VN.json | 2 + src/locales/zh-CN.json | 2 + src/locales/zh-TW.json | 2 + src/pages/main/index.vue | 3 + .../preference/components/general/index.vue | 9 ++ src/plugins/window.ts | 5 ++ src/stores/general.ts | 2 + 18 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 src/composables/useMultiScreenFollow.ts diff --git a/Cargo.lock b/Cargo.lock index 3c4659882..0caf0a34f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5185,6 +5185,7 @@ dependencies = [ name = "tauri-plugin-custom-window" version = "0.1.0" dependencies = [ + "objc2-app-kit", "serde", "tauri", "tauri-nspanel", diff --git a/src-tauri/src/plugins/window/Cargo.toml b/src-tauri/src/plugins/window/Cargo.toml index 82601face..5134e6c98 100644 --- a/src-tauri/src/plugins/window/Cargo.toml +++ b/src-tauri/src/plugins/window/Cargo.toml @@ -16,6 +16,7 @@ tauri-plugin.workspace = true [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel.workspace = true +objc2-app-kit = "0.3" [target."cfg(target_os = \"windows\")".dependencies] windows = { version = "0.61", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation"] } \ No newline at end of file diff --git a/src-tauri/src/plugins/window/build.rs b/src-tauri/src/plugins/window/build.rs index fa3487662..9bdb782e3 100644 --- a/src-tauri/src/plugins/window/build.rs +++ b/src-tauri/src/plugins/window/build.rs @@ -3,6 +3,7 @@ const COMMANDS: &[&str] = &[ "hide_window", "set_always_on_top", "set_taskbar_visibility", + "set_multi_screen_follow", ]; fn main() { diff --git a/src-tauri/src/plugins/window/permissions/default.toml b/src-tauri/src/plugins/window/permissions/default.toml index 4c72b735e..ffbb3b1e8 100644 --- a/src-tauri/src/plugins/window/permissions/default.toml +++ b/src-tauri/src/plugins/window/permissions/default.toml @@ -2,4 +2,4 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-show-window", "allow-hide-window", "allow-set-always-on-top", "allow-set-taskbar-visibility"] +permissions = ["allow-show-window", "allow-hide-window", "allow-set-always-on-top", "allow-set-taskbar-visibility", "allow-set-multi-screen-follow"] diff --git a/src-tauri/src/plugins/window/src/commands/linux.rs b/src-tauri/src/plugins/window/src/commands/linux.rs index d8c5567e2..91610c280 100644 --- a/src-tauri/src/plugins/window/src/commands/linux.rs +++ b/src-tauri/src/plugins/window/src/commands/linux.rs @@ -31,3 +31,12 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(window: WebviewWindow, visible: bool) { let _ = window.set_skip_taskbar(!visible); } + +// 多屏跟随由前端统一控制,Linux 上窗口可自由跨屏,无需调整原生行为。 +#[command] +pub async fn set_multi_screen_follow( + _app_handle: AppHandle, + _window: WebviewWindow, + _enabled: bool, +) { +} diff --git a/src-tauri/src/plugins/window/src/commands/macos.rs b/src-tauri/src/plugins/window/src/commands/macos.rs index 90c510cad..36fb1c8ff 100644 --- a/src-tauri/src/plugins/window/src/commands/macos.rs +++ b/src-tauri/src/plugins/window/src/commands/macos.rs @@ -1,14 +1,51 @@ #![allow(deprecated)] use crate::MAIN_WINDOW_LABEL; +use objc2_app_kit::NSWindowCollectionBehavior; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Runtime, WebviewWindow, command}; use tauri_nspanel::{CollectionBehavior, ManagerExt, PanelLevel}; +// 多屏跟随开关;开启后去掉 `.stationary()`,让 NSPanel 可在不同屏幕之间移动。 +static MULTI_SCREEN_FOLLOW: AtomicBool = AtomicBool::new(false); + enum MacOSPanelStatus { Show, Hide, SetAlwaysOnTop(bool), } +fn show_collection_behavior() -> NSWindowCollectionBehavior { + let multi = MULTI_SCREEN_FOLLOW.load(Ordering::SeqCst); + if multi { + CollectionBehavior::new() + .can_join_all_spaces() + .full_screen_auxiliary() + .into() + } else { + CollectionBehavior::new() + .stationary() + .can_join_all_spaces() + .full_screen_auxiliary() + .into() + } +} + +fn hide_collection_behavior() -> NSWindowCollectionBehavior { + let multi = MULTI_SCREEN_FOLLOW.load(Ordering::SeqCst); + if multi { + CollectionBehavior::new() + .move_to_active_space() + .full_screen_auxiliary() + .into() + } else { + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into() + } +} + fn is_main_window(window: &WebviewWindow) -> bool { window.label() == MAIN_WINDOW_LABEL } @@ -27,24 +64,12 @@ fn set_macos_panel( MacOSPanelStatus::Show => { panel.show(); - panel.set_collection_behavior( - CollectionBehavior::new() - .stationary() - .can_join_all_spaces() - .full_screen_auxiliary() - .into(), - ); + panel.set_collection_behavior(show_collection_behavior()); } MacOSPanelStatus::Hide => { panel.hide(); - panel.set_collection_behavior( - CollectionBehavior::new() - .stationary() - .move_to_active_space() - .full_screen_auxiliary() - .into(), - ); + panel.set_collection_behavior(hide_collection_behavior()); } MacOSPanelStatus::SetAlwaysOnTop(always_on_top) => { if always_on_top { @@ -106,3 +131,24 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(app_handle: AppHandle, visible: bool) { let _ = app_handle.set_dock_visibility(visible); } + +#[command] +pub async fn set_multi_screen_follow( + app_handle: AppHandle, + window: WebviewWindow, + enabled: bool, +) { + if !is_main_window(&window) { + return; + } + + MULTI_SCREEN_FOLLOW.store(enabled, Ordering::SeqCst); + + let app_handle_clone = app_handle.clone(); + + let _ = app_handle.run_on_main_thread(move || { + if let Ok(panel) = app_handle_clone.get_webview_panel(MAIN_WINDOW_LABEL) { + panel.set_collection_behavior(show_collection_behavior()); + } + }); +} diff --git a/src-tauri/src/plugins/window/src/commands/windows.rs b/src-tauri/src/plugins/window/src/commands/windows.rs index a9170b846..151db2dd2 100644 --- a/src-tauri/src/plugins/window/src/commands/windows.rs +++ b/src-tauri/src/plugins/window/src/commands/windows.rs @@ -82,3 +82,12 @@ pub async fn set_always_on_top( pub async fn set_taskbar_visibility(window: WebviewWindow, visible: bool) { let _ = window.set_skip_taskbar(!visible); } + +// 多屏跟随由前端统一控制,Windows 上窗口可自由跨屏,无需调整原生行为。 +#[command] +pub async fn set_multi_screen_follow( + _app_handle: AppHandle, + _window: WebviewWindow, + _enabled: bool, +) { +} diff --git a/src-tauri/src/plugins/window/src/lib.rs b/src-tauri/src/plugins/window/src/lib.rs index 94266e6a5..0f849da8f 100644 --- a/src-tauri/src/plugins/window/src/lib.rs +++ b/src-tauri/src/plugins/window/src/lib.rs @@ -14,6 +14,7 @@ pub fn init() -> TauriPlugin { commands::hide_window, commands::set_always_on_top, commands::set_taskbar_visibility, + commands::set_multi_screen_follow, ]) .build() } diff --git a/src/composables/useMultiScreenFollow.ts b/src/composables/useMultiScreenFollow.ts new file mode 100644 index 000000000..98a5fc7c0 --- /dev/null +++ b/src/composables/useMultiScreenFollow.ts @@ -0,0 +1,83 @@ +import { PhysicalPosition } from '@tauri-apps/api/dpi' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { cursorPosition, monitorFromPoint } from '@tauri-apps/api/window' +import { useIntervalFn } from '@vueuse/core' +import { watch } from 'vue' + +import { setMultiScreenFollow } from '@/plugins/window' +import { useCatStore } from '@/stores/cat' +import { useGeneralStore } from '@/stores/general' +import { isMac } from '@/utils/platform' + +const POLL_INTERVAL_MS = 800 + +export function useMultiScreenFollow() { + if (!isMac) return + + const generalStore = useGeneralStore() + const catStore = useCatStore() + const appWindow = getCurrentWebviewWindow() + + const tick = async () => { + if (!catStore.window.visible) return + + const [winPos, winSize, cursor, scaleFactor] = await Promise.all([ + appWindow.outerPosition(), + appWindow.outerSize(), + cursorPosition(), + appWindow.scaleFactor(), + ]) + + const cursorLogical = cursor.toLogical(scaleFactor) + const winCenterLogical = new PhysicalPosition( + winPos.x + Math.floor(winSize.width / 2), + winPos.y + Math.floor(winSize.height / 2), + ).toLogical(scaleFactor) + + const [cursorMon, winMon] = await Promise.all([ + monitorFromPoint(cursorLogical.x, cursorLogical.y), + monitorFromPoint(winCenterLogical.x, winCenterLogical.y), + ]) + + if (!cursorMon || !winMon) return + + const sameMonitor + = winMon.position.x === cursorMon.position.x + && winMon.position.y === cursorMon.position.y + && winMon.size.width === cursorMon.size.width + && winMon.size.height === cursorMon.size.height + + if (sameMonitor) return + + const offsetX = winPos.x - winMon.position.x + const offsetY = winPos.y - winMon.position.y + + const minX = cursorMon.position.x + const maxX = cursorMon.position.x + cursorMon.size.width - winSize.width + const minY = cursorMon.position.y + const maxY = cursorMon.position.y + cursorMon.size.height - winSize.height + + const targetX = Math.max(minX, Math.min(cursorMon.position.x + offsetX, maxX)) + const targetY = Math.max(minY, Math.min(cursorMon.position.y + offsetY, maxY)) + + if (targetX === winPos.x && targetY === winPos.y) return + + await appWindow.setPosition(new PhysicalPosition(targetX, targetY)) + } + + const { pause, resume } = useIntervalFn(tick, POLL_INTERVAL_MS, { immediate: false }) + + watch( + () => generalStore.app.multiScreenFollow, + async (enabled) => { + await setMultiScreenFollow(enabled) + + if (enabled) { + resume() + } else { + pause() + } + }, + { immediate: true }, + ) +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 71b1e8b37..d5acdeade 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -50,6 +50,7 @@ "launchOnStartup": "Launch on Startup", "showTaskbarIcon": "Show Taskbar Icon", "showTrayIcon": "Show Tray Icon", + "multiScreenFollow": "Follow Active Display", "appearanceSettings": "Appearance Settings", "themeMode": "Theme Mode", "language": "Language", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "When enabled, the window can be captured via OBS Studio.", "showTrayIcon": "When enabled, the app icon is displayed in the system tray.", + "multiScreenFollow": "When enabled, the cat automatically moves to whichever display the cursor is on.", "inputMonitoringPermission": "Enable input monitoring to receive keyboard and mouse events from the system.", "inputMonitoringPermissionGuide": "If the permission is already enabled, select it and click the \"-\" button to remove it, then manually add it again and restart the app." }, diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index fb87561fd..68e0b238a 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -50,6 +50,7 @@ "launchOnStartup": "Iniciar na inicialização", "showTaskbarIcon": "Mostrar ícone na barra de tarefas", "showTrayIcon": "Mostrar ícone na bandeja", + "multiScreenFollow": "Seguir o monitor ativo", "appearanceSettings": "Configurações de aparência", "themeMode": "Tema", "language": "Idiomas", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "Uma vez ativado, você pode capturar a janela via OBS Studio.", "showTrayIcon": "Quando ativado, o ícone do aplicativo é exibido na bandeja do sistema.", + "multiScreenFollow": "Quando ativado, o BongoCat se move automaticamente para o monitor onde está o cursor.", "inputMonitoringPermission": "Ative a permissão de monitoramento de entrada para receber eventos de teclado e mouse do sistema para responder às suas ações.", "inputMonitoringPermissionGuide": "Se a permissão já estiver ativada, primeiro selecione-a e clique no botão \"-\" para removê-la. Em seguida, adicione-a novamente manualmente e reinicie o aplicativo para garantir que a permissão entre em vigor." }, diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index 832063c4d..a19c09946 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -50,6 +50,7 @@ "launchOnStartup": "Khởi động cùng hệ thống", "showTaskbarIcon": "Hiện biểu tượng trên thanh tác vụ (icon taskbar)", "showTrayIcon": "Hiện biểu tượng trên khay hệ thống (tray)", + "multiScreenFollow": "Theo màn hình hiện tại", "appearanceSettings": "Cài đặt giao diện", "themeMode": "Giao diện", "language": "Ngôn ngữ", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "Bật để có thể quay cửa sổ qua OBS.", "showTrayIcon": "Bật để hiện biểu tượng ứng dụng trên khay hệ thống.", + "multiScreenFollow": "Khi bật, BongoCat sẽ tự động di chuyển sang màn hình đang đặt con trỏ chuột.", "inputMonitoringPermission": "Bật quyền giám sát để nhận sự kiện bàn phím và chuột từ hệ thống nhằm phản hồi thao tác của bạn.", "inputMonitoringPermissionGuide": "Nếu quyền đã được bật, hãy chọn nó và nhấn nút \"-\" để xóa. Sau đó thêm lại thủ công và khởi động lại ứng dụng để đảm bảo quyền được áp dụng." }, diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 9abd52c4b..6ea0ec6b7 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -50,6 +50,7 @@ "launchOnStartup": "开机自启动", "showTaskbarIcon": "显示任务栏图标", "showTrayIcon": "显示托盘图标", + "multiScreenFollow": "跟随当前屏幕", "appearanceSettings": "外观设置", "themeMode": "主题模式", "language": "语言", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "启用后,即可通过 OBS Studio 捕获窗口。", "showTrayIcon": "启用后,在系统托盘中显示应用图标。", + "multiScreenFollow": "启用后,BongoCat 会自动移动到鼠标所在的显示器。", "inputMonitoringPermission": "开启输入监控权限,以便接收系统的键盘和鼠标事件来响应你的操作。", "inputMonitoringPermissionGuide": "如果权限已开启,请先选中并点击“-”按钮将其删除,然后重新手动添加,最后重启应用以确保权限生效。" }, diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 9ab44a041..a74a5602b 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -50,6 +50,7 @@ "launchOnStartup": "開機自動啟動", "showTaskbarIcon": "顯示工作列圖示", "showTrayIcon": "顯示托盤圖示", + "multiScreenFollow": "跟隨目前螢幕", "appearanceSettings": "外觀設定", "themeMode": "主題模式", "language": "語言", @@ -66,6 +67,7 @@ "hints": { "showTaskbarIcon": "啟用後,即可透過 OBS Studio 擷取視窗。", "showTrayIcon": "啟用後,在系統托盤中顯示應用程式圖示。", + "multiScreenFollow": "啟用後,BongoCat 會自動移動到滑鼠所在的螢幕。", "inputMonitoringPermission": "開啟輸入監控權限,以便接收系統的鍵盤和滑鼠游標事件來回應您的操作。", "inputMonitoringPermissionGuide": "如果權限已開啟,請先選中並點擊「-」按鈕將其刪除,然後重新手動新增,最後重啟應用程式以確保權限生效。" }, diff --git a/src/pages/main/index.vue b/src/pages/main/index.vue index b0427e672..af5f5ea90 100644 --- a/src/pages/main/index.vue +++ b/src/pages/main/index.vue @@ -16,6 +16,7 @@ import { useAppMenu } from '@/composables/useAppMenu' import { useDevice } from '@/composables/useDevice' import { useGamepad } from '@/composables/useGamepad' import { useModel } from '@/composables/useModel' +import { useMultiScreenFollow } from '@/composables/useMultiScreenFollow' import { useTauriListen } from '@/composables/useTauriListen' import { LISTEN_KEY } from '@/constants' import { hideWindow, setAlwaysOnTop, setTaskbarVisibility, showWindow } from '@/plugins/window' @@ -39,6 +40,8 @@ const resizing = ref(false) const backgroundImagePath = ref() const { stickActive } = useGamepad() +useMultiScreenFollow() + onMounted(startListening) onUnmounted(handleDestroy) diff --git a/src/pages/preference/components/general/index.vue b/src/pages/preference/components/general/index.vue index b69a303ec..4c5a49bfb 100644 --- a/src/pages/preference/components/general/index.vue +++ b/src/pages/preference/components/general/index.vue @@ -6,6 +6,7 @@ import { watch } from 'vue' import ProListItem from '@/components/pro-list-item/index.vue' import ProList from '@/components/pro-list/index.vue' import { useGeneralStore } from '@/stores/general' +import { isMac } from '@/utils/platform' import MacosPermissions from './components/macos-permissions/index.vue' import ThemeMode from './components/theme-mode/index.vue' @@ -46,6 +47,14 @@ watch(() => generalStore.app.autostart, async (value) => { > + + + + diff --git a/src/plugins/window.ts b/src/plugins/window.ts index 014e3e5bf..92c664ef9 100644 --- a/src/plugins/window.ts +++ b/src/plugins/window.ts @@ -13,6 +13,7 @@ const COMMAND = { HIDE_WINDOW: 'plugin:custom-window|hide_window', SET_ALWAYS_ON_TOP: 'plugin:custom-window|set_always_on_top', SET_TASKBAR_VISIBILITY: 'plugin:custom-window|set_taskbar_visibility', + SET_MULTI_SCREEN_FOLLOW: 'plugin:custom-window|set_multi_screen_follow', } export function showWindow(label?: WindowLabel) { @@ -52,3 +53,7 @@ export async function toggleWindowVisible(label?: WindowLabel) { export async function setTaskbarVisibility(visible: boolean) { invoke(COMMAND.SET_TASKBAR_VISIBILITY, { visible }) } + +export function setMultiScreenFollow(enabled: boolean) { + return invoke(COMMAND.SET_MULTI_SCREEN_FOLLOW, { enabled }) +} diff --git a/src/stores/general.ts b/src/stores/general.ts index 875ac3c1a..bb1384fc1 100644 --- a/src/stores/general.ts +++ b/src/stores/general.ts @@ -13,6 +13,7 @@ export interface GeneralStore { autostart: boolean taskbarVisible: boolean trayVisible: boolean + multiScreenFollow: boolean } appearance: { theme: 'auto' | Theme @@ -49,6 +50,7 @@ export const useGeneralStore = defineStore('general', () => { autostart: false, taskbarVisible: false, trayVisible: true, + multiScreenFollow: false, }) const appearance = reactive({ From d21260d24c1a332512cb405798f401d563bccc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Wed, 29 Apr 2026 15:16:30 +0800 Subject: [PATCH 02/45] =?UTF-8?q?feat(macos):=20=E5=A4=9A=E5=B1=8F?= =?UTF-8?q?=E5=B9=95=E8=B7=9F=E9=9A=8F=E8=AE=B0=E5=BF=86=E6=AF=8F=E5=B1=8F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 大屏 → 小屏切换时跨屏跟随会被裁剪到小屏边界,回到大屏后 之前用户手动设定的位置就会丢失。 - 新增 monitorOffsets Map,按屏幕 (x,y,w,h) 作为 key - 每次轮询同步当前屏幕的最新偏移,捕获用户屏内拖动 - 跨屏移动时优先使用目标屏幕的历史偏移,无记录再用源屏偏移 - 关闭开关时清空 Map,避免状态残留 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/composables/useMultiScreenFollow.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/composables/useMultiScreenFollow.ts b/src/composables/useMultiScreenFollow.ts index 98a5fc7c0..f1580d045 100644 --- a/src/composables/useMultiScreenFollow.ts +++ b/src/composables/useMultiScreenFollow.ts @@ -1,3 +1,5 @@ +import type { Monitor } from '@tauri-apps/api/window' + import { PhysicalPosition } from '@tauri-apps/api/dpi' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { cursorPosition, monitorFromPoint } from '@tauri-apps/api/window' @@ -11,6 +13,10 @@ import { isMac } from '@/utils/platform' const POLL_INTERVAL_MS = 800 +function monitorKey(mon: Monitor) { + return `${mon.position.x},${mon.position.y},${mon.size.width},${mon.size.height}` +} + export function useMultiScreenFollow() { if (!isMac) return @@ -18,6 +24,11 @@ export function useMultiScreenFollow() { const catStore = useCatStore() const appWindow = getCurrentWebviewWindow() + // 记忆每个屏幕上窗口最后停留的相对偏移(相对屏幕原点)。 + // 大屏 → 小屏时偏移会被裁剪到小屏边界,若直接用这个被裁剪过的偏移再换算回大屏, + // 用户最初的位置就会丢失。缓存能在回到原屏幕时恢复用户实际设定的位置。 + const monitorOffsets = new Map() + const tick = async () => { if (!catStore.window.visible) return @@ -41,6 +52,12 @@ export function useMultiScreenFollow() { if (!cursorMon || !winMon) return + // 始终更新当前所在屏幕的偏移记忆,捕捉用户在屏内手动拖动后的最新位置。 + monitorOffsets.set(monitorKey(winMon), { + x: winPos.x - winMon.position.x, + y: winPos.y - winMon.position.y, + }) + const sameMonitor = winMon.position.x === cursorMon.position.x && winMon.position.y === cursorMon.position.y @@ -49,8 +66,10 @@ export function useMultiScreenFollow() { if (sameMonitor) return - const offsetX = winPos.x - winMon.position.x - const offsetY = winPos.y - winMon.position.y + // 优先使用目标屏幕的历史偏移;首次进入则沿用源屏幕的偏移作为初值。 + const remembered = monitorOffsets.get(monitorKey(cursorMon)) + const offsetX = remembered?.x ?? (winPos.x - winMon.position.x) + const offsetY = remembered?.y ?? (winPos.y - winMon.position.y) const minX = cursorMon.position.x const maxX = cursorMon.position.x + cursorMon.size.width - winSize.width @@ -75,6 +94,7 @@ export function useMultiScreenFollow() { if (enabled) { resume() } else { + monitorOffsets.clear() pause() } }, From 7bcdc7b3a80509f40f21266b2e83d41f078267cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 17:15:58 +0800 Subject: [PATCH 03/45] =?UTF-8?q?docs:=20AI=20=E5=AF=B9=E8=AF=9D=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-29-ai-chat-bubble-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md diff --git a/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md b/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md new file mode 100644 index 000000000..f11577e53 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md @@ -0,0 +1,192 @@ +# 设计:猫咪头顶 AI 对话气泡(独立附着窗口) + +- 日期:2026-06-29 +- 状态:已确认,待实现 +- 方案:B(动态尺寸窗口) + +## 1. 目标与背景 + +在桌宠(Live2D 猫咪)头顶展示一个对话气泡,显示动态文字。 + +- 文字来源:**由代码/事件主动推送**(提供一个通用 `say()` 接口,任意位置可调用;后续接一言、句库、交互触发都建在它之上)。 +- 消失行为:**定时自动消失**,接口可传 `duration=0` 表示常驻。 +- 平台:**三端都要**(macOS / Windows / Linux),macOS 用 NSPanel 处理层级。 + +**为什么必须独立窗口**:主窗口尺寸紧贴模型且 `overflow-hidden`,气泡渲染在主窗口里会被裁切。独立窗口才能伸到猫咪上方边界之外。 + +## 2. 架构与权责 + +``` +say(text, duration) ← 任意位置可调用的推送 API + └─ emit 'show-chat'(广播到所有窗口) + └─▶ chat 窗口(/chat,独立透明穿透窗口)= 唯一生命周期权威 + ① 渲染文字 → 测量气泡真实尺寸 + ② setSize(自身 = 气泡尺寸) ← 方案 B 的动态尺寸 + ③ reposition(摆到猫咪正上方居中) + ④ show + 淡入 + ⑤ duration>0 → 计时 → 淡出 + hide +主窗口 move/resize → 通知 chat → chat reposition() +``` + +权责收敛原则:**chat 窗口自己管全部生命周期**(尺寸、定位、计时、动画),主窗口几乎零改动,从而把方案 B 的跨窗口同步坑降到最小。 + +主窗口仅有的改动:在 macOS 把已有的 `tauri://move` / `tauri://resize` 从「只发给 main」改成广播,好让 chat 也能收到。Windows/Linux 上 chat 直接用 `getByLabel('main').onMoved/onResized` 原生监听,无需改主窗口。 + +## 3. 新增窗口(`src-tauri/tauri.conf.json`) + +在 `windows` 数组新增: + +```jsonc +{ + "label": "chat", + "url": "index.html/#/chat", + "width": 200, "height": 100, // 初始值,运行时被动态 setSize 覆盖 + "visible": false, + "transparent": true, + "decorations": false, + "shadow": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "maximizable": false, + "focus": false +} +``` + +路由(`src/router`):新增 `/chat` → `src/pages/chat/index.vue`。 + +## 4. chat 页面 `src/pages/chat/index.vue` + +- **挂载时** `appWindow.setIgnoreCursorEvents(true)`(整窗鼠标穿透,透明空白区点不到)。 +- **气泡 UI**:圆角矩形 + 朝下小三角(指向猫咪),底部居中锚定,文字向上换行;`` 做淡入淡出。 +- 样式从 `useAiStore()` 读取并绑到 `:style`:`textColor` / `fontSize` / `bgColor`+`bgOpacity`(合成 `rgba` 作气泡背景填充,窗口本身始终透明)。 +- **监听 `show-chat {text, duration}`**: + 1. 写入 `text` → `await nextTick()` + 2. `bubbleEl.getBoundingClientRect()` 量出 `w/h`(CSS 逻辑像素) + 3. `appWindow.setSize(new LogicalSize(w + 阴影留白, h + 三角高))` + 4. `reposition()` → `appWindow.show()` → 触发淡入 + 5. 重置计时器:`duration > 0` 时 `duration` 毫秒后淡出并 `hide()`;`=0` 常驻 +- **监听主窗口几何变化** → 可见时 `reposition()`。 +- **样式变化(字号等)且气泡可见时** → 重新测量 → `setSize` → `reposition`(字号改变会改变尺寸)。 + +## 5. 定位(纯函数,可测)`src/utils/chatPosition.ts` + +```ts +// 全部用物理像素计算 +computeBubblePosition(main{x,y,w,h}, bubble{w,h}, screen{x,y,w,h}, gap) { + let x = main.x + (main.w - bubble.w) / 2 // 水平居中于猫 + let y = main.y - bubble.h - gap // 放猫咪正上方 + x = clamp(x, screen.x, screen.x + screen.w - bubble.w) // 不超出屏幕 + if (y < screen.y) y = main.y + main.h + gap // 上方没空间 → 翻到下方 + return { x, y } +} +``` + +**DPI / 多屏处理(方案 B 的关键坑)**: +- `getBoundingClientRect` 是逻辑像素;`outerPosition` / 显示器 bounds 是物理像素。 +- 定位前把 bubble 尺寸 × `scaleFactor` 转物理;`setSize` 用 `LogicalSize`、`setPosition` 用 `PhysicalPosition`。 +- `screen` 取**猫当前所在显示器**:用主窗口 `currentMonitor()` 拿该显示器的 position/size/scaleFactor(不是主屏),bubble 尺寸 × 该显示器 scaleFactor 转物理再算。 + +## 6. 推送 API `src/composables/useChat.ts` + +```ts +export function say(text: string, duration?: number) { + const aiStore = useAiStore() + if (!aiStore.ai.enabled) return + emit('show-chat', { text, duration: duration ?? aiStore.ai.duration * 1000 }) +} +``` + +- 全局 `emit` 广播,chat 窗口接收。任意页面/composable 直接 `say('你好~')`。 +- Rust 侧未来要推送只需 `app.emit("show-chat", payload)`,本版**不实现**(YAGNI,留一行注释说明)。 + +## 7. macOS NSPanel(`src-tauri/src/core/setup/macos.rs`) + +- 取 `chat` 窗口 → `to_panel()`,与猫**同 level(Dock)+ 同 collection behavior**(跟随空间、全屏辅助),`non_activating`、`can_become_key=false`(永不抢焦点)。 +- chat 创建晚于 main,show 时 order front 即在猫之上。`// ponytail: 同层 order-front;若层级不准再抬高 PanelLevel`。 +- 把现有 `emit_position` / resize 的 `emit_to(main)` 改成广播(emit),使 chat 能收到主窗口移动/缩放。 + +## 8. 独立设置项「AI」 + +新 tab:`preference/index.vue` 的 `menus` 加一项 +`{ key:'ai', label:'AI', icon:'i-solar:chat-round-bold', component: Ai }`, +新建 `src/pages/preference/components/ai/index.vue`。 + +新建独立 store `src/stores/ai.ts`(所有气泡配置集中在此,通过 `@tauri-store/pinia` 跨窗口同步): + +```ts +ai: { + enabled: boolean // 总开关,默认 true + duration: number // 默认展示秒数,默认 3 + textColor: string // 文字颜色,默认 '#333' + fontSize: number // 文字大小(px),默认 14 + bgColor: string // 气泡底色,默认 '#fff' + bgOpacity: number // 底色透明度 0-100,默认 90 + debug: boolean // DEBUG 开关,默认 false +} +``` + +AI 设置页(用现有 `ProList`/`ProListItem` + antdv-next 控件): +- **总开关** `enabled`(Switch) +- **默认秒数** `duration`(InputNumber + `s` 后缀) +- **文字颜色** `textColor`(ColorPicker)/ **文字大小** `fontSize`(InputNumber 或 Slider) +- **气泡底色** `bgColor`(ColorPicker)/ **透明度** `bgOpacity`(Slider 0-100) +- **DEBUG** `debug`(Switch);开启后**展开测试区**: + - 文本输入框 + 「展示」按钮 → 调 `say(inputText)` 立即在猫咪头顶展示 + - 这块同时充当定位的手动验证工具(见第 9 节) + +ColorPicker 用 antdv-next 自带;若缺失则回退原生 ``(`// ponytail`)。 + +i18n:5 个语言包补 key(zh-CN / zh-TW / en-US / vi-VN / pt-BR)。 + +## 9. 验证 —— 定位为重点 + +### (a) 纯函数 `chatPosition.ts` 单测(assert 自检,无框架,覆盖所有分支) + +1. 正常居中:猫在屏幕中央 → x 居中、y 在上方 +2. 贴左边缘 → x 夹到 `screen.x` +3. 贴右边缘 → x 夹到 `screen.x + screen.w - bubble.w` +4. 贴顶部、上方放不下 → 翻转到猫咪下方 +5. 气泡比猫宽 → 仍以猫中心对齐 +6. 气泡比屏幕还宽 → 夹取后不越界(取 `screen.x`) +7. 多显示器:猫在副屏(负坐标/偏移)→ 用猫所在显示器的 bounds 夹取 +8. DPI=2:逻辑尺寸 × scaleFactor 后物理坐标正确 + +### (b) DPI / 多屏的真实数据来源 + +定位时用主窗口 `currentMonitor()` 拿到**猫当前所在显示器**的 position/size/scaleFactor,而不是主屏——这是多屏正确的关键。气泡尺寸(逻辑 px)× 该显示器 scaleFactor 转物理再算。 + +### (c) 手动验证清单(借 DEBUG 测试区逐项过) + +- 短文 / 长文 / 多行换行 → 尺寸自适应且始终底边居中贴猫头顶 +- 把猫拖到屏幕四边 + 四角 → 不裁切、贴边自动夹取、顶部不够时翻到下方 +- 改猫咪 `scale` → 气泡跟随重新定位 +- 跨显示器拖动、不同 DPI 的两块屏 → 位置/尺寸正确 +- 拖动猫咪时气泡跟随(允许极轻微延迟) + +### (d) 可见验证触发 + +模型加载完成后 `say(t('greeting'))` 打个招呼(受 `ai.enabled` 控制),启动即可看到气泡浮在猫咪头顶。 + +## 10. 改动文件一览 + +新增: +- `src/pages/chat/index.vue` +- `src/utils/chatPosition.ts`(+ 自检) +- `src/composables/useChat.ts` +- `src/stores/ai.ts` +- `src/pages/preference/components/ai/index.vue` + +修改: +- `src-tauri/tauri.conf.json`(新增 chat 窗口) +- `src/router`(新增 /chat 路由) +- `src-tauri/src/core/setup/macos.rs`(chat NSPanel + move/resize 改广播) +- `src/pages/preference/index.vue`(menus 加 AI tab) +- `src/locales/*`(5 个语言包补 key) +- `src/pages/main/index.vue`(模型加载后调一次 `say` 打招呼) + +## 11. 已知简化(ponytail) + +- macOS chat 与猫同 NSPanel level + order-front;若层级不准再抬高 `PanelLevel`。 +- 拖动猫咪时气泡用 JS 重定位有极轻微跟随延迟;完全消除需原生子窗口(大量平台代码),不值。 +- Rust 侧推送接口本版不实现,留注释;前端 `say()` 已是完整底座。 From 55cf9ace62bfba71858c07ac0be22d32fec404e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 17:23:11 +0800 Subject: [PATCH 04/45] =?UTF-8?q?docs:=20=E6=B0=94=E6=B3=A1=20spec=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20HTTP=20=E5=A4=96=E9=83=A8=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E6=8E=A5=E5=8F=A3=EF=BC=88=E6=96=B9=E6=A1=88=E2=91=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-29-ai-chat-bubble-design.md | 89 +++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md b/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md index f11577e53..6ca74de79 100644 --- a/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md +++ b/docs/superpowers/specs/2026-06-29-ai-chat-bubble-design.md @@ -8,7 +8,7 @@ 在桌宠(Live2D 猫咪)头顶展示一个对话气泡,显示动态文字。 -- 文字来源:**由代码/事件主动推送**(提供一个通用 `say()` 接口,任意位置可调用;后续接一言、句库、交互触发都建在它之上)。 +- 文字来源:**由代码/事件主动推送**。两类生产者:前端 `say()`(任意位置可调用)+ 本地 HTTP 接口(进程外 bash/curl/任意工具推送,见 6.1);后续接一言、句库、交互触发都建在它之上。 - 消失行为:**定时自动消失**,接口可传 `duration=0` 表示常驻。 - 平台:**三端都要**(macOS / Windows / Linux),macOS 用 NSPanel 处理层级。 @@ -60,12 +60,13 @@ say(text, duration) ← 任意位置可调用的推送 API - **挂载时** `appWindow.setIgnoreCursorEvents(true)`(整窗鼠标穿透,透明空白区点不到)。 - **气泡 UI**:圆角矩形 + 朝下小三角(指向猫咪),底部居中锚定,文字向上换行;`` 做淡入淡出。 - 样式从 `useAiStore()` 读取并绑到 `:style`:`textColor` / `fontSize` / `bgColor`+`bgOpacity`(合成 `rgba` 作气泡背景填充,窗口本身始终透明)。 -- **监听 `show-chat {text, duration}`**: +- **监听 `show-chat {text, duration?}`**(`duration` 单位毫秒,可选): + 0. `!aiStore.ai.enabled` → 直接忽略(**总开关唯一生效点**,所有生产者共用) 1. 写入 `text` → `await nextTick()` 2. `bubbleEl.getBoundingClientRect()` 量出 `w/h`(CSS 逻辑像素) 3. `appWindow.setSize(new LogicalSize(w + 阴影留白, h + 三角高))` 4. `reposition()` → `appWindow.show()` → 触发淡入 - 5. 重置计时器:`duration > 0` 时 `duration` 毫秒后淡出并 `hide()`;`=0` 常驻 + 5. `ms = duration ?? aiStore.ai.duration * 1000`(**默认时长唯一兜底点**);重置计时器:`ms > 0` 时 `ms` 毫秒后淡出并 `hide()`;`=0` 常驻 - **监听主窗口几何变化** → 可见时 `reposition()`。 - **样式变化(字号等)且气泡可见时** → 重新测量 → `setSize` → `reposition`(字号改变会改变尺寸)。 @@ -91,14 +92,53 @@ computeBubblePosition(main{x,y,w,h}, bubble{w,h}, screen{x,y,w,h}, gap) { ```ts export function say(text: string, duration?: number) { - const aiStore = useAiStore() - if (!aiStore.ai.enabled) return - emit('show-chat', { text, duration: duration ?? aiStore.ai.duration * 1000 }) + emit('show-chat', { text, duration }) // duration 单位毫秒;undefined 时由 chat 页兜底默认值 } ``` - 全局 `emit` 广播,chat 窗口接收。任意页面/composable 直接 `say('你好~')`。 -- Rust 侧未来要推送只需 `app.emit("show-chat", payload)`,本版**不实现**(YAGNI,留一行注释说明)。 +- **总开关 / 默认时长不在这里判断**,统一由 chat 页处理(见第 4 节 step 0 / 5),保证前端 `say()` 与 HTTP 接口两个生产者行为一致。 +- 两类生产者最终都只是发同一个 `show-chat` 事件: + - 前端任意位置:`say()` + - 进程外(bash / curl / 任意工具):HTTP 接口(见 6.1) + +## 6.1 HTTP 外部推送接口(方案②) + +进程内事件够不着外部。内嵌一个**本地 HTTP server**作为进程外入口,让 `curl` / 任意工具直接推送。 + +**依赖**:`tiny_http`(极轻量、无需 async 运行时,单独 std 线程跑阻塞循环;比 axum 省)。加入 workspace `Cargo.toml`:`tiny_http = "0.12"`。 + +**模块**:新增 `src-tauri/src/core/server.rs`,在 `setup` 阶段按配置启动。 + +``` +GET http://127.0.0.1:/say?text=&duration=<秒,可选>&token=<可选> +``` + +- 启动时读 `aiStore` 配置:`httpEnabled` / `httpPort` / `httpToken`。 +- 在独立线程 `std::thread::spawn` 跑 `tiny_http::Server::http("127.0.0.1:")` 阻塞循环。 +- 收到请求 → 解析 query: + - `httpToken` 非空时校验 `token` 不匹配 → `401` + - `text` 缺失 → `400` + - `duration` 给了就 `秒 → 毫秒`;没给则**不带** `duration` 字段(让 chat 页用默认值);`0` 表示常驻 + - 校验通过 → `app_handle.emit("show-chat", { text, duration })` → 返回 `200 ok` +- **复用同一个 `show-chat` 事件**:总开关 / 默认时长 / 定位 / 动画全部由 chat 页统一处理,HTTP 侧只管解析转发。 + +调用示例: + +```bash +# 简单 +curl "http://127.0.0.1:7800/say?text=%E4%BD%A0%E5%A5%BD%E5%91%80" +# 自动 urlencode + 指定 5 秒 + token +curl -G "http://127.0.0.1:7800/say" \ + --data-urlencode "text=你好呀~" \ + --data "duration=5" --data "token=abc123" +``` + +**安全(信任边界,不可省)**: +- **只绑 `127.0.0.1`**,不监听外网。 +- 开关默认 **关闭**(`httpEnabled=false`):开一个监听端口是用户应主动同意的行为;在「AI」设置里显式开启。 +- 可选 `httpToken`:同机其它进程/用户也能访问 localhost,需要更强隔离时填 token 校验。默认空=仅靠 localhost。 +- `// ponytail: 改端口/开关/token 后需重启 app 生效(不做热重启)`。 ## 7. macOS NSPanel(`src-tauri/src/core/setup/macos.rs`) @@ -116,13 +156,17 @@ export function say(text: string, duration?: number) { ```ts ai: { - enabled: boolean // 总开关,默认 true - duration: number // 默认展示秒数,默认 3 - textColor: string // 文字颜色,默认 '#333' - fontSize: number // 文字大小(px),默认 14 - bgColor: string // 气泡底色,默认 '#fff' - bgOpacity: number // 底色透明度 0-100,默认 90 - debug: boolean // DEBUG 开关,默认 false + enabled: boolean // 总开关,默认 true + duration: number // 默认展示秒数,默认 3 + textColor: string // 文字颜色,默认 '#333' + fontSize: number // 文字大小(px),默认 14 + bgColor: string // 气泡底色,默认 '#fff' + bgOpacity: number // 底色透明度 0-100,默认 90 + debug: boolean // DEBUG 开关,默认 false + // —— HTTP 外部接口(见 6.1)—— + httpEnabled: boolean // HTTP 接口开关,默认 false(安全:默认不开端口) + httpPort: number // 监听端口,默认 7800 + httpToken: string // 可选校验 token,默认 ''(空=不校验) } ``` @@ -131,6 +175,7 @@ AI 设置页(用现有 `ProList`/`ProListItem` + antdv-next 控件): - **默认秒数** `duration`(InputNumber + `s` 后缀) - **文字颜色** `textColor`(ColorPicker)/ **文字大小** `fontSize`(InputNumber 或 Slider) - **气泡底色** `bgColor`(ColorPicker)/ **透明度** `bgOpacity`(Slider 0-100) +- **HTTP 接口**子区:`httpEnabled`(Switch)/ `httpPort`(InputNumber)/ `httpToken`(Input.Password,可空);开启时展示一条可复制的 `curl` 示例;旁注「改动后需重启生效」。 - **DEBUG** `debug`(Switch);开启后**展开测试区**: - 文本输入框 + 「展示」按钮 → 调 `say(inputText)` 立即在猫咪头顶展示 - 这块同时充当定位的手动验证工具(见第 9 节) @@ -168,6 +213,14 @@ i18n:5 个语言包补 key(zh-CN / zh-TW / en-US / vi-VN / pt-BR)。 模型加载完成后 `say(t('greeting'))` 打个招呼(受 `ai.enabled` 控制),启动即可看到气泡浮在猫咪头顶。 +### (e) HTTP 接口验证 + +- 关闭 `httpEnabled` → 端口不监听(`curl` 连不上)。 +- 开启后重启 → `curl ".../say?text=hi"` 返回 `200` 且猫咪头顶冒泡。 +- 缺 `text` → `400`;设了 `httpToken` 且不带/错 token → `401`。 +- 绑定确认:只在 `127.0.0.1` 可达,外网 IP 连不上。 +- `duration=0` → 常驻;`duration=5` → 5 秒后消失。 + ## 10. 改动文件一览 新增: @@ -177,10 +230,14 @@ i18n:5 个语言包补 key(zh-CN / zh-TW / en-US / vi-VN / pt-BR)。 - `src/stores/ai.ts` - `src/pages/preference/components/ai/index.vue` +- `src-tauri/src/core/server.rs`(HTTP 外部推送,tiny_http) + 修改: - `src-tauri/tauri.conf.json`(新增 chat 窗口) - `src/router`(新增 /chat 路由) - `src-tauri/src/core/setup/macos.rs`(chat NSPanel + move/resize 改广播) +- `src-tauri/src/core/mod.rs` + `lib.rs`(setup 阶段启动 HTTP server) +- `src-tauri/Cargo.toml`(+ `tiny_http`) - `src/pages/preference/index.vue`(menus 加 AI tab) - `src/locales/*`(5 个语言包补 key) - `src/pages/main/index.vue`(模型加载后调一次 `say` 打招呼) @@ -189,4 +246,6 @@ i18n:5 个语言包补 key(zh-CN / zh-TW / en-US / vi-VN / pt-BR)。 - macOS chat 与猫同 NSPanel level + order-front;若层级不准再抬高 `PanelLevel`。 - 拖动猫咪时气泡用 JS 重定位有极轻微跟随延迟;完全消除需原生子窗口(大量平台代码),不值。 -- Rust 侧推送接口本版不实现,留注释;前端 `say()` 已是完整底座。 +- HTTP 接口用 `tiny_http` 单线程阻塞循环(够用),不引入 axum/tokio server 栈。 +- HTTP 改端口/开关/token 后需重启 app 生效,不做配置热重载。 +- HTTP 仅 `GET /say`,不做 REST/多路由/POST body(YAGNI,curl 一行就够)。 From e4f2037f8b8c9094d0c061165288f99853a1bc86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 17:48:18 +0800 Subject: [PATCH 05/45] =?UTF-8?q?docs:=20AI=20=E5=AF=B9=E8=AF=9D=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-29-ai-chat-bubble.md | 1503 +++++++++++++++++ 1 file changed, 1503 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-ai-chat-bubble.md diff --git a/docs/superpowers/plans/2026-06-29-ai-chat-bubble.md b/docs/superpowers/plans/2026-06-29-ai-chat-bubble.md new file mode 100644 index 000000000..e8e2297db --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-ai-chat-bubble.md @@ -0,0 +1,1503 @@ +# 猫咪头顶 AI 对话气泡 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 Live2D 猫咪头顶用一个独立透明穿透窗口展示自动消失的对话气泡,文字由前端 `say()` 或本地 HTTP 接口推送,三端通用。 + +**Architecture:** 新增一个独立的 `chat` 窗口(`/chat` 路由),它是气泡生命周期的唯一权威:监听全局 `show-chat` 事件 → 渲染文字 → 量出真实尺寸 → 动态 `setSize` 自身 → 定位到猫咪正上方 → 淡入 → 定时淡出。主窗口几乎零改动(仅 macOS 把已有的 move/resize 重发改成广播,好让 chat 收到几何变化)。配置集中在新 pinia store `ai`,通过 `@tauri-store/pinia` 跨窗口同步。进程外推送由内嵌的 `tiny_http` 本地 HTTP server 提供,复用同一个 `show-chat` 事件。 + +**Tech Stack:** Tauri 2 + Vue 3 (` + + +``` + +- [ ] **Step 4: 跑起来确认 chat 窗口已注册(不报错、main 仍正常)** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` +Expected: app 正常启动,猫咪正常显示;无 "window label chat" 相关报错。chat 窗口 `visible:false` 所以看不到,正常。确认无误后 `Ctrl-C` 退出。 + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/tauri.conf.json src/router/index.ts src/pages/chat/index.vue +git commit -m "feat(ai): register chat window, route and page skeleton" +``` + +--- + +## Task 5: chat 页面完整生命周期 + +**Files:** +- Modify: `src/pages/chat/index.vue`(全量替换 Task 4 的骨架) + +**Interfaces:** +- Consumes: `useAiStore()`(Task 1)、`computeBubblePosition`(Task 2)、`LISTEN_KEY.SHOW_CHAT` / `WINDOW_LABEL.MAIN`(Task 3)。 +- Produces: 监听 `show-chat {text, duration?}` 的完整气泡渲染/测量/定位/计时/动画。 + +- [ ] **Step 1: 全量替换 chat 页面** + +把 `src/pages/chat/index.vue` 整个替换为: + +```vue + + + + + +``` + +> 说明:`bubbleRef` 外层用 `m-3`(margin)为阴影预留空间,`getBoundingClientRect` 不含 box-shadow,靠 margin 让窗口尺寸留白,窗口透明所以留白不可见。三角用 border 画,`top-full` 贴在气泡底部正中指向猫咪。 + +- [ ] **Step 2: 手动验证基本展示(借后续 DEBUG 测试区前,先用临时招呼验证)** + +临时验证:在 `src/pages/chat/index.vue` 的 `onMounted` 末尾临时加一行 `setTimeout(() => showChat({ text: '你好呀~测试一条比较长的文字看看换行' }), 2000)`,然后: + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` +Expected: 启动 ~2 秒后,猫咪头顶冒出气泡,3 秒后淡出消失。位置在猫正上方居中。 + +确认后 **删除这行临时代码**。`Ctrl-C` 退出。 + +- [ ] **Step 3: lint** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` +Expected: 无 `chat/index.vue` 相关报错。 + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/chat/index.vue +git commit -m "feat(ai): full chat bubble lifecycle (measure, size, position, timer, fade)" +``` + +--- + +## Task 6: macOS NSPanel + move/resize 广播 + +**Files:** +- Modify: `src-tauri/src/core/setup/macos.rs` + +**Interfaces:** +- Consumes: `tauri.conf.json` 里 label 为 `chat` 的窗口(Task 4)。 +- Produces: + - chat 窗口转为 NSPanel,与猫同 level(Dock)+ 同 collection behavior,`non_activating` / `can_become_key=false`。 + - 主窗口的 `tauri://move` / `tauri://resize` 改为 **广播**(`emit`),使 chat 能收到主窗口几何变化。 + +- [ ] **Step 1: macos.rs 签名加 chat 窗口参数 + 改广播 + 建 chat panel** + +修改 `src-tauri/src/core/setup/macos.rs`。 + +(a) 改 `platform` 签名,新增 `chat_window` 参数(在 `_preference_window` 之后): + +```rust +pub fn platform( + app_handle: &AppHandle, + main_window: WebviewWindow, + _preference_window: WebviewWindow, + chat_window: WebviewWindow, +) { +``` + +(b) 把 `emit_position` 内的 `emit_to(target, ...)` 改成广播 `emit`,并把 resize 分支里给 main 的 `emit_to` 也改成广播。替换原 `fn emit_position` 与 `window_did_resize` 两处: + +原: +```rust + fn emit_position(window: &WebviewWindow) { + let target = EventTarget::labeled(MAIN_WINDOW_LABEL); + + if let Ok(position) = window.outer_position() { + let _ = window.emit_to(target, WINDOW_MOVED_EVENT, position); + } + } + + let resize_window = main_window.clone(); + handler.window_did_resize(move |_| { + emit_position(&resize_window); + + let target = EventTarget::labeled(MAIN_WINDOW_LABEL); + + if let Ok(size) = resize_window.inner_size() { + let _ = resize_window.emit_to(target, WINDOW_RESIZED_EVENT, size); + } + }); +``` + +改为: +```rust + // 广播给所有窗口(含 chat),使 chat 能跟随主窗口移动/缩放 + fn emit_position(window: &WebviewWindow) { + if let Ok(position) = window.outer_position() { + let _ = window.emit(WINDOW_MOVED_EVENT, position); + } + } + + let resize_window = main_window.clone(); + handler.window_did_resize(move |_| { + emit_position(&resize_window); + + if let Ok(size) = resize_window.inner_size() { + let _ = resize_window.emit(WINDOW_RESIZED_EVENT, size); + } + }); +``` + +> `window.emit(...)` 走 `Emitter` trait(已 `use tauri::Emitter`),广播到所有 webview。focus/blur 两处保持 `emit_to(main)` 不动(只有 main 需要)。`EventTarget` import 若变为未使用,保留即可(focus/blur 仍用到)。 + +(c) 在 `panel.set_event_handler(Some(handler.as_ref()));` 之前,新增 chat 窗口转 NSPanel 的代码: + +```rust + // chat 窗口:与猫同 level + 同 collection behavior,永不抢焦点 + if let Ok(chat_panel) = chat_window.to_panel::() { + chat_panel.set_level(PanelLevel::Dock.value()); + + chat_panel.set_style_mask(StyleMask::empty().nonactivating_panel().into()); + + chat_panel.set_collection_behavior( + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into(), + ); + } + // ponytail: 同层 order-front(chat 创建晚于 main,show 时在猫之上);若层级不准再抬高 PanelLevel +``` + +- [ ] **Step 2: setup/mod.rs 与 common.rs 传入 chat 窗口** + +修改 `src-tauri/src/core/setup/mod.rs`,`default` 签名加 `chat_window`,并透传: + +```rust +pub fn default( + app_handle: &AppHandle, + main_window: WebviewWindow, + preference_window: WebviewWindow, + chat_window: WebviewWindow, +) { + #[cfg(debug_assertions)] + main_window.open_devtools(); + + platform( + app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); +} +``` + +修改 `src-tauri/src/core/setup/common.rs`,给非 mac 平台的 `platform` 加同名参数(不使用): + +```rust +pub fn platform( + _app_handle: &AppHandle, + _main_window: WebviewWindow, + _preference_window: WebviewWindow, + _chat_window: WebviewWindow, +) { +} +``` + +- [ ] **Step 3: lib.rs 取 chat 窗口并传入 setup** + +修改 `src-tauri/src/lib.rs`,在 `let preference_window = ...` 之后、`setup::default(...)` 调用处: + +```rust + let chat_window = app.get_webview_window("chat").unwrap(); + + setup::default( + &app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat/src-tauri && cargo build` +Expected: 编译通过(warnings 可接受)。 + +- [ ] **Step 5: macOS 上手动验证层级 + 跟随** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev`(在 macOS 上) +临时在 chat 页 onMounted 加 `setTimeout(() => showChat({ text: '层级测试', duration: 0 }), 1500)`(`duration:0` 常驻便于观察),验证: +- 气泡浮在猫之上、不被裁切。 +- 拖动猫咪 → 气泡跟随(允许极轻微延迟)。 + +确认后删除临时代码,`Ctrl-C` 退出。 + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/core/setup/macos.rs src-tauri/src/core/setup/mod.rs src-tauri/src/core/setup/common.rs src-tauri/src/lib.rs +git commit -m "feat(ai): macos chat NSPanel + broadcast main move/resize to chat" +``` + +--- + +## Task 7: AI 设置 tab + i18n + +**Files:** +- Create: `src/pages/preference/components/ai/index.vue` +- Modify: `src/pages/preference/index.vue`(import + menus) +- Modify: `src/locales/zh-CN.json` / `zh-TW.json` / `en-US.json` / `vi-VN.json` / `pt-BR.json` + +**Interfaces:** +- Consumes: `useAiStore()`(Task 1)、`say()`(Task 3)、`ProList` / `ProListItem`。 +- Produces: preference 窗口新增「AI」tab,含全部配置 + DEBUG 测试区。 + +- [ ] **Step 1: 5 个语言包补 key** + +每个文件在 `pages.preference` 对象内加一个 `ai` 子对象,并在 `pages.main` 内加 `greeting`。 + +`src/locales/zh-CN.json` — `pages.main` 加 `"greeting": "你好呀~"`;`pages.preference` 加: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "气泡设置", + "enabled": "启用气泡", + "duration": "默认展示时长", + "textColor": "文字颜色", + "fontSize": "文字大小", + "bgColor": "气泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 接口", + "httpEnabled": "启用 HTTP 接口", + "httpPort": "监听端口", + "httpToken": "校验 Token", + "debug": "调试模式", + "testText": "测试文本", + "testShow": "展示" + }, + "hints": { + "enabled": "总开关,关闭后所有气泡都不显示。", + "http": "开启一个本地 HTTP 接口,供外部工具(如 curl)推送气泡。仅监听 127.0.0.1。", + "httpRestart": "改动端口/开关/Token 后需重启应用生效。", + "debug": "开启后展开下方测试区,可手动触发气泡用于验证。" + } + } +``` + +`src/locales/zh-TW.json` — `pages.main.greeting`: `"你好呀~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "氣泡設定", + "enabled": "啟用氣泡", + "duration": "預設顯示時長", + "textColor": "文字顏色", + "fontSize": "文字大小", + "bgColor": "氣泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 介面", + "httpEnabled": "啟用 HTTP 介面", + "httpPort": "監聽連接埠", + "httpToken": "驗證 Token", + "debug": "除錯模式", + "testText": "測試文字", + "testShow": "顯示" + }, + "hints": { + "enabled": "總開關,關閉後所有氣泡都不顯示。", + "http": "開啟一個本機 HTTP 介面,供外部工具(如 curl)推送氣泡。僅監聽 127.0.0.1。", + "httpRestart": "變更連接埠/開關/Token 後需重新啟動應用程式才會生效。", + "debug": "開啟後展開下方測試區,可手動觸發氣泡用於驗證。" + } + } +``` + +`src/locales/en-US.json` — `pages.main.greeting`: `"Hi there~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "Bubble settings", + "enabled": "Enable bubble", + "duration": "Default duration", + "textColor": "Text color", + "fontSize": "Font size", + "bgColor": "Bubble color", + "bgOpacity": "Background opacity", + "http": "HTTP endpoint", + "httpEnabled": "Enable HTTP endpoint", + "httpPort": "Listen port", + "httpToken": "Auth token", + "debug": "Debug mode", + "testText": "Test text", + "testShow": "Show" + }, + "hints": { + "enabled": "Master switch. When off, no bubble is shown.", + "http": "Expose a local HTTP endpoint so external tools (e.g. curl) can push bubbles. Bound to 127.0.0.1 only.", + "httpRestart": "Changing port/switch/token requires an app restart to take effect.", + "debug": "Expands the test area below for manually triggering a bubble." + } + } +``` + +`src/locales/vi-VN.json` — `pages.main.greeting`: `"Xin chào~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "Cài đặt bong bóng", + "enabled": "Bật bong bóng", + "duration": "Thời lượng mặc định", + "textColor": "Màu chữ", + "fontSize": "Cỡ chữ", + "bgColor": "Màu nền bong bóng", + "bgOpacity": "Độ trong suốt nền", + "http": "Giao diện HTTP", + "httpEnabled": "Bật giao diện HTTP", + "httpPort": "Cổng lắng nghe", + "httpToken": "Token xác thực", + "debug": "Chế độ gỡ lỗi", + "testText": "Văn bản thử", + "testShow": "Hiển thị" + }, + "hints": { + "enabled": "Công tắc tổng. Khi tắt, không bong bóng nào hiển thị.", + "http": "Mở một giao diện HTTP cục bộ để công cụ ngoài (vd: curl) đẩy bong bóng. Chỉ lắng nghe 127.0.0.1.", + "httpRestart": "Đổi cổng/công tắc/token cần khởi động lại ứng dụng để có hiệu lực.", + "debug": "Mở khu vực thử bên dưới để kích hoạt bong bóng thủ công." + } + } +``` + +`src/locales/pt-BR.json` — `pages.main.greeting`: `"Olá~"`;`pages.preference.ai`: + +```json + "ai": { + "title": "AI", + "labels": { + "basic": "Configurações do balão", + "enabled": "Ativar balão", + "duration": "Duração padrão", + "textColor": "Cor do texto", + "fontSize": "Tamanho da fonte", + "bgColor": "Cor do balão", + "bgOpacity": "Opacidade do fundo", + "http": "Endpoint HTTP", + "httpEnabled": "Ativar endpoint HTTP", + "httpPort": "Porta de escuta", + "httpToken": "Token de autenticação", + "debug": "Modo de depuração", + "testText": "Texto de teste", + "testShow": "Mostrar" + }, + "hints": { + "enabled": "Interruptor geral. Quando desligado, nenhum balão é exibido.", + "http": "Expõe um endpoint HTTP local para ferramentas externas (ex.: curl) enviarem balões. Vinculado apenas a 127.0.0.1.", + "httpRestart": "Alterar porta/interruptor/token exige reiniciar o app para ter efeito.", + "debug": "Expande a área de teste abaixo para disparar um balão manualmente." + } + } +``` + +> 校验 JSON 合法:`node -e "require('./src/locales/zh-CN.json')"`(对 5 个文件各跑一次,不报错即合法)。 + +- [ ] **Step 2: 写 AI 设置组件** + +Create `src/pages/preference/components/ai/index.vue`: + +```vue + + + +``` + +> `ColorPicker` 用 antdv-next 自带。`// ponytail`: 若该版本无 `ColorPicker` 导出,回退 ``。 + +- [ ] **Step 3: preference 页加 AI tab** + +修改 `src/pages/preference/index.vue`。import 区加: + +```ts +import Ai from './components/ai/index.vue' +``` + +在 `menus` 数组里、`shortcut` 与 `about` 之间加一项: + +```ts + { + key: 'ai', + label: t('pages.preference.ai.title'), + icon: 'i-solar:chat-round-bold', + component: Ai, + }, +``` + +- [ ] **Step 4: 跑起来验证设置页 + DEBUG 测试** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` +打开偏好设置(托盘/右键菜单)→「AI」tab: +- 改文字颜色/底色/透明度/字号控件正常。 +- 打开 DEBUG → 输入文本点「展示」→ 猫咪头顶冒出气泡。 +- 改字号后再次展示,气泡尺寸自适应、仍居中贴猫头顶。 +- 打开 HTTP 开关 → 出现端口/Token/curl 示例与「需重启」提示。 + +确认后 `Ctrl-C` 退出。 + +- [ ] **Step 5: lint** + +Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` +Expected: 无相关报错。 + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/preference/components/ai/index.vue src/pages/preference/index.vue src/locales +git commit -m "feat(ai): add AI settings tab with debug test area and i18n" +``` + +--- + +## Task 8: 模型加载后打招呼 + +**Files:** +- Modify: `src/pages/main/index.vue` + +**Interfaces:** +- Consumes: `say()`(Task 3)、`pages.main.greeting`(Task 7)。 +- Produces: 首次模型加载完成后调用一次 `say(greeting)`(受 `ai.enabled` 控制,由 chat 页判断)。 + +- [ ] **Step 1: 主页面加首次招呼** + +修改 `src/pages/main/index.vue`。 + +import 区加: + +```ts +import { say } from '@/composables/useChat' +``` + +在 ` + + diff --git a/src/router/index.ts b/src/router/index.ts index 16b281e17..125808c17 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -2,6 +2,7 @@ import type { RouteRecordRaw } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router' +import Chat from '../pages/chat/index.vue' import Main from '../pages/main/index.vue' import Preference from '../pages/preference/index.vue' @@ -14,6 +15,10 @@ const routes: Readonly = [ path: '/preference', component: Preference, }, + { + path: '/chat', + component: Chat, + }, ] const router = createRouter({ From 9b5db6e5eb5de7f222ef77989a2a7b2ce93e1107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:09:06 +0800 Subject: [PATCH 10/45] feat(ai): full chat bubble lifecycle (measure, size, position, timer, fade) --- src/pages/chat/index.vue | 171 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 6 deletions(-) diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index f4b84253d..733b873d4 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -1,15 +1,174 @@ + + From 3597ce322b4d4774a95feaffc18712bebd3656d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:14:17 +0800 Subject: [PATCH 11/45] fix(ai): clean up chat bubble listeners/timer and guard hexToRgba Collects IPC listener UnlistenFn in onMounted and calls them in onUnmounted to prevent listener leaks. Also clears pending auto-hide timer and validates hex color input in hexToRgba before parsing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/chat/index.vue | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 733b873d4..79b0a05ed 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -2,7 +2,7 @@ import { LogicalSize, PhysicalPosition } from '@tauri-apps/api/dpi' import { TauriEvent } from '@tauri-apps/api/event' import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow' -import { computed, nextTick, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useTauriListen } from '@/composables/useTauriListen' import { LISTEN_KEY, WINDOW_LABEL } from '@/constants' @@ -24,11 +24,13 @@ const text = ref('') const visible = ref(false) let timer: ReturnType | undefined +const unlisteners: Array<() => void> = [] // 气泡背景:hex + 透明度(0-100) 合成 rgba;窗口本身始终透明 function hexToRgba(hex: string, opacity: number) { const value = hex.replace('#', '') const full = value.length === 3 ? value.split('').map(c => c + c).join('') : value + if (full.length !== 6) return `rgba(0, 0, 0, ${opacity / 100})` const r = Number.parseInt(full.slice(0, 2), 16) const g = Number.parseInt(full.slice(2, 4), 16) const b = Number.parseInt(full.slice(4, 6), 16) @@ -116,16 +118,24 @@ onMounted(async () => { if (isMac) { // macOS:NSPanel 不触发原生 move/resize,由 macos.rs 广播 tauri://move / tauri://resize - appWindow.listen(TauriEvent.WINDOW_MOVED, reposition) - appWindow.listen(TauriEvent.WINDOW_RESIZED, reposition) + unlisteners.push(await appWindow.listen(TauriEvent.WINDOW_MOVED, reposition)) + unlisteners.push(await appWindow.listen(TauriEvent.WINDOW_RESIZED, reposition)) } else { // Windows/Linux:原生监听主窗口几何变化 const main = await WebviewWindow.getByLabel(WINDOW_LABEL.MAIN) - main?.onMoved(reposition) - main?.onResized(reposition) + if (main) { + unlisteners.push(await main.onMoved(reposition)) + unlisteners.push(await main.onResized(reposition)) + } } }) +// ponytail: singleton overlay window won't remount in prod, but cleanup is cheap and correct +onUnmounted(() => { + unlisteners.forEach(fn => fn()) + clearTimeout(timer) +}) + useTauriListen(LISTEN_KEY.SHOW_CHAT, ({ payload }) => { showChat(payload) }) From 7549c80a390ba6cb84b66a6229a0f1aedb82846a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:18:03 +0800 Subject: [PATCH 12/45] feat(ai): macos chat NSPanel + broadcast main move/resize to chat --- src-tauri/src/core/setup/common.rs | 1 + src-tauri/src/core/setup/macos.rs | 26 ++++++++++++++++++++------ src-tauri/src/core/setup/mod.rs | 8 +++++++- src-tauri/src/lib.rs | 9 ++++++++- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/core/setup/common.rs b/src-tauri/src/core/setup/common.rs index 6c4b072bd..91916c4c8 100644 --- a/src-tauri/src/core/setup/common.rs +++ b/src-tauri/src/core/setup/common.rs @@ -4,5 +4,6 @@ pub fn platform( _app_handle: &AppHandle, _main_window: WebviewWindow, _preference_window: WebviewWindow, + _chat_window: WebviewWindow, ) { } diff --git a/src-tauri/src/core/setup/macos.rs b/src-tauri/src/core/setup/macos.rs index d61b1d4a9..62ae87dcb 100644 --- a/src-tauri/src/core/setup/macos.rs +++ b/src-tauri/src/core/setup/macos.rs @@ -29,6 +29,7 @@ pub fn platform( app_handle: &AppHandle, main_window: WebviewWindow, _preference_window: WebviewWindow, + chat_window: WebviewWindow, ) { let _ = app_handle.plugin(tauri_nspanel::init()); @@ -64,11 +65,10 @@ pub fn platform( let _ = blur_window.emit_to(target, WINDOW_BLUR_EVENT, true); }); + // 广播给所有窗口(含 chat),使 chat 能跟随主窗口移动/缩放 fn emit_position(window: &WebviewWindow) { - let target = EventTarget::labeled(MAIN_WINDOW_LABEL); - if let Ok(position) = window.outer_position() { - let _ = window.emit_to(target, WINDOW_MOVED_EVENT, position); + let _ = window.emit(WINDOW_MOVED_EVENT, position); } } @@ -76,10 +76,8 @@ pub fn platform( handler.window_did_resize(move |_| { emit_position(&resize_window); - let target = EventTarget::labeled(MAIN_WINDOW_LABEL); - if let Ok(size) = resize_window.inner_size() { - let _ = resize_window.emit_to(target, WINDOW_RESIZED_EVENT, size); + let _ = resize_window.emit(WINDOW_RESIZED_EVENT, size); } }); @@ -88,5 +86,21 @@ pub fn platform( emit_position(&move_window); }); + // chat 窗口:与猫同 level + 同 collection behavior,永不抢焦点 + if let Ok(chat_panel) = chat_window.to_panel::() { + chat_panel.set_level(PanelLevel::Dock.value()); + + chat_panel.set_style_mask(StyleMask::empty().nonactivating_panel().into()); + + chat_panel.set_collection_behavior( + CollectionBehavior::new() + .stationary() + .move_to_active_space() + .full_screen_auxiliary() + .into(), + ); + } + // ponytail: 同层 order-front(chat 创建晚于 main,show 时在猫之上);若层级不准再抬高 PanelLevel + panel.set_event_handler(Some(handler.as_ref())); } diff --git a/src-tauri/src/core/setup/mod.rs b/src-tauri/src/core/setup/mod.rs index d761c7a81..38b0c7225 100644 --- a/src-tauri/src/core/setup/mod.rs +++ b/src-tauri/src/core/setup/mod.rs @@ -16,9 +16,15 @@ pub fn default( app_handle: &AppHandle, main_window: WebviewWindow, preference_window: WebviewWindow, + chat_window: WebviewWindow, ) { #[cfg(debug_assertions)] main_window.open_devtools(); - platform(app_handle, main_window.clone(), preference_window.clone()); + platform( + app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8fd07874..21be9556a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,7 +23,14 @@ pub fn run() { let preference_window = app.get_webview_window(PREFERENCE_WINDOW_LABEL).unwrap(); - setup::default(&app_handle, main_window.clone(), preference_window.clone()); + let chat_window = app.get_webview_window("chat").unwrap(); + + setup::default( + &app_handle, + main_window.clone(), + preference_window.clone(), + chat_window.clone(), + ); Ok(()) }) From 486f9cd603047167f5fdf7f991055b878731c846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:23:50 +0800 Subject: [PATCH 13/45] fix(ai): chat panel never becomes key (can_become_key=false) Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/core/setup/macos.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/core/setup/macos.rs b/src-tauri/src/core/setup/macos.rs index 62ae87dcb..100a650ec 100644 --- a/src-tauri/src/core/setup/macos.rs +++ b/src-tauri/src/core/setup/macos.rs @@ -17,6 +17,14 @@ tauri_panel! { } }) + panel!(NsChatPanel { + config: { + is_floating_panel: true, + can_become_key_window: false, + can_become_main_window: false + } + }) + panel_event!(NsPanelEventHandler { window_did_become_key(notification: &NSNotification) -> (), window_did_resign_key(notification: &NSNotification) -> (), @@ -87,7 +95,7 @@ pub fn platform( }); // chat 窗口:与猫同 level + 同 collection behavior,永不抢焦点 - if let Ok(chat_panel) = chat_window.to_panel::() { + if let Ok(chat_panel) = chat_window.to_panel::() { chat_panel.set_level(PanelLevel::Dock.value()); chat_panel.set_style_mask(StyleMask::empty().nonactivating_panel().into()); From 1b0a52f5ecdb9dfd6d9db60dfc4672daddd96da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:29:47 +0800 Subject: [PATCH 14/45] feat(ai): add AI settings tab with debug test area and i18n Co-Authored-By: Claude Sonnet 4.6 --- src/locales/en-US.json | 26 ++++ src/locales/pt-BR.json | 26 ++++ src/locales/vi-VN.json | 26 ++++ src/locales/zh-CN.json | 26 ++++ src/locales/zh-TW.json | 26 ++++ src/pages/preference/components/ai/index.vue | 142 +++++++++++++++++++ src/pages/preference/index.vue | 7 + 7 files changed, 279 insertions(+) create mode 100644 src/pages/preference/components/ai/index.vue diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 15172d14e..ff5830177 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "Hi there~", "hints": { "redrawing": "Redrawing...", "switching": "Switching..." @@ -129,6 +130,31 @@ "alwaysOnTop": "Toggle whether the cat window stays on top." } }, + "ai": { + "title": "AI", + "labels": { + "basic": "Bubble settings", + "enabled": "Enable bubble", + "duration": "Default duration", + "textColor": "Text color", + "fontSize": "Font size", + "bgColor": "Bubble color", + "bgOpacity": "Background opacity", + "http": "HTTP endpoint", + "httpEnabled": "Enable HTTP endpoint", + "httpPort": "Listen port", + "httpToken": "Auth token", + "debug": "Debug mode", + "testText": "Test text", + "testShow": "Show" + }, + "hints": { + "enabled": "Master switch. When off, no bubble is shown.", + "http": "Expose a local HTTP endpoint so external tools (e.g. curl) can push bubbles. Bound to 127.0.0.1 only.", + "httpRestart": "Changing port/switch/token requires an app restart to take effect.", + "debug": "Expands the test area below for manually triggering a bubble." + } + }, "about": { "title": "About", "labels": { diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index d1c10fb15..9886dbf87 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "Olá~", "hints": { "redrawing": "Redimensionando...", "switching": "Alternando..." @@ -129,6 +130,31 @@ "alwaysOnTop": "Alternar se a janela do gato permanece no topo." } }, + "ai": { + "title": "AI", + "labels": { + "basic": "Configurações do balão", + "enabled": "Ativar balão", + "duration": "Duração padrão", + "textColor": "Cor do texto", + "fontSize": "Tamanho da fonte", + "bgColor": "Cor do balão", + "bgOpacity": "Opacidade do fundo", + "http": "Endpoint HTTP", + "httpEnabled": "Ativar endpoint HTTP", + "httpPort": "Porta de escuta", + "httpToken": "Token de autenticação", + "debug": "Modo de depuração", + "testText": "Texto de teste", + "testShow": "Mostrar" + }, + "hints": { + "enabled": "Interruptor geral. Quando desligado, nenhum balão é exibido.", + "http": "Expõe um endpoint HTTP local para ferramentas externas (ex.: curl) enviarem balões. Vinculado apenas a 127.0.0.1.", + "httpRestart": "Alterar porta/interruptor/token exige reiniciar o app para ter efeito.", + "debug": "Expande a área de teste abaixo para disparar um balão manualmente." + } + }, "about": { "title": "Sobre", "labels": { diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index fd32d4e5a..df94d88ec 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "Xin chào~", "hints": { "redrawing": "Đang đổi kích thước...", "switching": "Đang chuyển đổi..." @@ -129,6 +130,31 @@ "alwaysOnTop": "Bật/Tắt luôn giữ cửa sổ mèo trên cùng." } }, + "ai": { + "title": "AI", + "labels": { + "basic": "Cài đặt bong bóng", + "enabled": "Bật bong bóng", + "duration": "Thời lượng mặc định", + "textColor": "Màu chữ", + "fontSize": "Cỡ chữ", + "bgColor": "Màu nền bong bóng", + "bgOpacity": "Độ trong suốt nền", + "http": "Giao diện HTTP", + "httpEnabled": "Bật giao diện HTTP", + "httpPort": "Cổng lắng nghe", + "httpToken": "Token xác thực", + "debug": "Chế độ gỡ lỗi", + "testText": "Văn bản thử", + "testShow": "Hiển thị" + }, + "hints": { + "enabled": "Công tắc tổng. Khi tắt, không bong bóng nào hiển thị.", + "http": "Mở một giao diện HTTP cục bộ để công cụ ngoài (vd: curl) đẩy bong bóng. Chỉ lắng nghe 127.0.0.1.", + "httpRestart": "Đổi cổng/công tắc/token cần khởi động lại ứng dụng để có hiệu lực.", + "debug": "Mở khu vực thử bên dưới để kích hoạt bong bóng thủ công." + } + }, "about": { "title": "Giới thiệu", "labels": { diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index e1a3e4dcc..9a8e6b9d5 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "你好呀~", "hints": { "redrawing": "重绘中...", "switching": "切换中..." @@ -129,6 +130,31 @@ "alwaysOnTop": "切换猫咪窗口是否置顶。" } }, + "ai": { + "title": "AI", + "labels": { + "basic": "气泡设置", + "enabled": "启用气泡", + "duration": "默认展示时长", + "textColor": "文字颜色", + "fontSize": "文字大小", + "bgColor": "气泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 接口", + "httpEnabled": "启用 HTTP 接口", + "httpPort": "监听端口", + "httpToken": "校验 Token", + "debug": "调试模式", + "testText": "测试文本", + "testShow": "展示" + }, + "hints": { + "enabled": "总开关,关闭后所有气泡都不显示。", + "http": "开启一个本地 HTTP 接口,供外部工具(如 curl)推送气泡。仅监听 127.0.0.1。", + "httpRestart": "改动端口/开关/Token 后需重启应用生效。", + "debug": "开启后展开下方测试区,可手动触发气泡用于验证。" + } + }, "about": { "title": "关于", "labels": { diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index db82dd4b9..cd5c45212 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -1,6 +1,7 @@ { "pages": { "main": { + "greeting": "你好呀~", "hints": { "redrawing": "重繪中…", "switching": "切換中…" @@ -129,6 +130,31 @@ "alwaysOnTop": "切換貓咪視窗是否置頂。" } }, + "ai": { + "title": "AI", + "labels": { + "basic": "氣泡設定", + "enabled": "啟用氣泡", + "duration": "預設顯示時長", + "textColor": "文字顏色", + "fontSize": "文字大小", + "bgColor": "氣泡底色", + "bgOpacity": "底色透明度", + "http": "HTTP 介面", + "httpEnabled": "啟用 HTTP 介面", + "httpPort": "監聽連接埠", + "httpToken": "驗證 Token", + "debug": "除錯模式", + "testText": "測試文字", + "testShow": "顯示" + }, + "hints": { + "enabled": "總開關,關閉後所有氣泡都不顯示。", + "http": "開啟一個本機 HTTP 介面,供外部工具(如 curl)推送氣泡。僅監聽 127.0.0.1。", + "httpRestart": "變更連接埠/開關/Token 後需重新啟動應用程式才會生效。", + "debug": "開啟後展開下方測試區,可手動觸發氣泡用於驗證。" + } + }, "about": { "title": "關於", "labels": { diff --git a/src/pages/preference/components/ai/index.vue b/src/pages/preference/components/ai/index.vue new file mode 100644 index 000000000..0c3f4f729 --- /dev/null +++ b/src/pages/preference/components/ai/index.vue @@ -0,0 +1,142 @@ + + + diff --git a/src/pages/preference/index.vue b/src/pages/preference/index.vue index 1520be135..c7c7dc5b7 100644 --- a/src/pages/preference/index.vue +++ b/src/pages/preference/index.vue @@ -12,6 +12,7 @@ import { useModelStore } from '@/stores/model' import { isMac } from '@/utils/platform' import About from './components/about/index.vue' +import Ai from './components/ai/index.vue' import Cat from './components/cat/index.vue' import General from './components/general/index.vue' import Model from './components/model/index.vue' @@ -54,6 +55,12 @@ const menus = computed(() => [ icon: 'i-solar:keyboard-bold', component: Shortcut, }, + { + key: 'ai', + label: t('pages.preference.ai.title'), + icon: 'i-solar:chat-round-bold', + component: Ai, + }, { key: 'about', label: t('pages.preference.about.title'), From c41064a2f392d362bebeee2804c826bf3198f9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:35:38 +0800 Subject: [PATCH 15/45] feat(ai): greet once on model ready --- src/pages/main/index.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pages/main/index.vue b/src/pages/main/index.vue index 712f59746..f61c0b71f 100644 --- a/src/pages/main/index.vue +++ b/src/pages/main/index.vue @@ -11,8 +11,10 @@ import { useDebounceFn, useEventListener } from '@vueuse/core' import { round } from 'es-toolkit' import { nth } from 'es-toolkit/compat' import { onMounted, onUnmounted, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' import { useAppMenu } from '@/composables/useAppMenu' +import { say } from '@/composables/useChat' import { useDevice } from '@/composables/useDevice' import { useGamepad } from '@/composables/useGamepad' import { useModel } from '@/composables/useModel' @@ -35,6 +37,8 @@ const catStore = useCatStore() const { getBaseMenu, getExitMenu } = useAppMenu() const modelStore = useModelStore() const generalStore = useGeneralStore() +const { t } = useI18n() +let greeted = false const resizing = ref(false) const backgroundImagePath = ref() const { stickActive } = useGamepad() @@ -84,6 +88,11 @@ watch(() => modelStore.currentModel, async (model) => { } modelStore.modelReady = true + + if (!greeted) { + greeted = true + say(t('pages.main.greeting')) + } }, { deep: true, immediate: true }) watch([() => catStore.window.scale, modelSize], async ([scale, modelSize]) => { From b3705ab8a748fad32878c3c1990ba8a588e9ded3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 18:48:08 +0800 Subject: [PATCH 16/45] feat(ai): local http push server (tiny_http) reusing show-chat Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 33 +++++++++ src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 1 + src-tauri/src/core/mod.rs | 1 + src-tauri/src/core/server.rs | 103 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + 6 files changed, 143 insertions(+) create mode 100644 src-tauri/src/core/server.rs diff --git a/Cargo.lock b/Cargo.lock index 5ff155b37..dc779dbc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -408,8 +414,10 @@ dependencies = [ name = "bongo-cat" version = "1.1.0" dependencies = [ + "form_urlencoded", "fs_extra", "gilrs", + "log", "rdev", "serde", "serde_json", @@ -433,6 +441,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tiny_http", ] [[package]] @@ -675,6 +684,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -2129,6 +2144,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -5698,6 +5719,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cfd8ea4d5..00233fae9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,9 @@ tauri-plugin-macos-permissions = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" fs_extra = "1" +tiny_http = "0.12" +form_urlencoded = "1" +log = "0.4" tauri-plugin-clipboard-manager = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-locale = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d8ee94f97..cd1fddcb4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,7 @@ "core:window:allow-set-ignore-cursor-events", "core:window:allow-set-decorations", "core:window:allow-set-position", + "core:window:allow-current-monitor", "core:window:allow-set-theme", "core:window:allow-set-title", "admin-status:default", diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 2e56f7730..623d43623 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod device; pub mod gamepad; pub mod prevent_default; +pub mod server; pub mod setup; diff --git a/src-tauri/src/core/server.rs b/src-tauri/src/core/server.rs new file mode 100644 index 000000000..00f22cede --- /dev/null +++ b/src-tauri/src/core/server.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_pinia::ManagerExt; +use tiny_http::{Method, Response, Server}; + +#[derive(Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct AiConfig { + http_enabled: bool, + http_port: u16, + http_token: String, +} + +impl Default for AiConfig { + fn default() -> Self { + Self { + http_enabled: false, + http_port: 7800, + http_token: String::new(), + } + } +} + +#[derive(Serialize, Clone)] +struct ShowChatPayload { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, +} + +// ponytail: 改端口/开关/token 后需重启 app 生效(不做热重启) +pub fn start(app_handle: &AppHandle) { + // 读持久化的 ai 配置(store id 与 key 均为 "ai");无文件时取默认(关闭) + let config: AiConfig = app_handle + .with_store("ai", |store| store.try_get_or_default::("ai")) + .unwrap_or_default(); + + if !config.http_enabled { + return; + } + + let handle = app_handle.clone(); + let addr = format!("127.0.0.1:{}", config.http_port); + let token = config.http_token; + + // ponytail: tiny_http 单线程阻塞循环,够用;不引入 axum/tokio + std::thread::spawn(move || { + let server = match Server::http(&addr) { + Ok(server) => server, + Err(err) => { + log::error!("chat http server failed to bind {addr}: {err}"); + return; + } + }; + + log::info!("chat http server listening on {addr}"); + + for request in server.incoming_requests() { + handle_request(&handle, &token, request); + } + }); +} + +fn handle_request(app_handle: &AppHandle, token: &str, request: tiny_http::Request) { + let url = request.url().to_string(); + let (path, query) = url.split_once('?').unwrap_or((url.as_str(), "")); + + // 只支持 GET /say + if request.method() != &Method::Get || path != "/say" { + let _ = request.respond(Response::from_string("not found").with_status_code(404)); + return; + } + + let params: HashMap = form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); + + // token 非空时校验 + if !token.is_empty() && params.get("token").map(String::as_str) != Some(token) { + let _ = request.respond(Response::from_string("unauthorized").with_status_code(401)); + return; + } + + let text = match params.get("text") { + Some(text) if !text.is_empty() => text.clone(), + _ => { + let _ = request.respond(Response::from_string("missing text").with_status_code(400)); + return; + } + }; + + // duration:秒 → 毫秒;没给则不带(chat 页用默认);0 表示常驻 + let duration = params + .get("duration") + .and_then(|value| value.parse::().ok()) + .map(|seconds| seconds * 1000); + + let _ = app_handle.emit("show-chat", ShowChatPayload { text, duration }); + + let _ = request.respond(Response::from_string("ok")); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21be9556a..433215b49 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,8 @@ pub fn run() { chat_window.clone(), ); + core::server::start(&app_handle); + Ok(()) }) .invoke_handler(generate_handler![ From daf2f960814037bdab27d4684949b6e6eaa0f503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Mon, 29 Jun 2026 19:00:16 +0800 Subject: [PATCH 17/45] fix(ai): grant chat window core show/hide permissions Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/capabilities/default.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cd1fddcb4..7a1b334cc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,8 @@ "core:window:allow-set-decorations", "core:window:allow-set-position", "core:window:allow-current-monitor", + "core:window:allow-show", + "core:window:allow-hide", "core:window:allow-set-theme", "core:window:allow-set-title", "admin-status:default", From 0f28e65aa248d3a6f555284cbb8d68455683f5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 09:40:30 +0800 Subject: [PATCH 18/45] fix(ai): resolve cat monitor via availableMonitors (currentMonitor is not a WebviewWindow method) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reposition() called main.currentMonitor(), but currentMonitor is a free function in @tauri-apps/api/window, not a method on WebviewWindow/Window — so every reposition threw TypeError and the bubble never positioned/showed. Find the cat's monitor by physical-bounds containment via availableMonitors() (same pattern as useWindowState). Also make App.vue's unhandledrejection handler log Error name/message/stack instead of JSON.stringify→'{}'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.vue | 10 +++++++++- src/pages/chat/index.vue | 12 ++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/App.vue b/src/App.vue index cde900642..959c32369 100644 --- a/src/App.vue +++ b/src/App.vue @@ -65,7 +65,15 @@ useTauriListen(LISTEN_KEY.HIDE_WINDOW, ({ payload }) => { }) useEventListener('unhandledrejection', ({ reason }) => { - const message = isString(reason) ? reason : JSON.stringify(reason) + let message: string + + if (isString(reason)) { + message = reason + } else if (reason instanceof Error) { + message = `${reason.name}: ${reason.message}\n${reason.stack ?? ''}` + } else { + message = JSON.stringify(reason) + } error(message) }) diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 79b0a05ed..8de74bfc6 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -2,6 +2,7 @@ import { LogicalSize, PhysicalPosition } from '@tauri-apps/api/dpi' import { TauriEvent } from '@tauri-apps/api/event' import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow' +import { availableMonitors } from '@tauri-apps/api/window' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { useTauriListen } from '@/composables/useTauriListen' @@ -67,12 +68,19 @@ async function reposition() { const main = await WebviewWindow.getByLabel(WINDOW_LABEL.MAIN) if (!main) return - const [position, size, monitor] = await Promise.all([ + const [position, size, monitors] = await Promise.all([ main.outerPosition(), main.outerSize(), - main.currentMonitor(), + availableMonitors(), ]) + // 猫所在显示器:包含猫中心点的那块屏(全物理像素比较,多屏/不同 DPI 都正确) + const centerX = position.x + size.width / 2 + const centerY = position.y + size.height / 2 + const monitor = monitors.find(({ position: p, size: s }) => { + return centerX >= p.x && centerX < p.x + s.width && centerY >= p.y && centerY < p.y + s.height + }) ?? monitors[0] + if (!monitor) return const sf = monitor.scaleFactor From 0e0171fc7f0c83681e13b3485de3c832bfc9822d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 09:54:24 +0800 Subject: [PATCH 19/45] fix(ai): bubble text renders horizontally, wraps at max width The bubble was a flex item; width:max-content does not stop flex-shrink, so in the narrow chat window it collapsed to min-content (one CJK char per line) and setSize locked that in. Switch to a measured block wrapper (w-max max-w-80 p-3): a block with width:max-content is window-independent, so short text stays on one line and long text wraps only at the 320px cap. The wrapper's padding (measured, unlike the old margin) reserves the box-shadow space. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/chat/index.vue | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 8de74bfc6..34e699238 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -56,6 +56,8 @@ async function resize() { const el = bubbleRef.value if (!el) return + // bubbleRef 是带 padding 的外层 wrapper(padding 给阴影留白,且会被 getBoundingClientRect 计入); + // wrapper 是普通 block + w-max,宽度由内容决定、与当前窗口宽度无关,不会被 flex 压缩成竖排。 const rect = el.getBoundingClientRect() await appWindow.setSize(new LogicalSize(Math.ceil(rect.width), Math.ceil(rect.height))) @@ -157,23 +159,28 @@ watch(() => aiStore.ai.fontSize, async () => { ``` -- [ ] **Step 4: 跑起来确认 chat 窗口已注册(不报错、main 仍正常)** +- [x] **Step 4: 跑起来确认 chat 窗口已注册(不报错、main 仍正常)** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` Expected: app 正常启动,猫咪正常显示;无 "window label chat" 相关报错。chat 窗口 `visible:false` 所以看不到,正常。确认无误后 `Ctrl-C` 退出。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src-tauri/tauri.conf.json src/router/index.ts src/pages/chat/index.vue @@ -463,7 +473,7 @@ git commit -m "feat(ai): register chat window, route and page skeleton" - Consumes: `useAiStore()`(Task 1)、`computeBubblePosition`(Task 2)、`LISTEN_KEY.SHOW_CHAT` / `WINDOW_LABEL.MAIN`(Task 3)。 - Produces: 监听 `show-chat {text, duration?}` 的完整气泡渲染/测量/定位/计时/动画。 -- [ ] **Step 1: 全量替换 chat 页面** +- [x] **Step 1: 全量替换 chat 页面** 把 `src/pages/chat/index.vue` 整个替换为: @@ -646,7 +656,7 @@ watch(() => aiStore.ai.fontSize, async () => { > 说明:`bubbleRef` 外层用 `m-3`(margin)为阴影预留空间,`getBoundingClientRect` 不含 box-shadow,靠 margin 让窗口尺寸留白,窗口透明所以留白不可见。三角用 border 画,`top-full` 贴在气泡底部正中指向猫咪。 -- [ ] **Step 2: 手动验证基本展示(借后续 DEBUG 测试区前,先用临时招呼验证)** +- [x] **Step 2: 手动验证基本展示(借后续 DEBUG 测试区前,先用临时招呼验证)** 临时验证:在 `src/pages/chat/index.vue` 的 `onMounted` 末尾临时加一行 `setTimeout(() => showChat({ text: '你好呀~测试一条比较长的文字看看换行' }), 2000)`,然后: @@ -655,12 +665,12 @@ Expected: 启动 ~2 秒后,猫咪头顶冒出气泡,3 秒后淡出消失。 确认后 **删除这行临时代码**。`Ctrl-C` 退出。 -- [ ] **Step 3: lint** +- [x] **Step 3: lint** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` Expected: 无 `chat/index.vue` 相关报错。 -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/pages/chat/index.vue @@ -680,7 +690,7 @@ git commit -m "feat(ai): full chat bubble lifecycle (measure, size, position, ti - chat 窗口转为 NSPanel,与猫同 level(Dock)+ 同 collection behavior,`non_activating` / `can_become_key=false`。 - 主窗口的 `tauri://move` / `tauri://resize` 改为 **广播**(`emit`),使 chat 能收到主窗口几何变化。 -- [ ] **Step 1: macos.rs 签名加 chat 窗口参数 + 改广播 + 建 chat panel** +- [x] **Step 1: macos.rs 签名加 chat 窗口参数 + 改广播 + 建 chat panel** 修改 `src-tauri/src/core/setup/macos.rs`。 @@ -760,7 +770,7 @@ pub fn platform( // ponytail: 同层 order-front(chat 创建晚于 main,show 时在猫之上);若层级不准再抬高 PanelLevel ``` -- [ ] **Step 2: setup/mod.rs 与 common.rs 传入 chat 窗口** +- [x] **Step 2: setup/mod.rs 与 common.rs 传入 chat 窗口** 修改 `src-tauri/src/core/setup/mod.rs`,`default` 签名加 `chat_window`,并透传: @@ -795,7 +805,7 @@ pub fn platform( } ``` -- [ ] **Step 3: lib.rs 取 chat 窗口并传入 setup** +- [x] **Step 3: lib.rs 取 chat 窗口并传入 setup** 修改 `src-tauri/src/lib.rs`,在 `let preference_window = ...` 之后、`setup::default(...)` 调用处: @@ -810,12 +820,12 @@ pub fn platform( ); ``` -- [ ] **Step 4: 编译验证** +- [x] **Step 4: 编译验证** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat/src-tauri && cargo build` Expected: 编译通过(warnings 可接受)。 -- [ ] **Step 5: macOS 上手动验证层级 + 跟随** +- [x] **Step 5: macOS 上手动验证层级 + 跟随** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev`(在 macOS 上) 临时在 chat 页 onMounted 加 `setTimeout(() => showChat({ text: '层级测试', duration: 0 }), 1500)`(`duration:0` 常驻便于观察),验证: @@ -824,7 +834,7 @@ Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev`(在 macOS 确认后删除临时代码,`Ctrl-C` 退出。 -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src-tauri/src/core/setup/macos.rs src-tauri/src/core/setup/mod.rs src-tauri/src/core/setup/common.rs src-tauri/src/lib.rs @@ -844,7 +854,7 @@ git commit -m "feat(ai): macos chat NSPanel + broadcast main move/resize to chat - Consumes: `useAiStore()`(Task 1)、`say()`(Task 3)、`ProList` / `ProListItem`。 - Produces: preference 窗口新增「AI」tab,含全部配置 + DEBUG 测试区。 -- [ ] **Step 1: 5 个语言包补 key** +- [x] **Step 1: 5 个语言包补 key** 每个文件在 `pages.preference` 对象内加一个 `ai` 子对象,并在 `pages.main` 内加 `greeting`。 @@ -1000,7 +1010,7 @@ git commit -m "feat(ai): macos chat NSPanel + broadcast main move/resize to chat > 校验 JSON 合法:`node -e "require('./src/locales/zh-CN.json')"`(对 5 个文件各跑一次,不报错即合法)。 -- [ ] **Step 2: 写 AI 设置组件** +- [x] **Step 2: 写 AI 设置组件** Create `src/pages/preference/components/ai/index.vue`: @@ -1151,7 +1161,7 @@ function handleTest() { > `ColorPicker` 用 antdv-next 自带。`// ponytail`: 若该版本无 `ColorPicker` 导出,回退 ``。 -- [ ] **Step 3: preference 页加 AI tab** +- [x] **Step 3: preference 页加 AI tab** 修改 `src/pages/preference/index.vue`。import 区加: @@ -1170,7 +1180,7 @@ import Ai from './components/ai/index.vue' }, ``` -- [ ] **Step 4: 跑起来验证设置页 + DEBUG 测试** +- [x] **Step 4: 跑起来验证设置页 + DEBUG 测试** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` 打开偏好设置(托盘/右键菜单)→「AI」tab: @@ -1181,12 +1191,12 @@ Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` 确认后 `Ctrl-C` 退出。 -- [ ] **Step 5: lint** +- [x] **Step 5: lint** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` Expected: 无相关报错。 -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src/pages/preference/components/ai/index.vue src/pages/preference/index.vue src/locales @@ -1204,7 +1214,7 @@ git commit -m "feat(ai): add AI settings tab with debug test area and i18n" - Consumes: `say()`(Task 3)、`pages.main.greeting`(Task 7)。 - Produces: 首次模型加载完成后调用一次 `say(greeting)`(受 `ai.enabled` 控制,由 chat 页判断)。 -- [ ] **Step 1: 主页面加首次招呼** +- [x] **Step 1: 主页面加首次招呼** 修改 `src/pages/main/index.vue`。 @@ -1243,19 +1253,19 @@ import { useI18n } from 'vue-i18n' const { t } = useI18n() ``` -- [ ] **Step 2: 跑起来验证启动即招呼** +- [x] **Step 2: 跑起来验证启动即招呼** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` Expected: app 启动、模型加载完成后,猫咪头顶自动冒出一句招呼(默认 3 秒后消失)。把 AI 设置里 `enabled` 关掉重启 → 不再招呼。 确认后 `Ctrl-C` 退出。 -- [ ] **Step 3: lint** +- [x] **Step 3: lint** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm lint` Expected: 无相关报错。 -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/pages/main/index.vue @@ -1277,7 +1287,7 @@ git commit -m "feat(ai): greet once on model ready" - Consumes: `ai` store 持久化数据(Task 1,Rust 端 `with_store("ai", |s| s.try_get_or_default::("ai"))`)、`show-chat` 事件(Task 3)。 - Produces: `core::server::start(&app_handle)`;`GET http://127.0.0.1:/say?text=&duration=&token=`。 -- [ ] **Step 1: 加 Rust 依赖** +- [x] **Step 1: 加 Rust 依赖** 修改 `src-tauri/Cargo.toml`,在 `[dependencies]` 段内(`fs_extra = "1"` 附近)加两行: @@ -1286,7 +1296,7 @@ tiny_http = "0.12" form_urlencoded = "1" ``` -- [ ] **Step 2: 写 server.rs** +- [x] **Step 2: 写 server.rs** Create `src-tauri/src/core/server.rs`: @@ -1396,7 +1406,7 @@ fn handle_request(app_handle: &AppHandle, token: &str, request: tiny_http::Reque } ``` -- [ ] **Step 3: 注册模块 + setup 启动** +- [x] **Step 3: 注册模块 + setup 启动** 修改 `src-tauri/src/core/mod.rs`,加一行: @@ -1412,7 +1422,7 @@ pub mod server; > `app_handle` 在闭包里类型为 `&AppHandle`,与 `server::start(&AppHandle)` 匹配。`core` 模块已在文件顶部 `mod core;`。 -- [ ] **Step 4: 加 currentMonitor 权限(防御性)** +- [x] **Step 4: 加 currentMonitor 权限(防御性)** 修改 `src-tauri/capabilities/default.json`,在 `permissions` 数组里 `"core:window:allow-set-position",` 之后加一行: @@ -1422,12 +1432,12 @@ pub mod server; > chat 页 `currentMonitor()` 需要。`windows: ["*"]` 已让 chat 继承全部权限。 -- [ ] **Step 5: 编译** +- [x] **Step 5: 编译** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat/src-tauri && cargo build` Expected: 编译通过。 -- [ ] **Step 6: 端到端验证 HTTP 接口** +- [x] **Step 6: 端到端验证 HTTP 接口** Run: `cd /Users/xuebaoku/GolandProjects/BongoCat && pnpm tauri dev` @@ -1454,7 +1464,7 @@ Expected: 第一/三/四条返回 `ok` 且猫头顶冒泡;第二条返回 `400 确认后 `Ctrl-C` 退出。 -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/src/core/server.rs src-tauri/src/core/mod.rs src-tauri/src/lib.rs src-tauri/capabilities/default.json From 98df3f3e745a69217f0752ea1d3f1befab36dd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 10:29:52 +0800 Subject: [PATCH 22/45] =?UTF-8?q?docs:=20=E6=B0=94=E6=B3=A1=20http=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-30-bubble-http-control-design.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-bubble-http-control-design.md diff --git a/docs/superpowers/specs/2026-06-30-bubble-http-control-design.md b/docs/superpowers/specs/2026-06-30-bubble-http-control-design.md new file mode 100644 index 000000000..e9298d7c2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-bubble-http-control-design.md @@ -0,0 +1,138 @@ +# 气泡 HTTP 接口:控制接口 + 消息接口 + +**日期:** 2026-06-30 +**状态:** 设计已批准,待实现 + +## 背景 + +气泡的 HTTP 接口已存在:`src-tauri/src/core/server.rs` 用 `tiny_http` 在 +`127.0.0.1:{httpPort}` 上监听,目前仅有 `GET /say?text=&duration=&token=`, +通过 `show-chat` 事件把文字推送到 chat 窗口。设置页 +(`src/pages/preference/components/ai/index.vue`)已有 HTTP 开关、端口、Token, +以及一行 curl 示例。 + +本设计把接口拆成两类,并补全设置页的调用说明。 + +## 目标 + +1. **消息接口**(扩展现有 `GET /say`):单条气泡可携带**临时、一次性**的样式覆盖 + (展示时长、文字颜色、文字大小、气泡底色、底色透明度),不影响已保存的设置。 +2. **控制接口**(新增 `GET /config`):修改**已保存的默认值**,持久化到磁盘, + 并实时同步到设置页 UI。 +3. **设置页调用说明**:HTTP 区块展开后内联展示两类接口的完整调用介绍。 + +## 非目标 + +- 不引入新的 HTTP 框架(继续用 `tiny_http`,仅 GET + query 参数,不解析 POST body)。 +- 不通过 HTTP 控制 `enabled` 主开关、`httpPort`/`httpToken`/`debug` 等元配置。 +- 不做鉴权之外的访问控制(仍只监听 127.0.0.1)。 + +## 接口设计 + +参数名直接复用 `aiStore.ai.*` 字段名,使「设置项 ↔ API 参数」一一对应。 + +### 消息接口 `GET /say` + +| 参数 | 必填 | 说明 | +| ----------- | ---- | ------------------------------ | +| `text` | 是 | 气泡文字(非空) | +| `token` | 否 | 服务端设置了 Token 时必填 | +| `duration` | 否 | 本条气泡展示秒数(一次性覆盖) | +| `textColor` | 否 | 文字颜色 hex(一次性覆盖) | +| `fontSize` | 否 | 文字大小 px(一次性覆盖) | +| `bgColor` | 否 | 气泡底色 hex(一次性覆盖) | +| `bgOpacity` | 否 | 底色透明度 0–100(一次性覆盖) | + +覆盖仅作用于这一条气泡,不写入保存的设置;下一条气泡若不带覆盖则回到默认值。 +成功返回 `200 OK`。 + +示例: + +``` +curl "http://127.0.0.1:7800/say?text=hi&textColor=%23ff0000&fontSize=20&duration=5" +``` + +### 控制接口 `GET /config` + +| 参数 | 必填 | 说明 | +| ----------- | ---- | ------------------------- | +| `token` | 否 | 服务端设置了 Token 时必填 | +| `duration` | 否 | 默认展示秒数 | +| `textColor` | 否 | 文字颜色 hex | +| `fontSize` | 否 | 文字大小 px | +| `bgColor` | 否 | 气泡底色 hex | +| `bgOpacity` | 否 | 底色透明度 0–100 | + +- 带任意设值参数:校验后持久化为新默认值,并实时同步到设置页;返回更新后的配置 JSON。 +- 不带任何设值参数:仅返回当前配置 JSON(用于查询/验证)。 + +示例: + +``` +curl "http://127.0.0.1:7800/config?bgColor=%23000000&bgOpacity=80" +curl "http://127.0.0.1:7800/config" # 读取当前配置 +``` + +## 数据流 + +### 消息(临时覆盖) + +``` +GET /say?text=...&textColor=...&... + → server.rs 校验参数 + → emit "show-chat" { text, duration?, textColor?, fontSize?, bgColor?, bgOpacity? } + → chat/index.vue showChat(): 把携带的覆盖存入局部 ref(每次先重置) + → bubbleStyle 取 override ?? aiStore.ai.* + → 气泡按本条覆盖渲染,设置不变 +``` + +`show-chat` 事件 payload 从 `{text, duration}` 扩展为附带可选样式覆盖字段。 + +### 控制(持久 + 同步) + +``` +GET /config?bgColor=...&... + → server.rs 校验参数 + → emit "update-config" { 部分配置 } + → chat/index.vue 监听 update-config,将值赋给 aiStore.ai.* + → saveOnChange 持久化到磁盘 + → @tauri-store/pinia 跨窗口同步,设置页实时更新 + → 返回更新后的配置 JSON +``` + +写入走「始终存活的 chat 窗口」:chat 窗口在启动时创建、仅隐藏,其 JS 持续运行, +已持有 `aiStore` 并已监听事件,因此由它统一写 store,再由 pinia 插件同步到设置页 +并落盘。不在 Rust 侧直接写 store,避免与前端为数据源的模型冲突。 + +## 校验 + +服务端在 emit 前校验,非法输入返回 `400` 加简短中文说明(当前服务仅有 404/401/200): + +- `textColor` / `bgColor`:必须是合法 hex(`#rgb` 或 `#rrggbb`)。 +- `fontSize`:8–64。 +- `bgOpacity`:0–100。 +- `duration`:≥ 0。 +- `/say` 的 `text`:必填且非空。 + +Token 校验逻辑不变:服务端设置了 Token 且请求 Token 不匹配 → `401`。 + +## 设置页调用说明 + +`src/pages/preference/components/ai/index.vue` 的 HTTP 区块在启用后, +内联展开一块说明面板,包含: + +- 两类接口(消息 / 控制)的区别(一次性 vs 持久)。 +- 参数表(名称、是否必填、含义、取值范围)。 +- 可复制的 curl 示例,端口/Token 用当前实际值插值。 + +新增 i18n 字符串挂在 `ai.http.*` 下(zh-CN 及其它已存在 locale)。 + +## 涉及文件 + +- `src-tauri/src/core/server.rs`:拆出 `/say` 与 `/config` 处理;参数校验;400 响应; + `/config` 返回配置 JSON。 +- `src/pages/chat/index.vue`:`showChat` 支持一次性样式覆盖;新增 `update-config` 监听写 store。 +- `src/pages/preference/components/ai/index.vue`:内联调用说明面板。 +- `src/constants/index.ts`:新增 `update-config` 事件 key(`LISTEN_KEY`)。 +- `src/locales/*.json`:新增 `ai.http.*` 文案。 +- 事件 payload 类型(`show-chat` 扩展、`update-config` 新增)所在的类型定义处。 From a59f73b6565943f02fdd26ac06b5604f7e95aff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 10:50:45 +0800 Subject: [PATCH 23/45] =?UTF-8?q?docs:=20=E6=B0=94=E6=B3=A1=20http=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=8E=A5=E5=8F=A3=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-30-bubble-http-control.md | 633 ++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-bubble-http-control.md diff --git a/docs/superpowers/plans/2026-06-30-bubble-http-control.md b/docs/superpowers/plans/2026-06-30-bubble-http-control.md new file mode 100644 index 000000000..51c6a4d1d --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-bubble-http-control.md @@ -0,0 +1,633 @@ +# 气泡 HTTP 控制接口 + 消息接口 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把气泡 HTTP 接口拆成「消息接口 `/say`(一次性样式覆盖)」与「控制接口 `/config`(持久写入默认值 + 设置页实时同步)」,并在设置页内联补全调用说明。 + +**Architecture:** Rust 端(`tiny_http`)按 path 路由两个 GET 接口,参数解析/校验抽成纯函数。`/say` 把临时样式随 `show-chat` 事件下发,chat 页用局部 override 渲染本条气泡且不写设置;`/config` 通过 `update-config` 事件交由「始终存活的 chat 窗口」写入 `aiStore`,由 `@tauri-store/pinia` 落盘并跨窗口同步到设置页。 + +**Tech Stack:** Tauri 2 / Rust / `tiny_http` / `serde_json`;Vue 3 + TypeScript + Pinia(`@tauri-store/pinia`)/ antdv-next。 + +## Global Constraints + +- 参数名一律复用 `aiStore.ai.*` 字段名:`duration` `textColor` `fontSize` `bgColor` `bgOpacity`(设置项 ↔ API 一一对应)。 +- 仅 GET + query 参数,不解析 POST body,不引入新 HTTP 框架/依赖。 +- 仅监听 `127.0.0.1`;`token` 非空时两个接口都校验。 +- 取值范围:`fontSize` 8–64;`bgOpacity` 0–100;`duration` ≥ 0(秒);`textColor`/`bgColor` 为 `#rgb` 或 `#rrggbb`。非法 → `400` + 简短中文说明。 +- `duration` 在 store 与 `/config` 中单位是「秒」;`/say` 下发 `show-chat` 时转毫秒(沿用 chat 页既有 `ms` 逻辑)。 +- 不通过 HTTP 改 `enabled` 主开关、`httpPort`/`httpToken`/`debug` 等元配置。 +- 本仓库无前端测试框架,**不**为本功能引入;前端用手动验证。Rust 纯函数用 `cargo test`(内置,无新依赖)。 +- 提交信息走 commitlint:`type: 描述`,subject 不要大写开头/全大写(曾因 `HTTP` 被拒)。 + +## File Structure + +- `src-tauri/src/core/server.rs` — 修改:新增 `Overrides`/`AiPublicConfig` 类型、`is_hex_color`/`parse_overrides` 纯函数 + 单测、按 path 路由 `/say`(扩展) 与 `/config`(新)。 +- `src/constants/index.ts` — 修改:`LISTEN_KEY` 新增 `UPDATE_CONFIG`。 +- `src/pages/chat/index.vue` — 修改:`ShowChatPayload` 扩展、局部 override 渲染、新增 `update-config` 监听写 store。 +- `src/pages/preference/components/ai/index.vue` — 修改:HTTP 区块内联说明面板(替换现有单行 curl)。 +- `src/locales/{zh-CN,zh-TW,en-US,vi-VN,pt-BR}.json` — 修改:`pages.preference.ai.labels.httpDocs` 与 `pages.preference.ai.hints.httpDocs`。 + +--- + +### Task 1: 后端 `/say` 扩展 + `/config` 新接口(含参数校验单测) + +**Files:** +- Modify: `src-tauri/src/core/server.rs`(整文件替换为下方实现) + +**Interfaces:** +- Produces 事件 `show-chat`,payload(camelCase,None 跳过):`{ text: string, duration?: number(ms), textColor?: string, fontSize?: number, bgColor?: string, bgOpacity?: number }` +- Produces 事件 `update-config`,payload(camelCase,None 跳过):`{ duration?: number(秒), textColor?: string, fontSize?: number, bgColor?: string, bgOpacity?: number }` +- `GET /config` 无 setter 参数时返回 store 内 `ai` 配置 JSON;带参数时返回 `{"applied": {<已应用的覆盖>}}`。 + +- [ ] **Step 1: 整文件替换 `server.rs`** + +```rust +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_pinia::ManagerExt; +use tiny_http::{Method, Response, Server}; + +#[derive(Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct AiConfig { + http_enabled: bool, + http_port: u16, + http_token: String, +} + +impl Default for AiConfig { + fn default() -> Self { + Self { + http_enabled: false, + http_port: 7800, + http_token: String::new(), + } + } +} + +// /config 无参数时回读的完整气泡配置(与 store 默认值保持一致) +#[derive(Serialize, Deserialize, Clone)] +#[serde(default, rename_all = "camelCase")] +struct AiPublicConfig { + enabled: bool, + duration: u64, + text_color: String, + font_size: u32, + bg_color: String, + bg_opacity: u32, +} + +impl Default for AiPublicConfig { + fn default() -> Self { + Self { + enabled: true, + duration: 3, + text_color: "#333".into(), + font_size: 14, + bg_color: "#fff".into(), + bg_opacity: 90, + } + } +} + +// 可选样式覆盖:/say 一次性、/config 持久共用同一组字段。 +// duration 单位为「秒」(与设置项一致);/say 发事件时再转毫秒。 +#[derive(Serialize, Clone, Default, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +struct Overrides { + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_opacity: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ShowChatPayload { + text: String, + // 毫秒;chat 页 `ms = duration ?? 默认 * 1000` + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_opacity: Option, +} + +fn is_hex_color(value: &str) -> bool { + match value.strip_prefix('#') { + Some(hex) => (hex.len() == 3 || hex.len() == 6) && hex.bytes().all(|b| b.is_ascii_hexdigit()), + None => false, + } +} + +// 解析并校验可选样式参数;任一非法返回错误说明。duration 保持「秒」。 +fn parse_overrides(params: &HashMap) -> Result { + let mut out = Overrides::default(); + + if let Some(raw) = params.get("duration") { + let value = raw + .parse::() + .map_err(|_| "duration 必须是非负整数(秒)".to_string())?; + out.duration = Some(value); + } + + if let Some(raw) = params.get("textColor") { + if !is_hex_color(raw) { + return Err("textColor 必须是 hex 颜色(如 #ff0000)".into()); + } + out.text_color = Some(raw.clone()); + } + + if let Some(raw) = params.get("fontSize") { + let value = raw + .parse::() + .map_err(|_| "fontSize 必须是整数".to_string())?; + if !(8..=64).contains(&value) { + return Err("fontSize 必须在 8–64 之间".into()); + } + out.font_size = Some(value); + } + + if let Some(raw) = params.get("bgColor") { + if !is_hex_color(raw) { + return Err("bgColor 必须是 hex 颜色(如 #ffffff)".into()); + } + out.bg_color = Some(raw.clone()); + } + + if let Some(raw) = params.get("bgOpacity") { + let value = raw + .parse::() + .map_err(|_| "bgOpacity 必须是整数".to_string())?; + if value > 100 { + return Err("bgOpacity 必须在 0–100 之间".into()); + } + out.bg_opacity = Some(value); + } + + Ok(out) +} + +// ponytail: 改端口/开关/token 后需重启 app 生效(不做热重启) +pub fn start(app_handle: &AppHandle) { + // 读持久化的 ai 配置(store id 与 key 均为 "ai");无文件时取默认(关闭) + let config: AiConfig = app_handle + .with_store("ai", |store| store.try_get_or_default::("ai")) + .unwrap_or_default(); + + if !config.http_enabled { + return; + } + + let handle = app_handle.clone(); + let addr = format!("127.0.0.1:{}", config.http_port); + let token = config.http_token; + + // ponytail: tiny_http 单线程阻塞循环,够用;不引入 axum/tokio + std::thread::spawn(move || { + let server = match Server::http(&addr) { + Ok(server) => server, + Err(err) => { + log::error!("chat http server failed to bind {addr}: {err}"); + return; + } + }; + + log::info!("chat http server listening on {addr}"); + + for request in server.incoming_requests() { + handle_request(&handle, &token, request); + } + }); +} + +fn respond(request: tiny_http::Request, status: u16, body: &str) { + let _ = request.respond(Response::from_string(body).with_status_code(status)); +} + +fn respond_json(request: tiny_http::Request, body: String) { + let header = tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("static header"); + let _ = request.respond(Response::from_string(body).with_header(header)); +} + +fn handle_request(app_handle: &AppHandle, token: &str, request: tiny_http::Request) { + let url = request.url().to_string(); + let (path, query) = url.split_once('?').unwrap_or((url.as_str(), "")); + let path = path.to_string(); + + if request.method() != &Method::Get { + return respond(request, 404, "not found"); + } + + let params: HashMap = form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); + + // token 非空时校验(两个接口共用) + if !token.is_empty() && params.get("token").map(String::as_str) != Some(token) { + return respond(request, 401, "unauthorized"); + } + + match path.as_str() { + "/say" => handle_say(app_handle, ¶ms, request), + "/config" => handle_config(app_handle, ¶ms, request), + _ => respond(request, 404, "not found"), + } +} + +fn handle_say(app_handle: &AppHandle, params: &HashMap, request: tiny_http::Request) { + let text = match params.get("text") { + Some(text) if !text.is_empty() => text.clone(), + _ => return respond(request, 400, "missing text"), + }; + + let overrides = match parse_overrides(params) { + Ok(overrides) => overrides, + Err(err) => return respond(request, 400, &err), + }; + + let payload = ShowChatPayload { + text, + duration: overrides.duration.map(|seconds| seconds * 1000), + text_color: overrides.text_color, + font_size: overrides.font_size, + bg_color: overrides.bg_color, + bg_opacity: overrides.bg_opacity, + }; + + let _ = app_handle.emit("show-chat", payload); + + respond(request, 200, "ok"); +} + +fn handle_config(app_handle: &AppHandle, params: &HashMap, request: tiny_http::Request) { + let overrides = match parse_overrides(params) { + Ok(overrides) => overrides, + Err(err) => return respond(request, 400, &err), + }; + + // 无 setter 参数 → 回读当前配置 + if overrides == Overrides::default() { + let current: AiPublicConfig = app_handle + .with_store("ai", |store| store.try_get_or_default::("ai")) + .unwrap_or_default(); + let body = serde_json::to_string(¤t).unwrap_or_else(|_| "null".into()); + return respond_json(request, body); + } + + let _ = app_handle.emit("update-config", &overrides); + + let body = serde_json::json!({ "applied": overrides }).to_string(); + respond_json(request, body); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_color_accepts_3_and_6_digits() { + assert!(is_hex_color("#fff")); + assert!(is_hex_color("#ffffff")); + assert!(is_hex_color("#FF0000")); + } + + #[test] + fn hex_color_rejects_bad_input() { + assert!(!is_hex_color("fff")); // 缺 # + assert!(!is_hex_color("#ff")); // 长度错 + assert!(!is_hex_color("#gggggg")); // 非 hex + assert!(!is_hex_color("red")); + } + + fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn parses_valid_overrides() { + let out = parse_overrides(¶ms(&[ + ("duration", "5"), + ("textColor", "#ff0000"), + ("fontSize", "20"), + ("bgColor", "#000"), + ("bgOpacity", "80"), + ])) + .unwrap(); + assert_eq!(out.duration, Some(5)); + assert_eq!(out.text_color.as_deref(), Some("#ff0000")); + assert_eq!(out.font_size, Some(20)); + assert_eq!(out.bg_color.as_deref(), Some("#000")); + assert_eq!(out.bg_opacity, Some(80)); + } + + #[test] + fn empty_params_give_default_overrides() { + assert_eq!(parse_overrides(¶ms(&[])).unwrap(), Overrides::default()); + } + + #[test] + fn rejects_out_of_range_and_bad_values() { + assert!(parse_overrides(¶ms(&[("fontSize", "4")])).is_err()); + assert!(parse_overrides(¶ms(&[("fontSize", "999")])).is_err()); + assert!(parse_overrides(¶ms(&[("bgOpacity", "101")])).is_err()); + assert!(parse_overrides(¶ms(&[("textColor", "blue")])).is_err()); + assert!(parse_overrides(¶ms(&[("duration", "-1")])).is_err()); + } +} +``` + +- [ ] **Step 2: 跑单测,确认通过** + +Run: `cd src-tauri && cargo test --lib server::tests` +Expected: PASS(6 个 test:`hex_color_accepts_3_and_6_digits`、`hex_color_rejects_bad_input`、`parses_valid_overrides`、`empty_params_give_default_overrides`、`rejects_out_of_range_and_bad_values` 等全部 ok) + +如报 `serde_json` 未引入:确认 `src-tauri/Cargo.toml` 已有 `serde_json`(tauri 传递依赖通常已含);若缺,`cargo add serde_json`。 + +- [ ] **Step 3: 编译整个 crate** + +Run: `cd src-tauri && cargo check` +Expected: 无 error(warning 可接受) + +- [ ] **Step 4: 提交** + +```bash +git add src-tauri/src/core/server.rs +git commit -m "feat(ai): split bubble http into say + config endpoints" +``` + +--- + +### Task 2: 前端 chat 页 —— 消息一次性覆盖 + `update-config` 持久同步 + +**Files:** +- Modify: `src/constants/index.ts:5-14`(`LISTEN_KEY` 加一项) +- Modify: `src/pages/chat/index.vue` + +**Interfaces:** +- Consumes 事件 `show-chat`(含 Task 1 的可选样式字段)与 `update-config`。 +- `LISTEN_KEY.UPDATE_CONFIG = 'update-config'`。 + +- [ ] **Step 1: 常量新增事件 key** + +`src/constants/index.ts`,把 `SHOW_CHAT: 'show-chat',` 一行改为两行: + +```ts + SHOW_CHAT: 'show-chat', + UPDATE_CONFIG: 'update-config', +``` + +- [ ] **Step 2: 扩展 `ShowChatPayload` 接口(chat/index.vue:14-17)** + +```ts +interface ShowChatPayload { + text: string + duration?: number + textColor?: string + fontSize?: number + bgColor?: string + bgOpacity?: number +} +``` + +- [ ] **Step 3: 加入局部 override 状态(chat/index.vue,紧接 `const visible = ref(false)` 之后)** + +```ts +// 本条气泡的一次性样式覆盖;每次 showChat 重置,不写入 aiStore(设置不变) +const override = reactive<{ + textColor?: string + fontSize?: number + bgColor?: string + bgOpacity?: number +}>({}) +``` + +并把顶部 `import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'` 补上 `reactive`: + +```ts +import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' +``` + +- [ ] **Step 4: 样式计算改为「override 优先,回落 store」(chat/index.vue:41-47)** + +```ts +const bgRgba = computed(() => hexToRgba(override.bgColor ?? aiStore.ai.bgColor, override.bgOpacity ?? aiStore.ai.bgOpacity)) + +const bubbleStyle = computed(() => ({ + color: override.textColor ?? aiStore.ai.textColor, + fontSize: `${override.fontSize ?? aiStore.ai.fontSize}px`, + background: bgRgba.value, +})) +``` + +- [ ] **Step 5: `showChat` 应用并重置 override(chat/index.vue:105-124,替换函数签名与开头)** + +把函数前半段改为: + +```ts +async function showChat({ text: nextText, duration, textColor, fontSize, bgColor, bgOpacity }: ShowChatPayload) { + // 总开关唯一生效点 + if (!aiStore.ai.enabled) return + + // 一次性覆盖:赋 undefined 即回落到 store 默认 + override.textColor = textColor + override.fontSize = fontSize + override.bgColor = bgColor + override.bgOpacity = bgOpacity + + text.value = nextText + visible.value = true + + await resize() + await reposition() + await appWindow.show() + + // 默认时长唯一兜底点;0 表示常驻 + const ms = duration ?? aiStore.ai.duration * 1000 + + clearTimeout(timer) + + if (ms > 0) { + timer = setTimeout(hide, ms) + } +} +``` + +- [ ] **Step 6: 新增 `update-config` 监听(chat/index.vue,紧接现有 `useTauriListen(...)` 之后)** + +```ts +interface UpdateConfigPayload { + duration?: number + textColor?: string + fontSize?: number + bgColor?: string + bgOpacity?: number +} + +// 控制接口:写入 aiStore 默认值,saveOnChange 落盘并跨窗口同步到设置页 +useTauriListen(LISTEN_KEY.UPDATE_CONFIG, ({ payload }) => { + const { duration, textColor, fontSize, bgColor, bgOpacity } = payload + + if (duration !== undefined) aiStore.ai.duration = duration + if (textColor !== undefined) aiStore.ai.textColor = textColor + if (fontSize !== undefined) aiStore.ai.fontSize = fontSize + if (bgColor !== undefined) aiStore.ai.bgColor = bgColor + if (bgOpacity !== undefined) aiStore.ai.bgOpacity = bgOpacity +}) +``` + +- [ ] **Step 7: lint** + +Run: `pnpm lint` +Expected: 无 error(自动 fix 后 `src/pages/chat/index.vue`、`src/constants/index.ts` 通过) + +- [ ] **Step 8: 手动验证(需先在设置页开启 HTTP,重启 app)** + +无前端测试框架,手动验证: + +1. `pnpm tauri dev` 启动;设置页 → AI → 开启「启用 HTTP 接口」,重启 app。 +2. 消息临时覆盖(设置不变): + ```bash + curl "http://127.0.0.1:7800/say?text=红色大字&textColor=%23ff0000&fontSize=28&duration=4" + ``` + 预期:气泡红色 28px 显示 4 秒;随后 + ```bash + curl "http://127.0.0.1:7800/say?text=恢复默认" + ``` + 预期:气泡回到默认颜色/字号;设置页数值未变。 +3. 控制接口持久 + 同步:打开设置页,执行 + ```bash + curl "http://127.0.0.1:7800/config?bgColor=%23000000&bgOpacity=70&fontSize=18" + ``` + 预期:设置页「气泡底色/底色透明度/文字大小」**实时**变化;返回 JSON 含 `"applied"`。重启 app 后值仍保留。 +4. 回读:`curl "http://127.0.0.1:7800/config"` 预期返回当前配置 JSON。 +5. 校验:`curl "http://127.0.0.1:7800/say?text=x&fontSize=999"` 预期 `400` + 「fontSize 必须在 8–64 之间」。 + +- [ ] **Step 9: 提交** + +```bash +git add src/constants/index.ts src/pages/chat/index.vue +git commit -m "feat(ai): apply per-message style overrides and config sync in bubble" +``` + +--- + +### Task 3: 设置页内联调用说明 + i18n + +**Files:** +- Modify: `src/pages/preference/components/ai/index.vue:106-110`(替换现有单行 curl 的 `ProListItem`) +- Modify: `src/locales/zh-CN.json` `src/locales/zh-TW.json` `src/locales/en-US.json` `src/locales/vi-VN.json` `src/locales/pt-BR.json` + +**Interfaces:** +- Consumes 已存在的 i18n 标签 `pages.preference.ai.labels.{textColor,fontSize,bgColor,bgOpacity,duration}`(说明面板复用,不新增)。 +- 新增 `pages.preference.ai.labels.httpDocs` 与 `pages.preference.ai.hints.httpDocs`。 + +- [ ] **Step 1: 5 个 locale 各加两个键** + +在每个文件的 `pages.preference.ai.labels` 末尾加 `httpDocs`,`pages.preference.ai.hints` 末尾加 `httpDocs`: + +`zh-CN.json`: +```json +"labels": { "...": "...", "httpDocs": "调用说明" }, +"hints": { "...": "...", "httpDocs": "消息接口 /say 的样式参数仅对本条气泡生效;控制接口 /config 写入默认值并持久保存。" } +``` +`zh-TW.json`: +```json +"httpDocs": "呼叫說明" +"httpDocs": "訊息介面 /say 的樣式參數僅對本條氣泡生效;控制介面 /config 寫入預設值並持久保存。" +``` +`en-US.json`: +```json +"httpDocs": "API reference" +"httpDocs": "Style params on /say apply to a single bubble only; /config writes the saved defaults." +``` +`vi-VN.json`: +```json +"httpDocs": "Hướng dẫn gọi API" +"httpDocs": "Tham số kiểu dáng của /say chỉ áp dụng cho một bong bóng; /config ghi vào giá trị mặc định đã lưu." +``` +`pt-BR.json`: +```json +"httpDocs": "Referência da API" +"httpDocs": "Os parâmetros de estilo de /say afetam apenas um balão; /config grava os padrões salvos." +``` + +(注意:实际编辑时把 `httpDocs` 作为新键追加进对应 `labels`/`hints` 对象,保留原有键不动;上面只展示新增项。) + +- [ ] **Step 2: 替换说明面板(ai/index.vue:106-110)** + +把现有 ` ... ` 整块替换为: + +```vue + + +
+
{{ $t('pages.preference.ai.labels.basic') }} · /say
+ curl "http://127.0.0.1:{{ aiStore.ai.httpPort }}/say?text=hi&textColor=%23ff0000&fontSize=20&duration=5" +
+ +
+
{{ $t('pages.preference.ai.labels.http') }} · /config
+ curl "http://127.0.0.1:{{ aiStore.ai.httpPort }}/config?bgColor=%23000000&bgOpacity=80" +
+ +
+ text · token? · duration · textColor · fontSize · bgColor · bgOpacity +
+
+
+``` + +(`Flex` 已在该文件第 2 行从 `antdv-next` 导入,无需新增 import。) + +- [ ] **Step 3: lint + 类型检查** + +Run: `pnpm lint` +Expected: 无 error。 + +Run: `node -e "['zh-CN','zh-TW','en-US','vi-VN','pt-BR'].forEach(l=>JSON.parse(require('fs').readFileSync('src/locales/'+l+'.json','utf8')))"` +Expected: 无输出(5 个 JSON 均合法,无尾逗号等语法错)。 + +- [ ] **Step 4: 手动验证** + +1. `pnpm tauri dev`,设置页 → AI → 开启 HTTP 接口。 +2. 预期 HTTP 区块下方出现「调用说明」面板:含 `/say` 与 `/config` 两条可全选复制的 curl(端口随 `httpPort` 变化)、参数名一行、一句区别说明。 +3. 切换语言(设置页语言项)→ 标题与说明文字随之切换。 + +- [ ] **Step 5: 提交** + +```bash +git add src/pages/preference/components/ai/index.vue src/locales/zh-CN.json src/locales/zh-TW.json src/locales/en-US.json src/locales/vi-VN.json src/locales/pt-BR.json +git commit -m "feat(ai): inline http api docs in settings" +``` + +--- + +## Self-Review + +- **Spec coverage:** 消息接口临时覆盖(Task 1 `/say` + Task 2 chat 渲染)✓;控制接口持久 + 实时同步(Task 1 `/config` + Task 2 `update-config` 监听)✓;5 个受控属性 duration/textColor/fontSize/bgColor/bgOpacity 全覆盖 ✓;设置页内联说明(Task 3)✓;校验 + 400(Task 1)✓;GET + 参数名复用字段名 + 不引新框架(Global Constraints)✓;回读 `/config`(Task 1)✓。 +- **Placeholder scan:** 无 TBD/TODO;所有代码块为完整实现。 +- **Type consistency:** `Overrides`/`ShowChatPayload`/`UpdateConfigPayload` 字段名与单位一致;`/say` duration 转毫秒、`/config` 与 store duration 同为秒,已在 Global Constraints 与各 payload 注释中标明;`LISTEN_KEY.UPDATE_CONFIG = 'update-config'` 与 Rust `emit("update-config")` 一致;`show-chat` 字段与 chat 页 `ShowChatPayload` 一致。 +- **已知前提:** `pinia` 插件跨窗口同步 + `saveOnChange` 落盘,依赖既有 `src/main.ts:15` 配置;chat 窗口启动即创建(仅隐藏)故其 JS 持续运行——Task 2 Step 8 的「设置页实时同步」是对该前提的实测验证点,若不同步需在该步排查插件同步行为。 From 32465d74f762d67f121281f054d8ce428e53c6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 10:55:29 +0800 Subject: [PATCH 24/45] feat(ai): split bubble http into say + config endpoints --- src-tauri/src/core/server.rs | 240 ++++++++++++++++++++++++++++++++--- 1 file changed, 222 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/core/server.rs b/src-tauri/src/core/server.rs index 00f22cede..06899b5bd 100644 --- a/src-tauri/src/core/server.rs +++ b/src-tauri/src/core/server.rs @@ -23,11 +23,118 @@ impl Default for AiConfig { } } +// /config 无参数时回读的完整气泡配置(与 store 默认值保持一致) +#[derive(Serialize, Deserialize, Clone)] +#[serde(default, rename_all = "camelCase")] +struct AiPublicConfig { + enabled: bool, + duration: u64, + text_color: String, + font_size: u32, + bg_color: String, + bg_opacity: u32, +} + +impl Default for AiPublicConfig { + fn default() -> Self { + Self { + enabled: true, + duration: 3, + text_color: "#333".into(), + font_size: 14, + bg_color: "#fff".into(), + bg_opacity: 90, + } + } +} + +// 可选样式覆盖:/say 一次性、/config 持久共用同一组字段。 +// duration 单位为「秒」(与设置项一致);/say 发事件时再转毫秒。 +#[derive(Serialize, Clone, Default, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +struct Overrides { + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_opacity: Option, +} + #[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] struct ShowChatPayload { text: String, + // 毫秒;chat 页 `ms = duration ?? 默认 * 1000` #[serde(skip_serializing_if = "Option::is_none")] duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg_opacity: Option, +} + +fn is_hex_color(value: &str) -> bool { + match value.strip_prefix('#') { + Some(hex) => (hex.len() == 3 || hex.len() == 6) && hex.bytes().all(|b| b.is_ascii_hexdigit()), + None => false, + } +} + +// 解析并校验可选样式参数;任一非法返回错误说明。duration 保持「秒」。 +fn parse_overrides(params: &HashMap) -> Result { + let mut out = Overrides::default(); + + if let Some(raw) = params.get("duration") { + let value = raw + .parse::() + .map_err(|_| "duration 必须是非负整数(秒)".to_string())?; + out.duration = Some(value); + } + + if let Some(raw) = params.get("textColor") { + if !is_hex_color(raw) { + return Err("textColor 必须是 hex 颜色(如 #ff0000)".into()); + } + out.text_color = Some(raw.clone()); + } + + if let Some(raw) = params.get("fontSize") { + let value = raw + .parse::() + .map_err(|_| "fontSize 必须是整数".to_string())?; + if !(8..=64).contains(&value) { + return Err("fontSize 必须在 8–64 之间".into()); + } + out.font_size = Some(value); + } + + if let Some(raw) = params.get("bgColor") { + if !is_hex_color(raw) { + return Err("bgColor 必须是 hex 颜色(如 #ffffff)".into()); + } + out.bg_color = Some(raw.clone()); + } + + if let Some(raw) = params.get("bgOpacity") { + let value = raw + .parse::() + .map_err(|_| "bgOpacity 必须是整数".to_string())?; + if value > 100 { + return Err("bgOpacity 必须在 0–100 之间".into()); + } + out.bg_opacity = Some(value); + } + + Ok(out) } // ponytail: 改端口/开关/token 后需重启 app 生效(不做热重启) @@ -63,41 +170,138 @@ pub fn start(app_handle: &AppHandle) { }); } +fn respond(request: tiny_http::Request, status: u16, body: &str) { + let _ = request.respond(Response::from_string(body).with_status_code(status)); +} + +fn respond_json(request: tiny_http::Request, body: String) { + let header = tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("static header"); + let _ = request.respond(Response::from_string(body).with_header(header)); +} + fn handle_request(app_handle: &AppHandle, token: &str, request: tiny_http::Request) { let url = request.url().to_string(); let (path, query) = url.split_once('?').unwrap_or((url.as_str(), "")); + let path = path.to_string(); - // 只支持 GET /say - if request.method() != &Method::Get || path != "/say" { - let _ = request.respond(Response::from_string("not found").with_status_code(404)); - return; + if request.method() != &Method::Get { + return respond(request, 404, "not found"); } let params: HashMap = form_urlencoded::parse(query.as_bytes()) .into_owned() .collect(); - // token 非空时校验 + // token 非空时校验(两个接口共用) if !token.is_empty() && params.get("token").map(String::as_str) != Some(token) { - let _ = request.respond(Response::from_string("unauthorized").with_status_code(401)); - return; + return respond(request, 401, "unauthorized"); } + match path.as_str() { + "/say" => handle_say(app_handle, ¶ms, request), + "/config" => handle_config(app_handle, ¶ms, request), + _ => respond(request, 404, "not found"), + } +} + +fn handle_say(app_handle: &AppHandle, params: &HashMap, request: tiny_http::Request) { let text = match params.get("text") { Some(text) if !text.is_empty() => text.clone(), - _ => { - let _ = request.respond(Response::from_string("missing text").with_status_code(400)); - return; - } + _ => return respond(request, 400, "missing text"), }; - // duration:秒 → 毫秒;没给则不带(chat 页用默认);0 表示常驻 - let duration = params - .get("duration") - .and_then(|value| value.parse::().ok()) - .map(|seconds| seconds * 1000); + let overrides = match parse_overrides(params) { + Ok(overrides) => overrides, + Err(err) => return respond(request, 400, &err), + }; - let _ = app_handle.emit("show-chat", ShowChatPayload { text, duration }); + let payload = ShowChatPayload { + text, + duration: overrides.duration.map(|seconds| seconds * 1000), + text_color: overrides.text_color, + font_size: overrides.font_size, + bg_color: overrides.bg_color, + bg_opacity: overrides.bg_opacity, + }; - let _ = request.respond(Response::from_string("ok")); + let _ = app_handle.emit("show-chat", payload); + + respond(request, 200, "ok"); +} + +fn handle_config(app_handle: &AppHandle, params: &HashMap, request: tiny_http::Request) { + let overrides = match parse_overrides(params) { + Ok(overrides) => overrides, + Err(err) => return respond(request, 400, &err), + }; + + // 无 setter 参数 → 回读当前配置 + if overrides == Overrides::default() { + let current: AiPublicConfig = app_handle + .with_store("ai", |store| store.try_get_or_default::("ai")) + .unwrap_or_default(); + let body = serde_json::to_string(¤t).unwrap_or_else(|_| "null".into()); + return respond_json(request, body); + } + + let _ = app_handle.emit("update-config", &overrides); + + let body = serde_json::json!({ "applied": overrides }).to_string(); + respond_json(request, body); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_color_accepts_3_and_6_digits() { + assert!(is_hex_color("#fff")); + assert!(is_hex_color("#ffffff")); + assert!(is_hex_color("#FF0000")); + } + + #[test] + fn hex_color_rejects_bad_input() { + assert!(!is_hex_color("fff")); // 缺 # + assert!(!is_hex_color("#ff")); // 长度错 + assert!(!is_hex_color("#gggggg")); // 非 hex + assert!(!is_hex_color("red")); + } + + fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn parses_valid_overrides() { + let out = parse_overrides(¶ms(&[ + ("duration", "5"), + ("textColor", "#ff0000"), + ("fontSize", "20"), + ("bgColor", "#000"), + ("bgOpacity", "80"), + ])) + .unwrap(); + assert_eq!(out.duration, Some(5)); + assert_eq!(out.text_color.as_deref(), Some("#ff0000")); + assert_eq!(out.font_size, Some(20)); + assert_eq!(out.bg_color.as_deref(), Some("#000")); + assert_eq!(out.bg_opacity, Some(80)); + } + + #[test] + fn empty_params_give_default_overrides() { + assert_eq!(parse_overrides(¶ms(&[])).unwrap(), Overrides::default()); + } + + #[test] + fn rejects_out_of_range_and_bad_values() { + assert!(parse_overrides(¶ms(&[("fontSize", "4")])).is_err()); + assert!(parse_overrides(¶ms(&[("fontSize", "999")])).is_err()); + assert!(parse_overrides(¶ms(&[("bgOpacity", "101")])).is_err()); + assert!(parse_overrides(¶ms(&[("textColor", "blue")])).is_err()); + assert!(parse_overrides(¶ms(&[("duration", "-1")])).is_err()); + } } From ae104be1eabb0f87bafb40e3a72deb4a4213c221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 11:18:08 +0800 Subject: [PATCH 25/45] feat(ai): apply per-message style overrides and config sync in bubble --- src/constants/index.ts | 1 + src/pages/chat/index.vue | 47 +++++++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/constants/index.ts b/src/constants/index.ts index df790dbc4..9d76e2357 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -11,6 +11,7 @@ export const LISTEN_KEY = { START_MOTION: 'start-motion', SET_EXPRESSION: 'set-expression', SHOW_CHAT: 'show-chat', + UPDATE_CONFIG: 'update-config', } export const INVOKE_KEY = { diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 34e699238..35df0b3ce 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -3,7 +3,7 @@ import { LogicalSize, PhysicalPosition } from '@tauri-apps/api/dpi' import { TauriEvent } from '@tauri-apps/api/event' import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow' import { availableMonitors } from '@tauri-apps/api/window' -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { useTauriListen } from '@/composables/useTauriListen' import { LISTEN_KEY, WINDOW_LABEL } from '@/constants' @@ -14,6 +14,10 @@ import { isMac } from '@/utils/platform' interface ShowChatPayload { text: string duration?: number + textColor?: string + fontSize?: number + bgColor?: string + bgOpacity?: number } const GAP = 8 // 气泡与猫的间距(逻辑像素),定位时 × scaleFactor 转物理 @@ -24,6 +28,14 @@ const bubbleRef = ref() const text = ref('') const visible = ref(false) +// 本条气泡的一次性样式覆盖;每次 showChat 重置,不写入 aiStore(设置不变) +const override = reactive<{ + textColor?: string + fontSize?: number + bgColor?: string + bgOpacity?: number +}>({}) + let timer: ReturnType | undefined const unlisteners: Array<() => void> = [] @@ -38,11 +50,11 @@ function hexToRgba(hex: string, opacity: number) { return `rgba(${r}, ${g}, ${b}, ${opacity / 100})` } -const bgRgba = computed(() => hexToRgba(aiStore.ai.bgColor, aiStore.ai.bgOpacity)) +const bgRgba = computed(() => hexToRgba(override.bgColor ?? aiStore.ai.bgColor, override.bgOpacity ?? aiStore.ai.bgOpacity)) const bubbleStyle = computed(() => ({ - color: aiStore.ai.textColor, - fontSize: `${aiStore.ai.fontSize}px`, + color: override.textColor ?? aiStore.ai.textColor, + fontSize: `${override.fontSize ?? aiStore.ai.fontSize}px`, background: bgRgba.value, })) @@ -102,10 +114,16 @@ function hide() { visible.value = false // 触发淡出;@after-leave 里再 appWindow.hide() } -async function showChat({ text: nextText, duration }: ShowChatPayload) { +async function showChat({ text: nextText, duration, textColor, fontSize, bgColor, bgOpacity }: ShowChatPayload) { // 总开关唯一生效点 if (!aiStore.ai.enabled) return + // 一次性覆盖:赋 undefined 即回落到 store 默认 + override.textColor = textColor + override.fontSize = fontSize + override.bgColor = bgColor + override.bgOpacity = bgOpacity + text.value = nextText visible.value = true @@ -150,6 +168,25 @@ useTauriListen(LISTEN_KEY.SHOW_CHAT, ({ payload }) => { showChat(payload) }) +interface UpdateConfigPayload { + duration?: number + textColor?: string + fontSize?: number + bgColor?: string + bgOpacity?: number +} + +// 控制接口:写入 aiStore 默认值,saveOnChange 落盘并跨窗口同步到设置页 +useTauriListen(LISTEN_KEY.UPDATE_CONFIG, ({ payload }) => { + const { duration, textColor, fontSize, bgColor, bgOpacity } = payload + + if (duration !== undefined) aiStore.ai.duration = duration + if (textColor !== undefined) aiStore.ai.textColor = textColor + if (fontSize !== undefined) aiStore.ai.fontSize = fontSize + if (bgColor !== undefined) aiStore.ai.bgColor = bgColor + if (bgOpacity !== undefined) aiStore.ai.bgOpacity = bgOpacity +}) + // 字号改变会改变气泡尺寸:可见时重新测量并定位 watch(() => aiStore.ai.fontSize, async () => { if (!visible.value) return From 36aabf1d5de9d3d4aa4f688043161a6a0fcd9a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 11:24:32 +0800 Subject: [PATCH 26/45] feat(ai): inline http api docs in settings Co-Authored-By: Claude Sonnet 4.6 --- src/locales/en-US.json | 6 ++-- src/locales/pt-BR.json | 6 ++-- src/locales/vi-VN.json | 6 ++-- src/locales/zh-CN.json | 6 ++-- src/locales/zh-TW.json | 6 ++-- src/pages/preference/components/ai/index.vue | 32 +++++++++++++++++--- 6 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/locales/en-US.json b/src/locales/en-US.json index ff5830177..d60823117 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -146,13 +146,15 @@ "httpToken": "Auth token", "debug": "Debug mode", "testText": "Test text", - "testShow": "Show" + "testShow": "Show", + "httpDocs": "API reference" }, "hints": { "enabled": "Master switch. When off, no bubble is shown.", "http": "Expose a local HTTP endpoint so external tools (e.g. curl) can push bubbles. Bound to 127.0.0.1 only.", "httpRestart": "Changing port/switch/token requires an app restart to take effect.", - "debug": "Expands the test area below for manually triggering a bubble." + "debug": "Expands the test area below for manually triggering a bubble.", + "httpDocs": "Style params on /say apply to a single bubble only; /config writes the saved defaults." } }, "about": { diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index 9886dbf87..b62f497e1 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -146,13 +146,15 @@ "httpToken": "Token de autenticação", "debug": "Modo de depuração", "testText": "Texto de teste", - "testShow": "Mostrar" + "testShow": "Mostrar", + "httpDocs": "Referência da API" }, "hints": { "enabled": "Interruptor geral. Quando desligado, nenhum balão é exibido.", "http": "Expõe um endpoint HTTP local para ferramentas externas (ex.: curl) enviarem balões. Vinculado apenas a 127.0.0.1.", "httpRestart": "Alterar porta/interruptor/token exige reiniciar o app para ter efeito.", - "debug": "Expande a área de teste abaixo para disparar um balão manualmente." + "debug": "Expande a área de teste abaixo para disparar um balão manualmente.", + "httpDocs": "Os parâmetros de estilo de /say afetam apenas um balão; /config grava os padrões salvos." } }, "about": { diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index df94d88ec..95012fe82 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -146,13 +146,15 @@ "httpToken": "Token xác thực", "debug": "Chế độ gỡ lỗi", "testText": "Văn bản thử", - "testShow": "Hiển thị" + "testShow": "Hiển thị", + "httpDocs": "Hướng dẫn gọi API" }, "hints": { "enabled": "Công tắc tổng. Khi tắt, không bong bóng nào hiển thị.", "http": "Mở một giao diện HTTP cục bộ để công cụ ngoài (vd: curl) đẩy bong bóng. Chỉ lắng nghe 127.0.0.1.", "httpRestart": "Đổi cổng/công tắc/token cần khởi động lại ứng dụng để có hiệu lực.", - "debug": "Mở khu vực thử bên dưới để kích hoạt bong bóng thủ công." + "debug": "Mở khu vực thử bên dưới để kích hoạt bong bóng thủ công.", + "httpDocs": "Tham số kiểu dáng của /say chỉ áp dụng cho một bong bóng; /config ghi vào giá trị mặc định đã lưu." } }, "about": { diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 9a8e6b9d5..770dd632f 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -146,13 +146,15 @@ "httpToken": "校验 Token", "debug": "调试模式", "testText": "测试文本", - "testShow": "展示" + "testShow": "展示", + "httpDocs": "调用说明" }, "hints": { "enabled": "总开关,关闭后所有气泡都不显示。", "http": "开启一个本地 HTTP 接口,供外部工具(如 curl)推送气泡。仅监听 127.0.0.1。", "httpRestart": "改动端口/开关/Token 后需重启应用生效。", - "debug": "开启后展开下方测试区,可手动触发气泡用于验证。" + "debug": "开启后展开下方测试区,可手动触发气泡用于验证。", + "httpDocs": "消息接口 /say 的样式参数仅对本条气泡生效;控制接口 /config 写入默认值并持久保存。" } }, "about": { diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index cd5c45212..0cf6fb196 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -146,13 +146,15 @@ "httpToken": "驗證 Token", "debug": "除錯模式", "testText": "測試文字", - "testShow": "顯示" + "testShow": "顯示", + "httpDocs": "呼叫說明" }, "hints": { "enabled": "總開關,關閉後所有氣泡都不顯示。", "http": "開啟一個本機 HTTP 介面,供外部工具(如 curl)推送氣泡。僅監聽 127.0.0.1。", "httpRestart": "變更連接埠/開關/Token 後需重新啟動應用程式才會生效。", - "debug": "開啟後展開下方測試區,可手動觸發氣泡用於驗證。" + "debug": "開啟後展開下方測試區,可手動觸發氣泡用於驗證。", + "httpDocs": "訊息介面 /say 的樣式參數僅對本條氣泡生效;控制介面 /config 寫入預設值並持久保存。" } }, "about": { diff --git a/src/pages/preference/components/ai/index.vue b/src/pages/preference/components/ai/index.vue index 0c3f4f729..8b7ddf948 100644 --- a/src/pages/preference/components/ai/index.vue +++ b/src/pages/preference/components/ai/index.vue @@ -103,10 +103,34 @@ function handleTest() { /> - - - curl "http://127.0.0.1:{{ aiStore.ai.httpPort }}/say?text=hi" - + + +
+
+ {{ $t('pages.preference.ai.labels.basic') }} · /say +
+ curl "http://127.0.0.1:{{ aiStore.ai.httpPort }}/say?text=hi&textColor=%23ff0000&fontSize=20&duration=5" +
+ +
+
+ {{ $t('pages.preference.ai.labels.http') }} · /config +
+ curl "http://127.0.0.1:{{ aiStore.ai.httpPort }}/config?bgColor=%23000000&bgOpacity=80" +
+ +
+ text · token? · duration · textColor · fontSize · bgColor · bgOpacity +
+
From 781308d1ec650b681555618ba34a6f4be69d3841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 11:36:22 +0800 Subject: [PATCH 27/45] fix(ai): guard /config success on chat window presence Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/core/server.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/core/server.rs b/src-tauri/src/core/server.rs index 06899b5bd..8fed71295 100644 --- a/src-tauri/src/core/server.rs +++ b/src-tauri/src/core/server.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_pinia::ManagerExt; use tiny_http::{Method, Response, Server}; @@ -35,6 +35,7 @@ struct AiPublicConfig { bg_opacity: u32, } +// keep in sync with src/stores/ai.ts defaults impl Default for AiPublicConfig { fn default() -> Self { Self { @@ -245,6 +246,10 @@ fn handle_config(app_handle: &AppHandle, params: &HashMap, reque return respond_json(request, body); } + if app_handle.get_webview_window("chat").is_none() { + return respond(request, 503, "chat window not ready"); + } + let _ = app_handle.emit("update-config", &overrides); let body = serde_json::json!({ "applied": overrides }).to_string(); From a7c9691f5001b6db15b43b22db562cdd2c1fc0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 11:41:53 +0800 Subject: [PATCH 28/45] chore(ai): add bubble http manual test script Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/bubble-http.sh | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100755 scripts/bubble-http.sh diff --git a/scripts/bubble-http.sh b/scripts/bubble-http.sh new file mode 100755 index 000000000..f8c763b24 --- /dev/null +++ b/scripts/bubble-http.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# +# 手工模拟气泡 HTTP 接口请求。 +# 消息接口 /say :推一条气泡,可带「一次性」样式覆盖,不改保存的设置。 +# 控制接口 /config:写入「持久」默认值(实时同步设置页),无参数时回读当前配置。 +# +# 前置:在「设置 → AI」开启「启用 HTTP 接口」,改动端口/Token 后需重启 app。 +# +# 用法: +# [BUBBLE_PORT=7800] [BUBBLE_TOKEN=xxx] ./scripts/bubble-http.sh <命令> [参数...] +# +# 命令: +# say [文本] 推一条默认样式气泡(默认「你好呀~」) +# say-styled [文本] 推一条带一次性样式覆盖的气泡(红字/大号/深色底/4s) +# set k=v [k=v...] /config 写默认值,例:set bgColor=#000000 bgOpacity=80 fontSize=18 +# get /config 回读当前配置(JSON) +# bad 故意越界(fontSize=999),预期 400 +# demo 依次跑:say → set → get → say(看持久生效)→ bad +# +# 颜色里的 # 不用手动转义,脚本用 curl --data-urlencode 处理。 +set -euo pipefail + +PORT="${BUBBLE_PORT:-7800}" +TOKEN="${BUBBLE_TOKEN:-}" +BASE="http://127.0.0.1:${PORT}" + +# req [k=v ...]:GET 请求,自动 URL 编码参数并附带 token(若设置),打印响应体 + 状态码 +req() { + local path="$1"; shift + local args=(-sS -G "${BASE}${path}" -w $'\n[HTTP %{http_code}]\n') + local kv + for kv in "$@"; do + args+=(--data-urlencode "$kv") + done + [ -n "$TOKEN" ] && args+=(--data-urlencode "token=${TOKEN}") + curl "${args[@]}" +} + +usage() { + sed -n '2,27p' "$0" +} + +cmd="${1:-help}" +shift || true + +case "$cmd" in + say) + req /say "text=${1:-你好呀~}" + ;; + say-styled) + req /say "text=${1:-红色大字}" "textColor=#ff0000" "fontSize=28" "bgColor=#000000" "bgOpacity=70" "duration=4" + ;; + set) + [ "$#" -gt 0 ] || { echo "用法:set k=v [k=v...],例:set bgColor=#000000 bgOpacity=80" >&2; exit 2; } + req /config "$@" + ;; + get) + req /config + ;; + bad) + req /say "text=x" "fontSize=999" + ;; + demo) + echo "== 1. /say 默认样式 ==" + req /say "text=demo 默认" + echo "== 2. /config 写默认值(底色黑、透明度 80、字号 18)==" + req /config "bgColor=#000000" "bgOpacity=80" "fontSize=18" + echo "== 3. /config 回读 ==" + req /config + echo "== 4. /say 不带样式,应使用刚写入的新默认值 ==" + req /say "text=demo 持久生效" + echo "== 5. 越界参数,预期 400 ==" + req /say "text=x" "fontSize=999" || true + ;; + help|-h|--help) + usage + ;; + *) + echo "未知命令:$cmd" >&2 + usage >&2 + exit 2 + ;; +esac From 30bb4e37f90f840121f8e8d6cf64ed3effce2866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=9D=E5=BA=93=EF=BC=88baoku=EF=BC=89?= Date: Tue, 30 Jun 2026 13:36:48 +0800 Subject: [PATCH 29/45] refactor(chat): rename ai module to chat and default bubble bg to #ffffff Co-Authored-By: Claude Opus 4.8 --- src/App.vue | 6 +- src/locales/en-US.json | 4 +- src/locales/pt-BR.json | 4 +- src/locales/vi-VN.json | 4 +- src/locales/zh-CN.json | 4 +- src/locales/zh-TW.json | 4 +- src/pages/chat/index.vue | 30 +++---- .../components/{ai => chat}/index.vue | 84 ++++++++++--------- src/pages/preference/index.vue | 8 +- src/stores/ai.ts | 36 -------- src/stores/chat.ts | 55 ++++++++++++ 11 files changed, 132 insertions(+), 107 deletions(-) rename src/pages/preference/components/{ai => chat}/index.vue (50%) delete mode 100644 src/stores/ai.ts create mode 100644 src/stores/chat.ts diff --git a/src/App.vue b/src/App.vue index 959c32369..9ac499d9b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,9 +16,9 @@ import { useWindowState } from './composables/useWindowState' import { LANGUAGE, LISTEN_KEY } from './constants' import { getAntdLocale } from './locales/index.ts' import { hideWindow, showWindow } from './plugins/window' -import { useAiStore } from './stores/ai' import { useAppStore } from './stores/app' import { useCatStore } from './stores/cat' +import { useChatStore } from './stores/chat' import { useGeneralStore } from './stores/general' import { useModelStore } from './stores/model' import { useShortcutStore } from './stores/shortcut.ts' @@ -28,7 +28,7 @@ const modelStore = useModelStore() const catStore = useCatStore() const generalStore = useGeneralStore() const shortcutStore = useShortcutStore() -const aiStore = useAiStore() +const chatStore = useChatStore() const appWindow = getCurrentWebviewWindow() const { isRestored, restoreState } = useWindowState() const { darkAlgorithm, defaultAlgorithm } = theme @@ -44,7 +44,7 @@ onMounted(async () => { await generalStore.$tauri.start() await generalStore.init() await shortcutStore.$tauri.start() - await aiStore.$tauri.start() + await chatStore.$tauri.start() await restoreState() }) diff --git a/src/locales/en-US.json b/src/locales/en-US.json index d60823117..6254708e3 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -130,8 +130,8 @@ "alwaysOnTop": "Toggle whether the cat window stays on top." } }, - "ai": { - "title": "AI", + "chat": { + "title": "Chat", "labels": { "basic": "Bubble settings", "enabled": "Enable bubble", diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index b62f497e1..f6c43e5c1 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -130,8 +130,8 @@ "alwaysOnTop": "Alternar se a janela do gato permanece no topo." } }, - "ai": { - "title": "AI", + "chat": { + "title": "Chat", "labels": { "basic": "Configurações do balão", "enabled": "Ativar balão", diff --git a/src/locales/vi-VN.json b/src/locales/vi-VN.json index 95012fe82..bf8f760bd 100644 --- a/src/locales/vi-VN.json +++ b/src/locales/vi-VN.json @@ -130,8 +130,8 @@ "alwaysOnTop": "Bật/Tắt luôn giữ cửa sổ mèo trên cùng." } }, - "ai": { - "title": "AI", + "chat": { + "title": "Chat", "labels": { "basic": "Cài đặt bong bóng", "enabled": "Bật bong bóng", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 770dd632f..ea3799a4b 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -130,8 +130,8 @@ "alwaysOnTop": "切换猫咪窗口是否置顶。" } }, - "ai": { - "title": "AI", + "chat": { + "title": "Chat", "labels": { "basic": "气泡设置", "enabled": "启用气泡", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 0cf6fb196..344eef5a5 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -130,8 +130,8 @@ "alwaysOnTop": "切換貓咪視窗是否置頂。" } }, - "ai": { - "title": "AI", + "chat": { + "title": "Chat", "labels": { "basic": "氣泡設定", "enabled": "啟用氣泡", diff --git a/src/pages/chat/index.vue b/src/pages/chat/index.vue index 35df0b3ce..61d87b5b3 100644 --- a/src/pages/chat/index.vue +++ b/src/pages/chat/index.vue @@ -7,7 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from import { useTauriListen } from '@/composables/useTauriListen' import { LISTEN_KEY, WINDOW_LABEL } from '@/constants' -import { useAiStore } from '@/stores/ai' +import { useChatStore } from '@/stores/chat' import { computeBubblePosition } from '@/utils/chatPosition' import { isMac } from '@/utils/platform' @@ -23,12 +23,12 @@ interface ShowChatPayload { const GAP = 8 // 气泡与猫的间距(逻辑像素),定位时 × scaleFactor 转物理 const appWindow = getCurrentWebviewWindow() -const aiStore = useAiStore() +const chatStore = useChatStore() const bubbleRef = ref() const text = ref('') const visible = ref(false) -// 本条气泡的一次性样式覆盖;每次 showChat 重置,不写入 aiStore(设置不变) +// 本条气泡的一次性样式覆盖;每次 showChat 重置,不写入 chatStore(设置不变) const override = reactive<{ textColor?: string fontSize?: number @@ -50,11 +50,11 @@ function hexToRgba(hex: string, opacity: number) { return `rgba(${r}, ${g}, ${b}, ${opacity / 100})` } -const bgRgba = computed(() => hexToRgba(override.bgColor ?? aiStore.ai.bgColor, override.bgOpacity ?? aiStore.ai.bgOpacity)) +const bgRgba = computed(() => hexToRgba(override.bgColor ?? chatStore.ai.bgColor, override.bgOpacity ?? chatStore.ai.bgOpacity)) const bubbleStyle = computed(() => ({ - color: override.textColor ?? aiStore.ai.textColor, - fontSize: `${override.fontSize ?? aiStore.ai.fontSize}px`, + color: override.textColor ?? chatStore.ai.textColor, + fontSize: `${override.fontSize ?? chatStore.ai.fontSize}px`, background: bgRgba.value, })) @@ -116,7 +116,7 @@ function hide() { async function showChat({ text: nextText, duration, textColor, fontSize, bgColor, bgOpacity }: ShowChatPayload) { // 总开关唯一生效点 - if (!aiStore.ai.enabled) return + if (!chatStore.ai.enabled) return // 一次性覆盖:赋 undefined 即回落到 store 默认 override.textColor = textColor @@ -132,7 +132,7 @@ async function showChat({ text: nextText, duration, textColor, fontSize, bgColor await appWindow.show() // 默认时长唯一兜底点;0 表示常驻 - const ms = duration ?? aiStore.ai.duration * 1000 + const ms = duration ?? chatStore.ai.duration * 1000 clearTimeout(timer) @@ -176,19 +176,19 @@ interface UpdateConfigPayload { bgOpacity?: number } -// 控制接口:写入 aiStore 默认值,saveOnChange 落盘并跨窗口同步到设置页 +// 控制接口:写入 chatStore 默认值,saveOnChange 落盘并跨窗口同步到设置页 useTauriListen(LISTEN_KEY.UPDATE_CONFIG, ({ payload }) => { const { duration, textColor, fontSize, bgColor, bgOpacity } = payload - if (duration !== undefined) aiStore.ai.duration = duration - if (textColor !== undefined) aiStore.ai.textColor = textColor - if (fontSize !== undefined) aiStore.ai.fontSize = fontSize - if (bgColor !== undefined) aiStore.ai.bgColor = bgColor - if (bgOpacity !== undefined) aiStore.ai.bgOpacity = bgOpacity + if (duration !== undefined) chatStore.ai.duration = duration + if (textColor !== undefined) chatStore.ai.textColor = textColor + if (fontSize !== undefined) chatStore.ai.fontSize = fontSize + if (bgColor !== undefined) chatStore.ai.bgColor = bgColor + if (bgOpacity !== undefined) chatStore.ai.bgOpacity = bgOpacity }) // 字号改变会改变气泡尺寸:可见时重新测量并定位 -watch(() => aiStore.ai.fontSize, async () => { +watch(() => chatStore.ai.fontSize, async () => { if (!visible.value) return await resize() await reposition() diff --git a/src/pages/preference/components/ai/index.vue b/src/pages/preference/components/chat/index.vue similarity index 50% rename from src/pages/preference/components/ai/index.vue rename to src/pages/preference/components/chat/index.vue index 8b7ddf948..38463536d 100644 --- a/src/pages/preference/components/ai/index.vue +++ b/src/pages/preference/components/chat/index.vue @@ -5,9 +5,9 @@ import { ref } from 'vue' import ProListItem from '@/components/pro-list-item/index.vue' import ProList from '@/components/pro-list/index.vue' import { say } from '@/composables/useChat' -import { useAiStore } from '@/stores/ai' +import { useChatStore } from '@/stores/chat' -const aiStore = useAiStore() +const chatStore = useChatStore() const testText = ref('你好呀~') function handleTest() { @@ -16,18 +16,18 @@ function handleTest() {