import React, { useEffect, useMemo, useRef, useState } from 'react' import axios from 'axios' import { BotIcon, CloseIcon, MinusIcon, SendIcon, SparkIcon } from './Icons' const POS_KEY = 'happy.aiPos' const WIN_WIDTH = 348 const starterMessages = [ { role: 'assistant', text: '我是小屁,你的外贸业务助理。可以帮你查客户、产品、供应商、订单、商机和待办,也能代你发起系统内的操作。' }, ] function readStoredPos() { try { const raw = JSON.parse(localStorage.getItem(POS_KEY) || 'null') if (raw && Number.isFinite(raw.left) && Number.isFinite(raw.top)) return { left: raw.left, top: raw.top } } catch { /* 存储不可用时用默认位置 */ } return null } /** 拖动或改窗口大小后,把窗口夹回视口,保证整窗(至少是标题栏)能点到 */ function clampPos(pos, height) { if (!pos) return pos const maxLeft = Math.max(8, window.innerWidth - WIN_WIDTH - 8) const usable = Math.min(height || 320, window.innerHeight - 60) const maxTop = Math.max(8, window.innerHeight - usable - 8) return { left: Math.min(Math.max(8, pos.left), maxLeft), top: Math.min(Math.max(8, pos.top), maxTop) } } /** * 小屁 —— 右上角浮动小窗。 * 不是模态:没有遮罩、不 inert 主界面,所以可以一边让它干活一边自己点系统。 */ function AIAssistant({ isOpen, onClose }) { const [messages, setMessages] = useState(starterMessages) const [input, setInput] = useState('') const [context, setContext] = useState(null) const [loading, setLoading] = useState(false) const [minimized, setMinimized] = useState(false) const [pos, setPos] = useState(readStoredPos) const inputRef = useRef(null) const winRef = useRef(null) const dragRef = useRef(null) useEffect(() => { if (isOpen && !minimized) inputRef.current?.focus() }, [isOpen, minimized]) // Esc 只收窗,不做任何全局拦截 useEffect(() => { if (!isOpen) return undefined const onKeyDown = (event) => { if (event.key === 'Escape') onClose() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [isOpen, onClose]) useEffect(() => { if (!isOpen) return axios.get('/api/assistant/context').then(response => setContext(response.data)).catch(() => setContext(null)) }, [isOpen]) useEffect(() => { if (!isOpen || !pos) return undefined const onResize = () => setPos(current => clampPos(current, winRef.current?.offsetHeight)) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [isOpen, pos]) const onDragStart = (event) => { if (event.target.closest('button')) return const rect = winRef.current?.getBoundingClientRect() if (!rect) return dragRef.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top } try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* 合成事件或指针已释放 */ } } const onDragMove = (event) => { if (!dragRef.current) return setPos(clampPos({ left: event.clientX - dragRef.current.dx, top: event.clientY - dragRef.current.dy }, winRef.current?.offsetHeight)) } const onDragEnd = (event) => { if (!dragRef.current) return dragRef.current = null try { event.currentTarget.releasePointerCapture(event.pointerId) } catch { /* 指针已释放 */ } const rect = winRef.current?.getBoundingClientRect() if (rect) { try { localStorage.setItem(POS_KEY, JSON.stringify({ left: rect.left, top: rect.top })) } catch { /* 仅本次会话 */ } } } const capabilities = useMemo(() => [ '读取全局业务概览与实时统计', '检索客户、供应商、产品和订单', '查看商机、待办与近期活动', '发起已授权的新增、更新和状态操作' ], []) if (!isOpen) return null const sendMessage = async (text = input) => { const prompt = text.trim() if (!prompt || loading) return setInput('') setMessages(prev => [...prev, { role: 'user', text: prompt }]) setLoading(true) try { const response = await axios.post('/api/assistant/command', { prompt }) setMessages(prev => [...prev, { role: 'assistant', text: response.data.reply || '我已收到指令,但当前还没有可执行的结果。', meta: response.data.meta }]) if (response.data.context) setContext(response.data.context) } catch { setMessages(prev => [...prev, { role: 'assistant', text: '暂时无法连接系统能力服务,请确认后端服务正在运行。' }]) } finally { setLoading(false) } } return ( ) } export default AIAssistant