feat(desktop): 添加悬浮球 Quick Ask 功能
- 实现浅灰色玻璃拟态悬浮球,带机器人头图标 - 支持点击展开对话框,淡入淡出动画 - 支持窗口拖拽,区分点击和拖拽操作 - macOS 透明窗口支持 (macOSPrivateApi) - 悬浮球 hover 放大效果,不溢出窗口 - 添加系统托盘 Toggle Quick Ask 菜单
This commit is contained in:
@@ -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>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user