diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 248ccdc0..19fdc2b8 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -43,6 +43,11 @@ import { useAIChatSessionState } from './ai/useAIChatSessionState'; import { useAIChatSessionTitleGenerator } from './ai/useAIChatSessionTitleGenerator'; import { useAIChatLocalTools } from './ai/useAIChatLocalTools'; import { useI18n } from '../i18n/provider'; +import { + coerceThinkingIntensityForProfile, + defaultThinkingIntensityForProfile, + resolveThinkingIntensityProfile, +} from '../utils/aiThinkingIntensity'; interface AIChatPanelProps { width?: number; @@ -52,12 +57,19 @@ interface AIChatPanelProps { onOpenSettings?: () => void; onWidthChange?: (width: number) => void; overlayTheme: OverlayWorkbenchTheme; + /** dock:侧栏;detached:独立浮动窗内 */ + presentation?: 'dock' | 'detached'; + onDetach?: () => void; + onAttach?: () => void; + /** 独立窗:从标题栏发起拖拽(按钮区域会 stopPropagation) */ + onWindowDragStart?: (event: React.PointerEvent) => void; } const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; export const AIChatPanel: React.FC = ({ - width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme + width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme, + presentation = 'dock', onDetach, onAttach, onWindowDragStart, }) => { const { t } = useI18n(); const [input, setInput] = useState(''); @@ -67,6 +79,7 @@ export const AIChatPanel: React.FC = ({ const [historyOpen, setHistoryOpen] = useState(false); const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat'); const [composerNoticeState, setComposerNoticeState] = useState(null); + const [thinkingIntensity, setThinkingIntensity] = useState('medium'); const { activeProvider, composerNotice: runtimeComposerNotice, @@ -149,6 +162,31 @@ export const AIChatPanel: React.FC = ({ } }, [runtimeComposerNotice]); + // 切换供应商/模型时,将思考强度钳制到当前体系合法档位。 + useEffect(() => { + if (!activeProvider) { + return; + } + const profile = resolveThinkingIntensityProfile({ + type: activeProvider.type, + apiFormat: activeProvider.apiFormat, + baseUrl: activeProvider.baseUrl, + model: activeProvider.model, + }); + setThinkingIntensity((current) => { + const next = coerceThinkingIntensityForProfile( + current || defaultThinkingIntensityForProfile(profile), + profile, + ); + return next; + }); + }, [ + activeProvider?.type, + activeProvider?.apiFormat, + activeProvider?.baseUrl, + activeProvider?.model, + ]); + const getConnectionName = useCallback(() => { let connectionId = activeContext?.connectionId; if (!connectionId) { @@ -456,6 +494,10 @@ export const AIChatPanel: React.FC = ({ sid, messages: allMessages, tools: availableTools, + sendOptions: { + model: String(activeProvider?.model || '').trim() || undefined, + thinkingIntensity: String(thinkingIntensity || '').trim() || undefined, + }, addAIChatMessage, updateAIChatMessage, setSending, @@ -488,6 +530,7 @@ export const AIChatPanel: React.FC = ({ loadingModels, resetToolCallState, t, + thinkingIntensity, updateAIChatMessage, ]); @@ -586,11 +629,26 @@ export const AIChatPanel: React.FC = ({ void handleModelChange(model); }, [handleModelChange]); - return ( -
-
+ const isDetachedPresentation = presentation === 'detached'; - {isResizing && panelRect.current && createPortal( + return ( +
+ {!isDetachedPresentation && ( +
+ )} + + {!isDetachedPresentation && isResizing && panelRect.current && createPortal(
= ({ textColor={textColor} overlayTheme={overlayTheme} isV2Ui={isV2Ui} + presentation={presentation} onHistoryClick={() => { if (isV2Ui) { setActivePanelMode('history'); @@ -626,6 +685,9 @@ export const AIChatPanel: React.FC = ({ }} onSettingsClick={handleOpenSettingsFromPanel} onClose={onClose} + onDetach={onDetach} + onAttach={onAttach} + onWindowDragStart={onWindowDragStart} sessionTitle={currentSessionTitle} activeMode={effectivePanelMode} onModeChange={(mode) => { @@ -696,6 +758,8 @@ export const AIChatPanel: React.FC = ({ onComposerAction={handleComposerActionWithNoticeReset} onModelChange={handleModelChangeWithNoticeReset} onFetchModels={fetchDynamicModels} + thinkingIntensity={thinkingIntensity} + onThinkingIntensityChange={setThinkingIntensity} textareaRef={textareaRef} darkMode={darkMode} textColor={textColor} diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 0be74c7d..4c6768c6 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -35,6 +35,7 @@ import { matchProviderPreset, waitForAIService, } from './ai/aiSettingsModalConfig'; +import { useStore } from '../store'; interface AISettingsModalProps { open: boolean; onClose: () => void; @@ -121,6 +122,8 @@ export const AISettingsContent: React.FC = ({ active, da const [form] = Form.useForm(); const modalBodyRef = useRef(null); const missingAIServiceWarnedRef = useRef(false); + const aiChatOpenMode = useStore((state) => state.aiChatOpenMode); + const setAIChatOpenMode = useStore((state) => state.setAIChatOpenMode); // Modal 内部 toast 通知 const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body }); @@ -412,7 +415,6 @@ export const AISettingsContent: React.FC = ({ active, da models: resolvedModels, baseUrl: finalBaseUrl, apiFormat: resolvedTransport.apiFormat, - thinkingIntensity: String(values.thinkingIntensity || '').trim(), }; // 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常 await Service?.AISaveProvider?.(payload); @@ -685,7 +687,6 @@ export const AISettingsContent: React.FC = ({ active, da models: resolvedModels, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7, - thinkingIntensity: String(values.thinkingIntensity || '').trim(), apiFormat: resolvedTransport.apiFormat, }); if (res?.success) { setTestStatus('success'); void messageApi.success(t('ai_settings.message.test_success')); } @@ -772,11 +773,20 @@ export const AISettingsContent: React.FC = ({ active, da {activeSection === 'context' && ( { + setAIChatOpenMode(mode); + void messageApi.success( + mode === 'detached' + ? t('ai_settings.open_mode.message.detached') + : t('ai_settings.open_mode.message.dock'), + ); + }} /> )} {activeSection === 'mcp' && ( diff --git a/frontend/src/components/FloatingAIChatWindow.tsx b/frontend/src/components/FloatingAIChatWindow.tsx new file mode 100644 index 00000000..dc20cecd --- /dev/null +++ b/frontend/src/components/FloatingAIChatWindow.tsx @@ -0,0 +1,288 @@ +import React, { useCallback, useRef } from 'react'; +import { Button } from 'antd'; +import { useStore } from '../store'; +import { t } from '../i18n'; +import { + clamp, + DEFAULT_DETACHED_AI_CHAT_MIN_HEIGHT, + DEFAULT_DETACHED_AI_CHAT_MIN_WIDTH, + DETACHED_WINDOW_VIEWPORT_PADDING, +} from '../utils/detachedWindow'; +import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import AIChatPanel from './AIChatPanel'; +import AIPanelErrorBoundary from './ai/AIPanelErrorBoundary'; + +type DragMode = 'move' | 'resize-e' | 'resize-s' | 'resize-se'; + +interface FloatingAIChatWindowProps { + darkMode: boolean; + bgColor?: string; + overlayTheme: OverlayWorkbenchTheme; + onOpenSettings: () => void; + onRenderError?: (error: Error, errorInfo: React.ErrorInfo) => void; + onRetryRender?: () => void; + renderNonce?: number; +} + +const FloatingAIChatWindow: React.FC = ({ + darkMode, + bgColor, + overlayTheme, + onOpenSettings, + onRenderError, + onRetryRender, + renderNonce = 0, +}) => { + const theme = useStore((state) => state.theme); + const windowState = useStore((state) => state.detachedAIChatWindow); + const attachAIChatPanel = useStore((state) => state.attachAIChatPanel); + const setAIPanelVisible = useStore((state) => state.setAIPanelVisible); + const updateDetachedAIChatBounds = useStore((state) => state.updateDetachedAIChatBounds); + const focusDetachedAIChatPanel = useStore((state) => state.focusDetachedAIChatPanel); + + const dragRef = useRef<{ + mode: DragMode; + startX: number; + startY: number; + originX: number; + originY: number; + originW: number; + originH: number; + } | null>(null); + + const startInteraction = useCallback(( + event: React.PointerEvent, + mode: DragMode, + bounds: { x: number; y: number; width: number; height: number }, + ) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + focusDetachedAIChatPanel(); + dragRef.current = { + mode, + startX: event.clientX, + startY: event.clientY, + originX: bounds.x, + originY: bounds.y, + originW: bounds.width, + originH: bounds.height, + }; + + const handleMove = (moveEvent: PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + const dx = moveEvent.clientX - drag.startX; + const dy = moveEvent.clientY - drag.startY; + if (drag.mode === 'move') { + const maxX = Math.max( + DETACHED_WINDOW_VIEWPORT_PADDING, + window.innerWidth - drag.originW - DETACHED_WINDOW_VIEWPORT_PADDING, + ); + const maxY = Math.max( + DETACHED_WINDOW_VIEWPORT_PADDING, + window.innerHeight - drag.originH - DETACHED_WINDOW_VIEWPORT_PADDING, + ); + updateDetachedAIChatBounds({ + x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX), + y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY), + }); + return; + } + let nextW = drag.originW; + let nextH = drag.originH; + if (drag.mode === 'resize-e' || drag.mode === 'resize-se') { + nextW = clamp( + drag.originW + dx, + DEFAULT_DETACHED_AI_CHAT_MIN_WIDTH, + window.innerWidth - drag.originX - DETACHED_WINDOW_VIEWPORT_PADDING, + ); + } + if (drag.mode === 'resize-s' || drag.mode === 'resize-se') { + nextH = clamp( + drag.originH + dy, + DEFAULT_DETACHED_AI_CHAT_MIN_HEIGHT, + window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING, + ); + } + updateDetachedAIChatBounds({ width: nextW, height: nextH }); + }; + + const stop = () => { + dragRef.current = null; + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', stop); + window.removeEventListener('pointercancel', stop); + }; + + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', stop); + window.addEventListener('pointercancel', stop); + }, [focusDetachedAIChatPanel, updateDetachedAIChatBounds]); + + if (!windowState) { + return null; + } + + const isDark = theme === 'dark'; + const bounds = windowState; + + return ( +
+ + +
focusDetachedAIChatPanel()} + > +
+ ( +
+
{t('app.ai_panel.error.title')}
+
{t('app.ai_panel.error.description')}
+ {error?.message && ( +
{error.message}
+ )} + {onRetryRender && ( + + )} +
+ )} + > + setAIPanelVisible(false)} + onOpenSettings={onOpenSettings} + onDetach={undefined} + onAttach={() => attachAIChatPanel()} + onWindowDragStart={(event) => startInteraction(event, 'move', bounds)} + /> +
+
+
startInteraction(event, 'resize-e', bounds)} + /> +
startInteraction(event, 'resize-s', bounds)} + /> +
startInteraction(event, 'resize-se', bounds)} + /> +
+
+ ); +}; + +export default FloatingAIChatWindow; diff --git a/frontend/src/components/ai/AIChatHeader.tsx b/frontend/src/components/ai/AIChatHeader.tsx index 976fa1c3..cdc19b5e 100644 --- a/frontend/src/components/ai/AIChatHeader.tsx +++ b/frontend/src/components/ai/AIChatHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Button, Tooltip } from 'antd'; -import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined, ExpandOutlined, CompressOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { AIChatMessage } from '../../types'; import { t as catalogTranslate } from '../../i18n/catalog'; @@ -12,10 +12,15 @@ interface AIChatHeaderProps { textColor: string; overlayTheme: OverlayWorkbenchTheme; isV2Ui?: boolean; + presentation?: 'dock' | 'detached'; onHistoryClick: () => void; onClear: () => void; onSettingsClick: () => void; onClose: () => void; + onDetach?: () => void; + onAttach?: () => void; + /** 独立窗拖拽:点在标题栏空白/品牌区开始拖动 */ + onWindowDragStart?: (event: React.PointerEvent) => void; messages?: AIChatMessage[]; sessionTitle?: string; activeMode?: 'chat' | 'insights' | 'history'; @@ -52,7 +57,9 @@ const exportToMarkdown = (messages: AIChatMessage[], title: string, labels: Expo export const AIChatHeader: React.FC = ({ darkMode, mutedColor, textColor, overlayTheme, isV2Ui = false, + presentation = 'dock', onHistoryClick, onClear, onSettingsClick, onClose, + onDetach, onAttach, onWindowDragStart, messages = [], sessionTitle, activeMode = 'chat', onModeChange, @@ -68,9 +75,23 @@ export const AIChatHeader: React.FC = ({ userRole: t('ai_chat.header.export_user'), }); + const handleDragStart = (event: React.PointerEvent) => { + if (!onWindowDragStart) return; + // 点在按钮/标签等交互控件上不拖窗 + const target = event.target as HTMLElement | null; + if (target?.closest('button, a, input, textarea, .ant-btn, .gn-v2-ai-mode-tabs, .ai-chat-header-right')) { + return; + } + onWindowDragStart(event); + }; + if (!isV2Ui) { return ( -
+
GoNavi AI
-
+
event.stopPropagation()}> {messages.length > 0 && (
-
+
event.stopPropagation()} + >