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 && (
+
+ )}
+
+
+
+
+ {/* 输入区域 */}
+
+
+
+
+
+ );
+}
diff --git a/packages/desktop/src/styles/floating.css b/packages/desktop/src/styles/floating.css
new file mode 100644
index 0000000..bf46a7b
--- /dev/null
+++ b/packages/desktop/src/styles/floating.css
@@ -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;
+}
diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts
index e0d4d18..9e546c7 100644
--- a/packages/desktop/vite.config.ts
+++ b/packages/desktop/vite.config.ts
@@ -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'),
+ },
+ },
},
});