feat(desktop): 添加悬浮球 Quick Ask 功能

- 实现浅灰色玻璃拟态悬浮球,带机器人头图标
- 支持点击展开对话框,淡入淡出动画
- 支持窗口拖拽,区分点击和拖拽操作
- macOS 透明窗口支持 (macOSPrivateApi)
- 悬浮球 hover 放大效果,不溢出窗口
- 添加系统托盘 Toggle Quick Ask 菜单
This commit is contained in:
2025-12-30 15:35:21 +08:00
parent 4108b112f9
commit 44bed99bb4
10 changed files with 565 additions and 4 deletions
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quick Ask</title>
<style>
/* 透明背景 */
html, body {
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
}
#root {
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/floating.tsx"></script>
</body>
</html>
+1 -1
View File
@@ -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"
@@ -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",
@@ -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<Vec<DirectoryEntry>, String>
Ok(entries)
}
// 悬浮窗口控制命令
#[tauri::command]
pub async fn toggle_floating_window(app: tauri::AppHandle) -> Result<bool, String> {
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())
}
}
+18 -2
View File
@@ -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<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error::Error>> {
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<R: Runtime>(app: &tauri::App<R>) -> Result<(), Box<dyn std::error:
let _ = window.set_focus();
}
}
"floating" => {
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);
}
+19 -1
View File
@@ -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
}
},
+25
View File
@@ -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(
<React.StrictMode>
<FloatingChat />
</React.StrictMode>
);
+375
View File
@@ -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<string | null>(null);
const [input, setInput] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div
className="relative w-[60px] h-[60px] cursor-pointer select-none flex items-center justify-center"
onMouseDown={(e) => {
handleMouseDown(e);
handleDragStart(e);
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
{/* 主体球 - 玻璃拟态,使用固定尺寸避免放大溢出 */}
<motion.div
className="relative rounded-full flex items-center justify-center cursor-pointer overflow-hidden"
style={{
backgroundColor: 'rgba(209, 213, 219, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.5)',
width: 52,
height: 52,
}}
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.92 }}
>
{/* 内部渐变光泽 */}
<div
className="absolute inset-0 rounded-full"
style={{
background: 'linear-gradient(to bottom right, rgba(255,255,255,0.35), transparent, rgba(0,0,0,0.1))',
}}
/>
{/* 内部呼吸灯效果 */}
<motion.div
className="absolute inset-0 rounded-full"
style={{
background: 'radial-gradient(circle at center, rgba(255, 255, 255, 0.4), transparent 70%)',
}}
animate={{
opacity: [0.3, 0.6, 0.3],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
{/* 顶部高光 */}
<div
className="absolute top-1.5 left-1/2 -translate-x-1/2 w-7 h-2 rounded-full"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.35)',
filter: 'blur(2px)',
}}
/>
{/* 图标 - 简约机器人头 */}
{isLoading ? (
<Loader2 size={26} className="animate-spin relative z-10" style={{ color: 'rgba(255, 255, 255, 0.95)' }} />
) : (
<svg
width="30"
height="30"
viewBox="0 0 24 24"
fill="none"
className="relative z-10"
>
{/* 天线 */}
<circle cx="12" cy="3" r="1.5" fill="rgba(255, 255, 255, 0.9)" />
<line x1="12" y1="4.5" x2="12" y2="7" stroke="rgba(255, 255, 255, 0.9)" strokeWidth="1.5" strokeLinecap="round" />
{/* 头部 */}
<rect x="4" y="7" width="16" height="13" rx="4" fill="rgba(255, 255, 255, 0.15)" stroke="rgba(255, 255, 255, 0.9)" strokeWidth="1.5" />
{/* 眼睛 */}
<circle cx="8.5" cy="13" r="2" fill="rgba(255, 255, 255, 0.9)" />
<circle cx="15.5" cy="13" r="2" fill="rgba(255, 255, 255, 0.9)" />
{/* 嘴巴 */}
<line x1="9" y1="17" x2="15" y2="17" stroke="rgba(255, 255, 255, 0.9)" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)}
</motion.div>
</div>
);
}
// 展开状态
return (
<ThemeProvider defaultTheme="dark">
<motion.div
className="w-full h-full flex flex-col bg-surface-base/95 backdrop-blur-xl rounded-2xl border border-line/50 shadow-2xl overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
>
{/* 顶部拖拽区域和工具栏 */}
<div
className="flex items-center justify-between px-3 py-2 border-b border-line/50 cursor-move select-none bg-surface-subtle/50"
onMouseDown={handleDragStart}
>
<div className="flex items-center gap-2">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={toggleExpanded}
className="p-1 rounded hover:bg-surface-muted text-fg-muted hover:text-fg transition-colors"
title="Collapse"
>
<ChevronDown size={16} />
</motion.button>
<span className="text-xs font-medium text-fg-muted">Quick Ask</span>
</div>
<div className="flex items-center gap-1">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleExpandToMain}
className="p-1 rounded hover:bg-surface-muted text-fg-muted hover:text-fg transition-colors"
title="Open in main window"
>
<Maximize2 size={14} />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={handleClose}
className="p-1 rounded hover:bg-red-500/20 text-fg-muted hover:text-red-400 transition-colors"
title="Hide"
>
<X size={14} />
</motion.button>
</div>
</div>
{/* 消息区域 */}
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{messages.length === 0 && !streamingMessage && (
<div className="text-center text-fg-muted text-sm py-8">
Ask me anything...
</div>
)}
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<ChatMessage
key={message.id}
message={message}
/>
))}
</AnimatePresence>
{streamingMessage && (
<ChatMessage
message={streamingMessage}
isStreaming
/>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="p-3 border-t border-line/50 bg-surface-subtle/30">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question... (Enter to send)"
className="flex-1 resize-none bg-surface-subtle border border-line rounded-lg px-3 py-2 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:ring-2 focus:ring-primary-500/50 min-h-[40px] max-h-[120px]"
rows={1}
disabled={!isConnected}
/>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={isLoading ? cancelProcessing : handleSend}
disabled={!isConnected || (!input.trim() && !isLoading)}
className={`p-2 rounded-lg transition-colors ${
isLoading
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white disabled:bg-surface-muted disabled:text-fg-muted'
}`}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</motion.button>
</div>
</div>
<Toaster />
</motion.div>
</ThemeProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 悬浮窗专用样式 - 确保背景完全透明 (macOS 需要 rgba(0,0,0,0)) */
html, body, #root {
margin: 0;
padding: 0;
background-color: rgba(0, 0, 0, 0) !important;
background: rgba(0, 0, 0, 0) !important;
overflow: hidden;
}
/* 覆盖可能的 dark 主题背景 */
.dark, [data-theme="dark"] {
background-color: rgba(0, 0, 0, 0) !important;
background: rgba(0, 0, 0, 0) !important;
}
+7
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { resolve } from 'path';
const host = process.env.TAURI_DEV_HOST;
@@ -43,5 +44,11 @@ export default defineConfig({
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_DEBUG,
outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
floating: resolve(__dirname, 'floating.html'),
},
},
},
});