From 44bed99bb4405a031ffab4bed9e9131b7dafba5d Mon Sep 17 00:00:00 2001 From: kurihada Date: Tue, 30 Dec 2025 15:35:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E6=B7=BB=E5=8A=A0=E6=82=AC?= =?UTF-8?q?=E6=B5=AE=E7=90=83=20Quick=20Ask=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现浅灰色玻璃拟态悬浮球,带机器人头图标 - 支持点击展开对话框,淡入淡出动画 - 支持窗口拖拽,区分点击和拖拽操作 - macOS 透明窗口支持 (macOSPrivateApi) - 悬浮球 hover 放大效果,不溢出窗口 - 添加系统托盘 Toggle Quick Ask 菜单 --- packages/desktop/floating.html | 25 ++ packages/desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/capabilities/default.json | 9 + packages/desktop/src-tauri/src/commands.rs | 68 ++++ packages/desktop/src-tauri/src/lib.rs | 20 +- packages/desktop/src-tauri/tauri.conf.json | 20 +- packages/desktop/src/floating.tsx | 25 ++ packages/desktop/src/pages/FloatingChat.tsx | 375 ++++++++++++++++++ packages/desktop/src/styles/floating.css | 18 + packages/desktop/vite.config.ts | 7 + 10 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 packages/desktop/floating.html create mode 100644 packages/desktop/src/floating.tsx create mode 100644 packages/desktop/src/pages/FloatingChat.tsx create mode 100644 packages/desktop/src/styles/floating.css diff --git a/packages/desktop/floating.html b/packages/desktop/floating.html new file mode 100644 index 0000000..e56c2bd --- /dev/null +++ b/packages/desktop/floating.html @@ -0,0 +1,25 @@ + + + + + + Quick Ask + + + +
+ + + diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 0f1b55d..595629a 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } tauri-plugin-shell = "2" tauri-plugin-fs = "2" tauri-plugin-dialog = "2" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index eadf4a1..8670617 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,15 @@ "windows": ["*"], "permissions": [ "core:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-close", + "core:window:allow-set-focus", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-start-dragging", + "core:window:allow-is-visible", + "core:webview:allow-create-webview-window", "shell:default", "fs:default", "dialog:default", diff --git a/packages/desktop/src-tauri/src/commands.rs b/packages/desktop/src-tauri/src/commands.rs index 87d0874..4f95260 100644 --- a/packages/desktop/src-tauri/src/commands.rs +++ b/packages/desktop/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use serde::Serialize; use std::fs; use std::path::PathBuf; use std::sync::mpsc; +use tauri::Manager; use tauri_plugin_dialog::DialogExt; #[derive(Serialize)] @@ -116,3 +117,70 @@ pub async fn list_directory(path: String) -> Result, String> Ok(entries) } + +// 悬浮窗口控制命令 + +#[tauri::command] +pub async fn toggle_floating_window(app: tauri::AppHandle) -> Result { + if let Some(window) = app.get_webview_window("floating") { + let is_visible = window.is_visible().map_err(|e| e.to_string())?; + if is_visible { + window.hide().map_err(|e| e.to_string())?; + Ok(false) + } else { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + Ok(true) + } + } else { + Err("Floating window not found".to_string()) + } +} + +#[tauri::command] +pub async fn show_floating_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("floating") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("Floating window not found".to_string()) + } +} + +#[tauri::command] +pub async fn hide_floating_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("floating") { + window.hide().map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("Floating window not found".to_string()) + } +} + +#[tauri::command] +pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("Main window not found".to_string()) + } +} + +#[tauri::command] +pub async fn set_floating_window_size( + app: tauri::AppHandle, + width: f64, + height: f64, +) -> Result<(), String> { + if let Some(window) = app.get_webview_window("floating") { + window + .set_size(tauri::Size::Logical(tauri::LogicalSize { width, height })) + .map_err(|e| e.to_string())?; + Ok(()) + } else { + Err("Floating window not found".to_string()) + } +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 2b10df3..94d138b 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -21,16 +21,22 @@ pub fn run() { commands::open_directory_dialog, commands::read_local_file, commands::list_directory, + commands::toggle_floating_window, + commands::show_floating_window, + commands::hide_floating_window, + commands::show_main_window, + commands::set_floating_window_size, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } fn setup_tray(app: &tauri::App) -> Result<(), Box> { - let show_item = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?; + let show_item = MenuItem::with_id(app, "show", "Show Main Window", true, None::<&str>)?; + let floating_item = MenuItem::with_id(app, "floating", "Toggle Quick Ask", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show_item, &quit_item])?; + let menu = Menu::with_items(app, &[&show_item, &floating_item, &quit_item])?; let _tray = TrayIconBuilder::new() .menu(&menu) @@ -42,6 +48,16 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box { + if let Some(window) = app.get_webview_window("floating") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } + } "quit" => { app.exit(0); } diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 69250fe..8a67572 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -11,8 +11,10 @@ }, "app": { "withGlobalTauri": true, + "macOSPrivateApi": true, "windows": [ { + "label": "main", "title": "AI Assistant", "width": 1200, "height": 800, @@ -21,13 +23,29 @@ "resizable": true, "fullscreen": false, "center": true + }, + { + "label": "floating", + "title": "", + "url": "/floating.html", + "width": 60, + "height": 60, + "minWidth": 60, + "minHeight": 60, + "resizable": false, + "decorations": false, + "transparent": true, + "shadow": false, + "alwaysOnTop": true, + "visible": true, + "skipTaskbar": true } ], "security": { "csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" }, "trayIcon": { - "iconPath": "icons/icon.png", + "iconPath": "icons/32x32.png", "iconAsTemplate": true } }, diff --git a/packages/desktop/src/floating.tsx b/packages/desktop/src/floating.tsx new file mode 100644 index 0000000..dc737be --- /dev/null +++ b/packages/desktop/src/floating.tsx @@ -0,0 +1,25 @@ +/** + * Floating Window Entry Point + * 悬浮窗口入口 + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { configureApiClient } from '@ai-assistant/ui'; +import { FloatingChat } from './pages/FloatingChat'; +import '@ai-assistant/ui/styles'; +// 悬浮窗使用专用样式,确保背景透明(放在最后以覆盖其他样式) +import './styles/floating.css'; + +// 配置 API 客户端 +configureApiClient({ + baseUrl: 'http://localhost:3000/api', + wsBaseUrl: 'ws://localhost:3000/api', + healthUrl: 'http://localhost:3000/health', +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/packages/desktop/src/pages/FloatingChat.tsx b/packages/desktop/src/pages/FloatingChat.tsx new file mode 100644 index 0000000..3fd5db9 --- /dev/null +++ b/packages/desktop/src/pages/FloatingChat.tsx @@ -0,0 +1,375 @@ +/** + * Floating Chat Window + * 圆形悬浮球,点击展开对话框 + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { Send, X, Maximize2, Loader2, ChevronDown } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + useChat, + ChatMessage, + ThemeProvider, + Toaster, +} from '@ai-assistant/ui'; + +// 窗口尺寸常量 +const BALL_SIZE = 60; +const EXPANDED_WIDTH = 400; +const EXPANDED_HEIGHT = 500; + +export function FloatingChat() { + const [isExpanded, setIsExpanded] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [sessionId, setSessionId] = useState(null); + const [input, setInput] = useState(''); + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + + const { + messages, + isConnected, + isLoading, + streamingMessage, + sendMessage, + cancelProcessing, + } = useChat({ + sessionId: sessionId || '', + onError: (error) => { + console.error('Chat error:', error); + }, + }); + + // 初始化:获取或创建会话 + useEffect(() => { + async function init() { + try { + const response = await fetch('http://localhost:3000/api/sessions'); + const result = await response.json(); + if (result.data && result.data.length > 0) { + setSessionId(result.data[0].id); + } else { + const createResponse = await fetch('http://localhost:3000/api/sessions', { + method: 'POST', + }); + const createResult = await createResponse.json(); + setSessionId(createResult.data.id); + } + } catch (error) { + console.error('Failed to initialize session:', error); + } + } + init(); + }, []); + + // 自动滚动到底部 + useEffect(() => { + if (isExpanded) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [messages, streamingMessage, isExpanded]); + + // 展开时聚焦输入框 + useEffect(() => { + if (isExpanded && sessionId) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isExpanded, sessionId]); + + // 展开/收起窗口(带动画) + const toggleExpanded = useCallback(async () => { + if (isAnimating) return; + + const newExpanded = !isExpanded; + setIsAnimating(true); + + if (newExpanded) { + // 展开:先调整窗口大小,再显示内容动画 + await invoke('set_floating_window_size', { width: EXPANDED_WIDTH, height: EXPANDED_HEIGHT }); + setIsExpanded(true); + // 动画完成后 + setTimeout(() => setIsAnimating(false), 300); + } else { + // 收起:先播放收起动画,再调整窗口大小 + setIsExpanded(false); + setTimeout(async () => { + await invoke('set_floating_window_size', { width: BALL_SIZE, height: BALL_SIZE }); + setIsAnimating(false); + }, 200); + } + }, [isExpanded, isAnimating]); + + const handleSend = useCallback(() => { + if (!input.trim() || isLoading) return; + sendMessage(input.trim()); + setInput(''); + }, [input, isLoading, sendMessage]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + // Escape 收起窗口 + if (e.key === 'Escape') { + if (isExpanded) { + toggleExpanded(); + } else { + invoke('hide_floating_window'); + } + } + }, [handleSend, isExpanded, toggleExpanded]); + + const handleClose = useCallback(() => { + invoke('hide_floating_window'); + }, []); + + const handleExpandToMain = useCallback(() => { + invoke('show_main_window'); + invoke('hide_floating_window'); + }, []); + + // 区分点击和拖拽 + const isDraggingRef = useRef(false); + const mouseDownPosRef = useRef({ x: 0, y: 0 }); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + isDraggingRef.current = false; + mouseDownPosRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const dx = Math.abs(e.clientX - mouseDownPosRef.current.x); + const dy = Math.abs(e.clientY - mouseDownPosRef.current.y); + // 移动超过 5px 认为是拖拽 + if (dx > 5 || dy > 5) { + isDraggingRef.current = true; + } + }, []); + + const handleClick = useCallback(() => { + // 如果是拖拽操作,不触发展开 + if (isDraggingRef.current) { + return; + } + toggleExpanded(); + }, [toggleExpanded]); + + // 窗口拖拽 + const handleDragStart = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + const appWindow = getCurrentWindow(); + await appWindow.startDragging(); + }, []); + + // 状态指示点颜色 + const statusColor = !isConnected + ? 'bg-yellow-400' // 连接中 + : isLoading + ? 'bg-blue-400' // 处理中 + : 'bg-green-400'; // 就绪 + + // 圆球状态 - 不使用 ThemeProvider,保持背景完全透明 + if (!isExpanded) { + return ( +
{ + handleMouseDown(e); + handleDragStart(e); + }} + onMouseMove={handleMouseMove} + onClick={handleClick} + > + {/* 主体球 - 玻璃拟态,使用固定尺寸避免放大溢出 */} + + {/* 内部渐变光泽 */} +
+ + {/* 内部呼吸灯效果 */} + + + {/* 顶部高光 */} +
+ + {/* 图标 - 简约机器人头 */} + {isLoading ? ( + + ) : ( + + {/* 天线 */} + + + + {/* 头部 */} + + + {/* 眼睛 */} + + + + {/* 嘴巴 */} + + + )} + + +
+ ); + } + + // 展开状态 + return ( + + + {/* 顶部拖拽区域和工具栏 */} +
+
+ + + + Quick Ask +
+
+ + + + + + +
+
+ + {/* 消息区域 */} +
+ {messages.length === 0 && !streamingMessage && ( +
+ Ask me anything... +
+ )} + + + {messages.map((message) => ( + + ))} + + + {streamingMessage && ( + + )} + +
+
+ + {/* 输入区域 */} +
+
+