feat(ai-chat): 支持侧栏/独立窗默认打开、双向切换与尺寸记忆

- 新增 AI 独立浮动窗,标题栏可拖拽缩放并与侧栏双向切换
- 设置中可配置默认打开方式(侧栏/独立窗),偏好持久化
- 记忆独立窗上次位置与尺寸,再次打开自动恢复
- 优化输入区默认高度与底部模型/思考/用量工具条布局
- 去掉独立窗外层重复标题栏,由面板 header 统一承载操作
This commit is contained in:
Syngnat
2026-07-09 12:18:51 +08:00
parent bc3f76dd73
commit c77d338ab1
11 changed files with 1038 additions and 97 deletions

View File

@@ -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}

View File

@@ -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' && (

View 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;

View File

@@ -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}

View File

@@ -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');

View File

@@ -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>
);
};

View File

@@ -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();

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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(