mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
✨ feat(ai-chat): 支持侧栏/独立窗默认打开、双向切换与尺寸记忆
- 新增 AI 独立浮动窗,标题栏可拖拽缩放并与侧栏双向切换 - 设置中可配置默认打开方式(侧栏/独立窗),偏好持久化 - 记忆独立窗上次位置与尺寸,再次打开自动恢复 - 优化输入区默认高度与底部模型/思考/用量工具条布局 - 去掉独立窗外层重复标题栏,由面板 header 统一承载操作
This commit is contained in:
@@ -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<AIChatPanelProps> = ({
|
||||
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<AIChatPanelProps> = ({
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
|
||||
const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);
|
||||
const [thinkingIntensity, setThinkingIntensity] = useState('medium');
|
||||
const {
|
||||
activeProvider,
|
||||
composerNotice: runtimeComposerNotice,
|
||||
@@ -149,6 +162,31 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}
|
||||
}, [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<AIChatPanelProps> = ({
|
||||
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<AIChatPanelProps> = ({
|
||||
loadingModels,
|
||||
resetToolCallState,
|
||||
t,
|
||||
thinkingIntensity,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
@@ -586,11 +629,26 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
void handleModelChange(model);
|
||||
}, [handleModelChange]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
const isDetachedPresentation = presentation === 'detached';
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}${isDetachedPresentation ? ' is-detached' : ''}`}
|
||||
style={{
|
||||
width: isDetachedPresentation ? '100%' : panelWidth,
|
||||
height: isDetachedPresentation ? '100%' : undefined,
|
||||
background: bgColor || 'transparent',
|
||||
color: textColor,
|
||||
borderLeft: isDetachedPresentation ? 'none' : overlayTheme.shellBorder,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!isDetachedPresentation && (
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
)}
|
||||
|
||||
{!isDetachedPresentation && isResizing && panelRect.current && createPortal(
|
||||
<div
|
||||
ref={ghostRef}
|
||||
style={{
|
||||
@@ -613,6 +671,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
textColor={textColor}
|
||||
overlayTheme={overlayTheme}
|
||||
isV2Ui={isV2Ui}
|
||||
presentation={presentation}
|
||||
onHistoryClick={() => {
|
||||
if (isV2Ui) {
|
||||
setActivePanelMode('history');
|
||||
@@ -626,6 +685,9 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}}
|
||||
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<AIChatPanelProps> = ({
|
||||
onComposerAction={handleComposerActionWithNoticeReset}
|
||||
onModelChange={handleModelChangeWithNoticeReset}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
thinkingIntensity={thinkingIntensity}
|
||||
onThinkingIntensityChange={setThinkingIntensity}
|
||||
textareaRef={textareaRef}
|
||||
darkMode={darkMode}
|
||||
textColor={textColor}
|
||||
|
||||
@@ -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<AISettingsContentProps> = ({ active, da
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(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<AISettingsContentProps> = ({ 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<AISettingsContentProps> = ({ 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<AISettingsContentProps> = ({ active, da
|
||||
{activeSection === 'context' && (
|
||||
<AISettingsContextSection
|
||||
contextLevel={contextLevel}
|
||||
openMode={aiChatOpenMode}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
onChange={handleContextChange}
|
||||
onOpenModeChange={(mode) => {
|
||||
setAIChatOpenMode(mode);
|
||||
void messageApi.success(
|
||||
mode === 'detached'
|
||||
? t('ai_settings.open_mode.message.detached')
|
||||
: t('ai_settings.open_mode.message.dock'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'mcp' && (
|
||||
|
||||
288
frontend/src/components/FloatingAIChatWindow.tsx
Normal file
288
frontend/src/components/FloatingAIChatWindow.tsx
Normal file
@@ -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<FloatingAIChatWindowProps> = ({
|
||||
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 (
|
||||
<div className="gn-detached-ai-chat-layer" aria-label={t('ai_chat.detached.window_aria')}>
|
||||
<style>{`
|
||||
.gn-detached-ai-chat-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: ${bounds.zIndex};
|
||||
}
|
||||
.gn-detached-ai-chat-window {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: ${DEFAULT_DETACHED_AI_CHAT_MIN_WIDTH}px;
|
||||
min-height: ${DEFAULT_DETACHED_AI_CHAT_MIN_HEIGHT}px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${isDark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.12)'};
|
||||
background: ${isDark ? 'rgba(22,24,28,0.98)' : 'rgba(255,255,255,0.98)'};
|
||||
box-shadow: ${isDark
|
||||
? '0 0 0 1px rgba(255,214,102,0.2), 0 20px 52px rgba(0,0,0,0.5)'
|
||||
: '0 0 0 1px rgba(22,119,255,0.14), 0 20px 52px rgba(15,23,42,0.18)'};
|
||||
/* 不用 transform 入场:scale 会破坏 macOS/WebView 中文输入法候选窗定位,造成“一直挂着” */
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
animation: gn-detached-ai-enter 160ms ease-out;
|
||||
}
|
||||
@keyframes gn-detached-ai-enter {
|
||||
from { opacity: 0.45; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* 输入区祖先避免 transform/filter,保证 IME 浮层相对视口定位 */
|
||||
.gn-detached-ai-chat-body,
|
||||
.gn-detached-ai-chat-body .ai-chat-panel,
|
||||
.gn-detached-ai-chat-body .ai-chat-input-area {
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
perspective: none !important;
|
||||
}
|
||||
/* 不再单独做外层标题栏,避免与面板内 header 按钮重复;拖拽交给面板 header */
|
||||
.gn-detached-ai-chat-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.gn-detached-ai-chat-body .ai-chat-panel {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
border-left: none !important;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.gn-detached-ai-chat-body .ai-resize-handle {
|
||||
display: none !important;
|
||||
}
|
||||
.gn-detached-ai-chat-body .ai-chat-header {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.gn-detached-ai-chat-body .ai-chat-header-right,
|
||||
.gn-detached-ai-chat-body .gn-v2-ai-header-actions,
|
||||
.gn-detached-ai-chat-body .gn-v2-ai-mode-tabs {
|
||||
cursor: default;
|
||||
user-select: auto;
|
||||
}
|
||||
.gn-detached-ai-chat-resize-e,
|
||||
.gn-detached-ai-chat-resize-s,
|
||||
.gn-detached-ai-chat-resize-se {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
.gn-detached-ai-chat-resize-e {
|
||||
top: 8px;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
bottom: 12px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.gn-detached-ai-chat-resize-s {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
height: 6px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.gn-detached-ai-chat-resize-se {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
className="gn-detached-ai-chat-window"
|
||||
style={{
|
||||
left: bounds.x,
|
||||
top: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
zIndex: bounds.zIndex,
|
||||
}}
|
||||
onPointerDown={() => focusDetachedAIChatPanel()}
|
||||
>
|
||||
<div className="gn-detached-ai-chat-body">
|
||||
<AIPanelErrorBoundary
|
||||
key={`detached-ai-${renderNonce}`}
|
||||
onError={onRenderError}
|
||||
fallback={(error) => (
|
||||
<div style={{ padding: 20, color: isDark ? 'rgba(255,255,255,0.88)' : '#162033' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t('app.ai_panel.error.title')}</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75, marginBottom: 12 }}>{t('app.ai_panel.error.description')}</div>
|
||||
{error?.message && (
|
||||
<div style={{ fontSize: 12, marginBottom: 12, wordBreak: 'break-word' }}>{error.message}</div>
|
||||
)}
|
||||
{onRetryRender && (
|
||||
<Button type="primary" size="small" onClick={onRetryRender}>
|
||||
{t('app.ai_panel.action.reload')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<AIChatPanel
|
||||
width={bounds.width}
|
||||
darkMode={darkMode}
|
||||
bgColor={bgColor}
|
||||
overlayTheme={overlayTheme}
|
||||
presentation="detached"
|
||||
onClose={() => setAIPanelVisible(false)}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onDetach={undefined}
|
||||
onAttach={() => attachAIChatPanel()}
|
||||
onWindowDragStart={(event) => startInteraction(event, 'move', bounds)}
|
||||
/>
|
||||
</AIPanelErrorBoundary>
|
||||
</div>
|
||||
<div
|
||||
className="gn-detached-ai-chat-resize-e"
|
||||
onPointerDown={(event) => startInteraction(event, 'resize-e', bounds)}
|
||||
/>
|
||||
<div
|
||||
className="gn-detached-ai-chat-resize-s"
|
||||
onPointerDown={(event) => startInteraction(event, 'resize-s', bounds)}
|
||||
/>
|
||||
<div
|
||||
className="gn-detached-ai-chat-resize-se"
|
||||
onPointerDown={(event) => startInteraction(event, 'resize-se', bounds)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingAIChatWindow;
|
||||
@@ -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<AIChatHeaderProps> = ({
|
||||
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<AIChatHeaderProps> = ({
|
||||
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 (
|
||||
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div
|
||||
className="ai-chat-header"
|
||||
style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}
|
||||
onPointerDown={handleDragStart}
|
||||
>
|
||||
<div className="ai-chat-header-left" style={{ gap: 8 }}>
|
||||
<Tooltip title={t('ai_chat.header.tooltip.history')}>
|
||||
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
|
||||
@@ -80,7 +101,7 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
</div>
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right">
|
||||
<div className="ai-chat-header-right" onPointerDown={(event) => event.stopPropagation()}>
|
||||
{messages.length > 0 && (
|
||||
<Tooltip title={t('ai_chat.header.tooltip.export_markdown')}>
|
||||
<Button type="text" size="small" icon={<ExportOutlined />} onClick={exportMarkdown} style={{ color: mutedColor }} />
|
||||
@@ -92,6 +113,16 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
<Tooltip title={t('ai_chat.header.tooltip.settings')}>
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
{presentation === 'dock' && onDetach && (
|
||||
<Tooltip title={t('ai_chat.detached.action.popout')}>
|
||||
<Button type="text" size="small" icon={<ExpandOutlined />} onClick={onDetach} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{presentation === 'detached' && onAttach && (
|
||||
<Tooltip title={t('ai_chat.detached.action.dock')}>
|
||||
<Button type="text" size="small" icon={<CompressOutlined />} onClick={onAttach} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('ai_chat.header.tooltip.close')}>
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
@@ -101,7 +132,11 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-chat-header gn-v2-ai-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div
|
||||
className="ai-chat-header gn-v2-ai-header"
|
||||
style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}
|
||||
onPointerDown={handleDragStart}
|
||||
>
|
||||
<div className="gn-v2-ai-header-top">
|
||||
<div className="ai-chat-header-left gn-v2-ai-brand" style={{ gap: 8 }}>
|
||||
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||
@@ -113,7 +148,7 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
</div>
|
||||
<span className="gn-v2-ai-provider-badge">{t('app.theme.ui_version.v2.badge')}</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right gn-v2-ai-header-actions">
|
||||
<div className="ai-chat-header-right gn-v2-ai-header-actions" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Tooltip title={t('ai_chat.header.tooltip.new_chat')}>
|
||||
<Button type="text" size="small" icon={<PlusOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
@@ -123,13 +158,27 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
<Tooltip title={t('ai_chat.header.tooltip.settings')}>
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
{presentation === 'dock' && onDetach && (
|
||||
<Tooltip title={t('ai_chat.detached.action.popout')}>
|
||||
<Button type="text" size="small" icon={<ExpandOutlined />} onClick={onDetach} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{presentation === 'detached' && onAttach && (
|
||||
<Tooltip title={t('ai_chat.detached.action.dock')}>
|
||||
<Button type="text" size="small" icon={<CompressOutlined />} onClick={onAttach} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('ai_chat.header.tooltip.close')}>
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gn-v2-ai-mode-tabs" aria-label={t('ai_chat.header.mode_tabs.aria_label')}>
|
||||
<div
|
||||
className="gn-v2-ai-mode-tabs"
|
||||
aria-label={t('ai_chat.header.mode_tabs.aria_label')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'chat' ? 'is-active' : undefined}
|
||||
|
||||
@@ -13,6 +13,13 @@ const overlayTheme = buildOverlayWorkbenchTheme(false);
|
||||
const contextSectionSource = readFileSync(new URL('./AISettingsContextSection.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const REQUIRED_CONTEXT_KEYS = [
|
||||
'ai_settings.open_mode.title',
|
||||
'ai_settings.open_mode.description',
|
||||
'ai_settings.open_mode.dock.label',
|
||||
'ai_settings.open_mode.dock.desc',
|
||||
'ai_settings.open_mode.detached.label',
|
||||
'ai_settings.open_mode.detached.desc',
|
||||
'ai_settings.context.section_title',
|
||||
'ai_settings.context.description',
|
||||
'ai_settings.context.schema_only.label',
|
||||
'ai_settings.context.schema_only.desc',
|
||||
@@ -76,20 +83,25 @@ describe('AI settings readonly sections', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the context cards and keeps the selected level visible', () => {
|
||||
it('renders the open-mode and context cards and keeps the selected values visible', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider preference="en-US" systemLanguages={['en-US']} onPreferenceChange={() => {}}>
|
||||
<AISettingsContextSection
|
||||
contextLevel="with_samples"
|
||||
openMode="dock"
|
||||
darkMode={false}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
onChange={() => {}}
|
||||
onOpenModeChange={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('Default open style');
|
||||
expect(markup).toContain('Sidebar panel');
|
||||
expect(markup).toContain('Floating window');
|
||||
expect(markup).toContain('Schema only');
|
||||
expect(markup).toContain('With samples');
|
||||
expect(markup).toContain('With query results');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CheckOutlined } from '@ant-design/icons';
|
||||
|
||||
import { t as catalogTranslate } from '../../i18n/catalog';
|
||||
import { useOptionalI18n } from '../../i18n/provider';
|
||||
import type { AIChatOpenMode } from '../../store';
|
||||
import type { AIContextLevel } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
@@ -17,75 +18,144 @@ const CONTEXT_OPTIONS: {
|
||||
{ labelKey: 'ai_settings.context.with_results.label', value: 'with_results', descKey: 'ai_settings.context.with_results.desc', icon: '📑' },
|
||||
];
|
||||
|
||||
const OPEN_MODE_OPTIONS: {
|
||||
labelKey: string;
|
||||
value: AIChatOpenMode;
|
||||
descKey: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ labelKey: 'ai_settings.open_mode.dock.label', value: 'dock', descKey: 'ai_settings.open_mode.dock.desc', icon: '📎' },
|
||||
{ labelKey: 'ai_settings.open_mode.detached.label', value: 'detached', descKey: 'ai_settings.open_mode.detached.desc', icon: '🪟' },
|
||||
];
|
||||
|
||||
interface AISettingsContextSectionProps {
|
||||
contextLevel: AIContextLevel;
|
||||
openMode: AIChatOpenMode;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
cardBg: string;
|
||||
cardBorder: string;
|
||||
onChange: (level: AIContextLevel) => void;
|
||||
onOpenModeChange: (mode: AIChatOpenMode) => void;
|
||||
}
|
||||
|
||||
const ChoiceCard = ({
|
||||
active,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
cardBg,
|
||||
cardBorder,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
cardBg: string;
|
||||
cardBorder: string;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
|
||||
background: active ? overlayTheme.selectedBg : cardBg,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{title}
|
||||
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AISettingsContextSection: React.FC<AISettingsContextSectionProps> = ({
|
||||
contextLevel,
|
||||
openMode,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
cardBg,
|
||||
cardBorder,
|
||||
onChange,
|
||||
onOpenModeChange,
|
||||
}) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 2 }}>
|
||||
{copy('ai_settings.open_mode.title')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
|
||||
{copy('ai_settings.open_mode.description')}
|
||||
</div>
|
||||
{OPEN_MODE_OPTIONS.map((opt) => (
|
||||
<ChoiceCard
|
||||
key={opt.value}
|
||||
active={openMode === opt.value}
|
||||
icon={opt.icon}
|
||||
title={copy(opt.labelKey)}
|
||||
description={copy(opt.descKey)}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
onClick={() => onOpenModeChange(opt.value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, margin: '16px 0 2px' }}>
|
||||
{copy('ai_settings.context.section_title')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginBottom: 8 }}>
|
||||
{copy('ai_settings.context.description')}
|
||||
</div>
|
||||
{CONTEXT_OPTIONS.map((opt) => {
|
||||
const active = contextLevel === opt.value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: `1.5px solid ${active ? overlayTheme.selectedText : cardBorder}`,
|
||||
background: active ? overlayTheme.selectedBg : cardBg,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontSize: 18,
|
||||
flexShrink: 0,
|
||||
background: active ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
|
||||
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{copy(opt.labelKey)}
|
||||
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 14 }} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: overlayTheme.mutedText, marginTop: 4, lineHeight: '1.5' }}>{copy(opt.descKey)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{CONTEXT_OPTIONS.map((opt) => (
|
||||
<ChoiceCard
|
||||
key={opt.value}
|
||||
active={contextLevel === opt.value}
|
||||
icon={opt.icon}
|
||||
title={copy(opt.labelKey)}
|
||||
description={copy(opt.descKey)}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
onClick={() => onChange(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1617,6 +1617,81 @@ describe('store appearance persistence', () => {
|
||||
expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
|
||||
});
|
||||
|
||||
it('detaches AI chat panel into a floating window and docks it back', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().setAIPanelVisible(true);
|
||||
useStore.getState().detachAIChatPanel({ x: 40, y: 50, width: 420, height: 640 });
|
||||
expect(useStore.getState().isAIChatDetached()).toBe(true);
|
||||
expect(useStore.getState().aiPanelVisible).toBe(true);
|
||||
const detached = useStore.getState().detachedAIChatWindow;
|
||||
expect(detached).toBeTruthy();
|
||||
expect(detached?.width).toBe(420);
|
||||
expect(detached?.height).toBe(640);
|
||||
expect(detached?.x).toBeGreaterThanOrEqual(16);
|
||||
expect(detached?.y).toBeGreaterThanOrEqual(16);
|
||||
expect(detached?.zIndex).toBeGreaterThan(0);
|
||||
|
||||
// 使用可落入默认/无 DOM 视口上限的尺寸,避免 createDefaultDetachedBounds clamp 干扰断言
|
||||
useStore.getState().updateDetachedAIChatBounds({ width: 500, height: 560 });
|
||||
expect(useStore.getState().detachedAIChatWindow?.width).toBe(500);
|
||||
expect(useStore.getState().detachedAIChatWindow?.height).toBe(560);
|
||||
expect(useStore.getState().aiChatDetachedBoundsMemory?.width).toBe(500);
|
||||
expect(useStore.getState().aiChatDetachedBoundsMemory?.height).toBe(560);
|
||||
|
||||
useStore.getState().attachAIChatPanel();
|
||||
expect(useStore.getState().isAIChatDetached()).toBe(false);
|
||||
expect(useStore.getState().detachedAIChatWindow).toBeNull();
|
||||
expect(useStore.getState().aiPanelVisible).toBe(true);
|
||||
// 还原侧栏后仍保留上次尺寸记忆
|
||||
expect(useStore.getState().aiChatDetachedBoundsMemory?.width).toBe(500);
|
||||
expect(useStore.getState().aiChatDetachedBoundsMemory?.height).toBe(560);
|
||||
|
||||
// 再次弹出应复用记忆尺寸
|
||||
useStore.getState().detachAIChatPanel();
|
||||
expect(useStore.getState().detachedAIChatWindow?.width).toBe(500);
|
||||
expect(useStore.getState().detachedAIChatWindow?.height).toBe(560);
|
||||
|
||||
useStore.getState().setAIPanelVisible(false);
|
||||
expect(useStore.getState().detachedAIChatWindow).toBeNull();
|
||||
expect(useStore.getState().aiPanelVisible).toBe(false);
|
||||
expect(useStore.getState().aiChatDetachedBoundsMemory?.width).toBe(500);
|
||||
});
|
||||
|
||||
it('opens AI chat according to the configured default open mode', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
expect(useStore.getState().aiChatOpenMode).toBe('dock');
|
||||
useStore.getState().setAIPanelVisible(true);
|
||||
expect(useStore.getState().aiPanelVisible).toBe(true);
|
||||
expect(useStore.getState().detachedAIChatWindow).toBeNull();
|
||||
|
||||
useStore.getState().setAIPanelVisible(false);
|
||||
useStore.getState().setAIChatOpenMode('detached');
|
||||
expect(useStore.getState().aiChatOpenMode).toBe('detached');
|
||||
|
||||
useStore.getState().setAIPanelVisible(true);
|
||||
expect(useStore.getState().aiPanelVisible).toBe(true);
|
||||
expect(useStore.getState().isAIChatDetached()).toBe(true);
|
||||
expect(useStore.getState().detachedAIChatWindow).toBeTruthy();
|
||||
|
||||
// 手动还原到侧栏不改变默认打开偏好
|
||||
useStore.getState().attachAIChatPanel();
|
||||
expect(useStore.getState().isAIChatDetached()).toBe(false);
|
||||
expect(useStore.getState().aiChatOpenMode).toBe('detached');
|
||||
|
||||
// 再次从入口打开仍按默认偏好弹出独立窗
|
||||
useStore.getState().setAIPanelVisible(false);
|
||||
useStore.getState().toggleAIPanel();
|
||||
expect(useStore.getState().isAIChatDetached()).toBe(true);
|
||||
|
||||
useStore.getState().setAIChatOpenMode('dock');
|
||||
useStore.getState().setAIPanelVisible(false);
|
||||
useStore.getState().setAIPanelVisible(true);
|
||||
expect(useStore.getState().detachedAIChatWindow).toBeNull();
|
||||
expect(useStore.getState().aiPanelVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('returns to the source tab after closing an object edit tab opened from a hyperlink', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ import { sanitizeFontFamilyInput } from "./utils/fontFamilies";
|
||||
import {
|
||||
createDefaultDetachedBounds,
|
||||
nextDetachedZIndex,
|
||||
toAIChatDetachedBoundsMemory,
|
||||
type AIChatDetachedBoundsMemory,
|
||||
type DetachedAIChatWindow,
|
||||
type DetachedQueryResultWindow,
|
||||
type DetachedWorkbenchWindow,
|
||||
type DetachedWindowBounds,
|
||||
@@ -108,6 +111,8 @@ import {
|
||||
export type TableDoubleClickAction = "open-data" | "open-design";
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type ThemePreference = ThemeMode | "system";
|
||||
/** AI 聊天默认打开形态:侧栏 / 独立浮动窗 */
|
||||
export type AIChatOpenMode = "dock" | "detached";
|
||||
|
||||
export interface AppearanceSettings extends DataGridDisplaySettings {
|
||||
uiVersion: "legacy" | "v2";
|
||||
@@ -1340,6 +1345,10 @@ interface AppState {
|
||||
detachedWorkbenchWindows: DetachedWorkbenchWindow[];
|
||||
/** SQL 结果区已拆出的浮动窗口(会话态,不持久化) */
|
||||
detachedQueryResultWindows: DetachedQueryResultWindow[];
|
||||
/** AI 聊天独立浮动窗口(单例,会话态不持久化) */
|
||||
detachedAIChatWindow: DetachedAIChatWindow | null;
|
||||
/** AI 独立窗上次尺寸/位置(持久化,再次打开时复用) */
|
||||
aiChatDetachedBoundsMemory: AIChatDetachedBoundsMemory | null;
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
@@ -1374,6 +1383,8 @@ interface AppState {
|
||||
|
||||
// AI 运行时与持久化状态
|
||||
aiPanelVisible: boolean;
|
||||
/** 打开 AI 时的默认形态:侧栏 dock 或独立窗口 detached(持久化) */
|
||||
aiChatOpenMode: AIChatOpenMode;
|
||||
aiChatHistory: Record<string, AIChatMessage[]>; // sessionId -> messages
|
||||
replaceAIChatHistory: (sessionId: string, messages: AIChatMessage[]) => void;
|
||||
aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表
|
||||
@@ -1580,6 +1591,16 @@ interface AppState {
|
||||
// AI actions
|
||||
toggleAIPanel: () => void;
|
||||
setAIPanelVisible: (visible: boolean) => void;
|
||||
setAIChatOpenMode: (mode: AIChatOpenMode) => void;
|
||||
detachAIChatPanel: (
|
||||
preferred?: Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
) => void;
|
||||
attachAIChatPanel: () => void;
|
||||
updateDetachedAIChatBounds: (
|
||||
bounds: Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
) => void;
|
||||
focusDetachedAIChatPanel: () => void;
|
||||
isAIChatDetached: () => boolean;
|
||||
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
|
||||
updateAIChatMessage: (
|
||||
sessionId: string,
|
||||
@@ -2378,6 +2399,42 @@ const sanitizeWindowState = (
|
||||
return "normal";
|
||||
};
|
||||
|
||||
const sanitizeAIChatOpenMode = (value: unknown): AIChatOpenMode => {
|
||||
return value === "detached" ? "detached" : "dock";
|
||||
};
|
||||
|
||||
const sanitizeAIChatDetachedBoundsMemory = (
|
||||
value: unknown,
|
||||
): AIChatDetachedBoundsMemory | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const width = Number(raw.width);
|
||||
const height = Number(raw.height);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const x = Number(raw.x);
|
||||
const y = Number(raw.y);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
x: Number.isFinite(x) ? x : 0,
|
||||
y: Number.isFinite(y) ? y : 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** 打开/弹出 AI 独立窗时,在记忆尺寸上叠加本次 preferred */
|
||||
const resolveAIChatDetachPreferred = (
|
||||
memory: AIChatDetachedBoundsMemory | null,
|
||||
preferred?: Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
): Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">> | undefined => {
|
||||
if (!memory && !preferred) return preferred;
|
||||
return {
|
||||
...(memory ?? {}),
|
||||
...(preferred ?? {}),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeWindowBounds = (
|
||||
value: unknown,
|
||||
): { width: number; height: number; x: number; y: number } | null => {
|
||||
@@ -2559,6 +2616,8 @@ export const useStore = create<AppState>()(
|
||||
tabs: [],
|
||||
detachedWorkbenchWindows: [],
|
||||
detachedQueryResultWindows: [],
|
||||
detachedAIChatWindow: null,
|
||||
aiChatDetachedBoundsMemory: null,
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
@@ -2608,6 +2667,7 @@ export const useStore = create<AppState>()(
|
||||
|
||||
// AI 运行状态
|
||||
aiPanelVisible: false,
|
||||
aiChatOpenMode: "dock" as AIChatOpenMode,
|
||||
aiChatHistory: {},
|
||||
aiChatSessions: [],
|
||||
aiActiveSessionId: null,
|
||||
@@ -4001,8 +4061,182 @@ export const useStore = create<AppState>()(
|
||||
|
||||
// AI actions
|
||||
toggleAIPanel: () =>
|
||||
set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
|
||||
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
|
||||
set((state) => {
|
||||
const nextVisible = !state.aiPanelVisible;
|
||||
// 关闭面板时一并收起独立窗,并记下尺寸
|
||||
if (!nextVisible) {
|
||||
const memory = state.detachedAIChatWindow
|
||||
? toAIChatDetachedBoundsMemory(state.detachedAIChatWindow)
|
||||
: state.aiChatDetachedBoundsMemory;
|
||||
return {
|
||||
aiPanelVisible: false,
|
||||
detachedAIChatWindow: null,
|
||||
aiChatDetachedBoundsMemory: memory,
|
||||
};
|
||||
}
|
||||
// 按默认打开形态展开
|
||||
if (state.aiChatOpenMode === "detached") {
|
||||
const peers = [
|
||||
...state.detachedWorkbenchWindows,
|
||||
...state.detachedQueryResultWindows,
|
||||
...(state.detachedAIChatWindow ? [state.detachedAIChatWindow] : []),
|
||||
];
|
||||
if (state.detachedAIChatWindow) {
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: {
|
||||
...state.detachedAIChatWindow,
|
||||
zIndex: nextDetachedZIndex(peers),
|
||||
},
|
||||
};
|
||||
}
|
||||
const nextBounds = createDefaultDetachedBounds(
|
||||
peers,
|
||||
resolveAIChatDetachPreferred(state.aiChatDetachedBoundsMemory),
|
||||
"ai-chat",
|
||||
);
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: nextBounds,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(nextBounds),
|
||||
};
|
||||
}
|
||||
// 侧栏打开:若仍挂着独立窗则先记下尺寸再收拢
|
||||
if (state.detachedAIChatWindow) {
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: null,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(
|
||||
state.detachedAIChatWindow,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { aiPanelVisible: true, detachedAIChatWindow: null };
|
||||
}),
|
||||
setAIPanelVisible: (visible) =>
|
||||
set((state) => {
|
||||
if (!visible) {
|
||||
const memory = state.detachedAIChatWindow
|
||||
? toAIChatDetachedBoundsMemory(state.detachedAIChatWindow)
|
||||
: state.aiChatDetachedBoundsMemory;
|
||||
return {
|
||||
aiPanelVisible: false,
|
||||
detachedAIChatWindow: null,
|
||||
aiChatDetachedBoundsMemory: memory,
|
||||
};
|
||||
}
|
||||
if (state.aiChatOpenMode === "detached") {
|
||||
const peers = [
|
||||
...state.detachedWorkbenchWindows,
|
||||
...state.detachedQueryResultWindows,
|
||||
...(state.detachedAIChatWindow ? [state.detachedAIChatWindow] : []),
|
||||
];
|
||||
if (state.detachedAIChatWindow) {
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: {
|
||||
...state.detachedAIChatWindow,
|
||||
zIndex: nextDetachedZIndex(peers),
|
||||
},
|
||||
};
|
||||
}
|
||||
const nextBounds = createDefaultDetachedBounds(
|
||||
peers,
|
||||
resolveAIChatDetachPreferred(state.aiChatDetachedBoundsMemory),
|
||||
"ai-chat",
|
||||
);
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: nextBounds,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(nextBounds),
|
||||
};
|
||||
}
|
||||
// 默认侧栏:打开时若之前是独立窗则收拢回侧栏,并记下尺寸
|
||||
if (state.detachedAIChatWindow) {
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: null,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(
|
||||
state.detachedAIChatWindow,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { aiPanelVisible: true, detachedAIChatWindow: null };
|
||||
}),
|
||||
setAIChatOpenMode: (mode) =>
|
||||
set({ aiChatOpenMode: sanitizeAIChatOpenMode(mode) }),
|
||||
detachAIChatPanel: (preferred) =>
|
||||
set((state) => {
|
||||
const peers = [
|
||||
...state.detachedWorkbenchWindows,
|
||||
...state.detachedQueryResultWindows,
|
||||
...(state.detachedAIChatWindow ? [state.detachedAIChatWindow] : []),
|
||||
];
|
||||
if (state.detachedAIChatWindow) {
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: {
|
||||
...state.detachedAIChatWindow,
|
||||
zIndex: nextDetachedZIndex(peers),
|
||||
},
|
||||
};
|
||||
}
|
||||
const nextBounds = createDefaultDetachedBounds(
|
||||
peers,
|
||||
resolveAIChatDetachPreferred(state.aiChatDetachedBoundsMemory, preferred),
|
||||
"ai-chat",
|
||||
);
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: nextBounds,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(nextBounds),
|
||||
};
|
||||
}),
|
||||
attachAIChatPanel: () =>
|
||||
set((state) => {
|
||||
if (!state.detachedAIChatWindow) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
aiPanelVisible: true,
|
||||
detachedAIChatWindow: null,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(
|
||||
state.detachedAIChatWindow,
|
||||
),
|
||||
};
|
||||
}),
|
||||
updateDetachedAIChatBounds: (bounds) =>
|
||||
set((state) => {
|
||||
if (!state.detachedAIChatWindow) {
|
||||
return state;
|
||||
}
|
||||
const nextWindow = {
|
||||
...state.detachedAIChatWindow,
|
||||
...bounds,
|
||||
};
|
||||
return {
|
||||
detachedAIChatWindow: nextWindow,
|
||||
aiChatDetachedBoundsMemory: toAIChatDetachedBoundsMemory(nextWindow),
|
||||
};
|
||||
}),
|
||||
focusDetachedAIChatPanel: () =>
|
||||
set((state) => {
|
||||
if (!state.detachedAIChatWindow) {
|
||||
return state;
|
||||
}
|
||||
const peers = [
|
||||
...state.detachedWorkbenchWindows,
|
||||
...state.detachedQueryResultWindows,
|
||||
state.detachedAIChatWindow,
|
||||
];
|
||||
return {
|
||||
detachedAIChatWindow: {
|
||||
...state.detachedAIChatWindow,
|
||||
zIndex: nextDetachedZIndex(peers),
|
||||
},
|
||||
};
|
||||
}),
|
||||
isAIChatDetached: () => Boolean(get().detachedAIChatWindow),
|
||||
addAIChatMessage: (sessionId, message) => {
|
||||
set((state) => {
|
||||
const history = { ...state.aiChatHistory };
|
||||
@@ -4304,6 +4538,10 @@ export const useStore = create<AppState>()(
|
||||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||||
nextState.aiChatOpenMode = sanitizeAIChatOpenMode(state.aiChatOpenMode);
|
||||
nextState.aiChatDetachedBoundsMemory = sanitizeAIChatDetachedBoundsMemory(
|
||||
state.aiChatDetachedBoundsMemory,
|
||||
);
|
||||
|
||||
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
|
||||
nextState.aiChatHistory =
|
||||
@@ -4349,6 +4587,7 @@ export const useStore = create<AppState>()(
|
||||
// Floating windows are session-only and must not be restored from disk.
|
||||
detachedWorkbenchWindows: [],
|
||||
detachedQueryResultWindows: [],
|
||||
detachedAIChatWindow: null,
|
||||
activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs),
|
||||
savedQueries: currentState.savedQueries,
|
||||
externalSQLDirectories: sanitizeExternalSQLDirectories(
|
||||
@@ -4382,6 +4621,10 @@ export const useStore = create<AppState>()(
|
||||
windowBounds: sanitizeWindowBounds(state.windowBounds),
|
||||
windowState: sanitizeWindowState(state.windowState),
|
||||
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
|
||||
aiChatOpenMode: sanitizeAIChatOpenMode(state.aiChatOpenMode),
|
||||
aiChatDetachedBoundsMemory: sanitizeAIChatDetachedBoundsMemory(
|
||||
state.aiChatDetachedBoundsMemory,
|
||||
),
|
||||
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
@@ -4416,6 +4659,10 @@ export const useStore = create<AppState>()(
|
||||
uiScale: state.uiScale,
|
||||
fontSize: state.fontSize,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
aiChatOpenMode: sanitizeAIChatOpenMode(state.aiChatOpenMode),
|
||||
aiChatDetachedBoundsMemory: sanitizeAIChatDetachedBoundsMemory(
|
||||
state.aiChatDetachedBoundsMemory,
|
||||
),
|
||||
globalProxy:
|
||||
toTrimmedString(state.globalProxy.password) !== ""
|
||||
? { ...state.globalProxy }
|
||||
|
||||
@@ -1500,13 +1500,13 @@ body[data-ui-version="v2"] .gn-v2-ai-attachment-file button {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-box {
|
||||
min-height: 68px;
|
||||
min-height: 104px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-surface {
|
||||
min-height: 68px;
|
||||
padding: 6px 6px 6px 10px;
|
||||
min-height: 104px;
|
||||
padding: 10px 8px 8px 12px;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 10px !important;
|
||||
background: var(--gn-bg-input) !important;
|
||||
@@ -1556,46 +1556,87 @@ body[data-ui-version="v2"] .gn-v2-ai-input-actions .ant-btn {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* 单行紧凑工具条:
|
||||
* [连接] [模型] [思考] ········· [token]
|
||||
* 全部固定/内容宽度,模型下拉绝不拉满整行。
|
||||
*/
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 4px;
|
||||
padding-top: 2px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-chip,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-token-meter {
|
||||
height: 20px;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px !important;
|
||||
padding: 0 7px;
|
||||
border-radius: 6px !important;
|
||||
border: 0.5px solid var(--gn-br-1) !important;
|
||||
background: var(--gn-bg-active) !important;
|
||||
color: var(--gn-fg-4) !important;
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 10.5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 连接芯片:内容宽度 + 上限 */
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-chip {
|
||||
flex: 1 1 auto;
|
||||
max-width: 180px;
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
max-width: 112px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-chip > span:last-child {
|
||||
/* Tooltip 外包层:不破坏 flex 子项收缩 */
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-bar > span {
|
||||
display: inline-flex !important;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-bar > span:has(.gn-v2-ai-context-chip) {
|
||||
max-width: 112px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-bar > span:has(.gn-v2-ai-token-meter) {
|
||||
flex: 0 0 auto;
|
||||
max-width: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-token-meter {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 无 Tooltip 包裹时 token 也靠右 */
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-bar > .gn-v2-ai-token-meter {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-chip > .anticon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-chip-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-live-dot {
|
||||
@@ -1606,18 +1647,37 @@ body[data-ui-version="v2"] .gn-v2-ai-context-live-dot {
|
||||
background: var(--gn-accent);
|
||||
}
|
||||
|
||||
/* 模型 / 思考:固定宽度,禁止被 flex 拉长 */
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select {
|
||||
width: 132px !important;
|
||||
width: 128px !important;
|
||||
min-width: 128px !important;
|
||||
max-width: 128px !important;
|
||||
height: 26px !important;
|
||||
flex: 0 0 132px;
|
||||
flex: 0 0 128px !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selector {
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select {
|
||||
width: 52px !important;
|
||||
min-width: 52px !important;
|
||||
max-width: 52px !important;
|
||||
height: 26px !important;
|
||||
flex: 0 0 52px !important;
|
||||
}
|
||||
|
||||
/* 输入区允许 IME 候选层正常定位,避免被裁切/残留 */
|
||||
body[data-ui-version="v2"] .gn-v2-ai-composer,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-box,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-surface {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selector,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selector {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 26px !important;
|
||||
min-height: 26px !important;
|
||||
padding: 0 28px 0 10px !important;
|
||||
padding: 0 22px 0 8px !important;
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 7px !important;
|
||||
background: var(--gn-bg-panel) !important;
|
||||
@@ -1627,23 +1687,27 @@ body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selector {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select.ant-select-focused .ant-select-selector,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select:hover .ant-select-selector {
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select:hover .ant-select-selector,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select.ant-select-focused .ant-select-selector,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select:hover .ant-select-selector {
|
||||
border-color: var(--gn-info) !important;
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12) !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search {
|
||||
left: 10px !important;
|
||||
right: 28px !important;
|
||||
inset-inline-start: 10px !important;
|
||||
inset-inline-end: 28px !important;
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-search {
|
||||
left: 8px !important;
|
||||
right: 22px !important;
|
||||
inset-inline-start: 8px !important;
|
||||
inset-inline-end: 22px !important;
|
||||
height: 24px !important;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search-input {
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search-input,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-search-input {
|
||||
height: 24px !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
@@ -1657,7 +1721,9 @@ body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search-i
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-item,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-placeholder {
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-placeholder,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-item,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-placeholder {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 24px !important;
|
||||
@@ -1665,19 +1731,28 @@ body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-placehol
|
||||
padding-inline-end: 0 !important;
|
||||
background: transparent !important;
|
||||
color: var(--gn-fg-2) !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-arrow {
|
||||
inset-inline-end: 9px !important;
|
||||
body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-arrow,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-arrow {
|
||||
inset-inline-end: 7px !important;
|
||||
color: var(--gn-fg-4) !important;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-token-meter {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
gap: 5px !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-token-meter-text {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-token-meter.is-warn {
|
||||
border-color: color-mix(in srgb, var(--gn-warn) 35%, transparent) !important;
|
||||
color: var(--gn-warn) !important;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveDetachedWindowTitle,
|
||||
resolveResultDetachPreferredBounds,
|
||||
shouldDetachTabByDrag,
|
||||
toAIChatDetachedBoundsMemory,
|
||||
} from './detachedWindow';
|
||||
|
||||
describe('detachedWindow helpers', () => {
|
||||
@@ -43,4 +44,27 @@ describe('detachedWindow helpers', () => {
|
||||
expect(resolveResultDetachPreferredBounds(200, 300)).toEqual({ x: 80, y: 276 });
|
||||
expect(resolveResultDetachPreferredBounds(10, 10)).toEqual({ x: 16, y: 16 });
|
||||
});
|
||||
|
||||
it('snapshots AI chat detached bounds for size memory', () => {
|
||||
expect(
|
||||
toAIChatDetachedBoundsMemory({
|
||||
x: 12,
|
||||
y: 34,
|
||||
width: 480,
|
||||
height: 560,
|
||||
}),
|
||||
).toEqual({ x: 12, y: 34, width: 480, height: 560 });
|
||||
});
|
||||
|
||||
it('reuses preferred size when building AI chat floating bounds', () => {
|
||||
const bounds = createDefaultDetachedBounds(
|
||||
[],
|
||||
{ width: 520, height: 560, x: 40, y: 60 },
|
||||
'ai-chat',
|
||||
);
|
||||
expect(bounds.width).toBe(520);
|
||||
expect(bounds.height).toBe(560);
|
||||
expect(bounds.x).toBe(40);
|
||||
expect(bounds.y).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,11 +41,33 @@ export type DetachedQueryResultWindow = DetachedWindowBounds & {
|
||||
result: DetachedQueryResultSnapshot;
|
||||
};
|
||||
|
||||
/** AI 聊天独立浮动窗(单例,会话态;尺寸/位置记忆另存) */
|
||||
export type DetachedAIChatWindow = DetachedWindowBounds;
|
||||
|
||||
/** 独立窗上次尺寸与位置(持久化,再次打开时复用) */
|
||||
export type AIChatDetachedBoundsMemory = Pick<
|
||||
DetachedWindowBounds,
|
||||
'x' | 'y' | 'width' | 'height'
|
||||
>;
|
||||
|
||||
export const toAIChatDetachedBoundsMemory = (
|
||||
bounds: Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>,
|
||||
): AIChatDetachedBoundsMemory => ({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
|
||||
export const DETACH_TAB_DRAG_Y_THRESHOLD = 56;
|
||||
export const DEFAULT_DETACHED_WINDOW_WIDTH = 960;
|
||||
export const DEFAULT_DETACHED_WINDOW_HEIGHT = 640;
|
||||
export const DEFAULT_DETACHED_WINDOW_MIN_WIDTH = 480;
|
||||
export const DEFAULT_DETACHED_WINDOW_MIN_HEIGHT = 320;
|
||||
export const DEFAULT_DETACHED_AI_CHAT_WIDTH = 440;
|
||||
export const DEFAULT_DETACHED_AI_CHAT_HEIGHT = 720;
|
||||
export const DEFAULT_DETACHED_AI_CHAT_MIN_WIDTH = 360;
|
||||
export const DEFAULT_DETACHED_AI_CHAT_MIN_HEIGHT = 420;
|
||||
export const DETACHED_WINDOW_VIEWPORT_PADDING = 16;
|
||||
|
||||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
@@ -78,17 +100,22 @@ const getViewportSize = () => {
|
||||
export const createDefaultDetachedBounds = (
|
||||
windows: Array<{ zIndex?: number }>,
|
||||
preferred?: Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>,
|
||||
sizePreset: 'workbench' | 'ai-chat' = 'workbench',
|
||||
): DetachedWindowBounds => {
|
||||
const viewport = getViewportSize();
|
||||
const defaultWidth = sizePreset === 'ai-chat' ? DEFAULT_DETACHED_AI_CHAT_WIDTH : DEFAULT_DETACHED_WINDOW_WIDTH;
|
||||
const defaultHeight = sizePreset === 'ai-chat' ? DEFAULT_DETACHED_AI_CHAT_HEIGHT : DEFAULT_DETACHED_WINDOW_HEIGHT;
|
||||
const minWidth = sizePreset === 'ai-chat' ? DEFAULT_DETACHED_AI_CHAT_MIN_WIDTH : DEFAULT_DETACHED_WINDOW_MIN_WIDTH;
|
||||
const minHeight = sizePreset === 'ai-chat' ? DEFAULT_DETACHED_AI_CHAT_MIN_HEIGHT : DEFAULT_DETACHED_WINDOW_MIN_HEIGHT;
|
||||
const width = clamp(
|
||||
Number(preferred?.width) || DEFAULT_DETACHED_WINDOW_WIDTH,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
Math.max(DEFAULT_DETACHED_WINDOW_MIN_WIDTH, viewport.width - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
Number(preferred?.width) || defaultWidth,
|
||||
minWidth,
|
||||
Math.max(minWidth, viewport.width - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
);
|
||||
const height = clamp(
|
||||
Number(preferred?.height) || DEFAULT_DETACHED_WINDOW_HEIGHT,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
Math.max(DEFAULT_DETACHED_WINDOW_MIN_HEIGHT, viewport.height - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
Number(preferred?.height) || defaultHeight,
|
||||
minHeight,
|
||||
Math.max(minHeight, viewport.height - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
);
|
||||
const cascade = (windows.length % 8) * 28;
|
||||
const defaultX = Math.max(
|
||||
|
||||
Reference in New Issue
Block a user