feat(ai-chat): 全面升级AI聊天面板并优化交互体验

- 消息管理:新增聊天气泡的重试、编辑与单条删除功能及相对应的持久化状态函数
- 快捷操作:支持长文一键滑动到底端,并在代码块内增加SQL一键送入编辑器的快捷执行机制
- 视觉优化:深化AI回复背景沉浸感,重绘AI洞察按钮并移除设置面板所有的冗余紫色调
- 设置调优:放宽模型初始必填限制,新增内置系统提示词(Builtin Prompt)全览面板
This commit is contained in:
Syngnat
2026-03-22 20:54:29 +08:00
parent 36a57f9601
commit 1bda751ada
35 changed files with 6745 additions and 132 deletions

View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>AI UI Brainstorming Prototypes</title>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Ant Design -->
<script src="https://unpkg.com/dayjs/dayjs.min.js"></script>
<script src="https://unpkg.com/antd/dist/antd.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/antd/dist/reset.css" />
<!-- Icons -->
<script src="https://unpkg.com/@ant-design/icons/dist/index.umd.js"></script>
<style>
body { padding: 40px; background: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
.prototype-container { display: flex; gap: 40px; }
.prototype-column { flex: 1; max-width: 600px; background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); overflow: hidden; }
.prototype-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; background: #fafafa; font-weight: bold; }
.prototype-body { padding: 24px; }
/* Default App Theme Colors (Light Mode) */
:root {
--gn-border: rgba(16,24,40,0.08);
--gn-bg: rgba(255,255,255,0.84);
--gn-text: #162033;
--gn-muted: rgba(16,24,40,0.55);
--gn-primary: #1677ff;
--gn-primary-bg: rgba(24,144,255,0.1);
}
/* V1 Styles: Professional List */
.v1-list-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; margin-bottom: 8px; border-radius: 8px;
border: 1px solid transparent; cursor: pointer; transition: all 0.2s;
}
.v1-list-item:hover { background: #f5f5f5; }
.v1-list-item.selected {
background: var(--gn-primary-bg); border-color: var(--gn-primary);
}
/* V2 Styles: Refined Cards (ConnectionModal Style) */
.v2-card-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
}
.v2-card {
padding: 16px; border-radius: 12px; border: 1px solid var(--gn-border);
cursor: pointer; transition: all 0.2s; background: white;
box-shadow: inset 0 0 0 1px rgba(16,24,40,0.01);
}
.v2-card:hover { border-color: #d9d9d9; background: #fafafa; }
.v2-card.selected {
border-color: var(--gn-primary); box-shadow: 0 0 0 1px var(--gn-primary) inset;
}
.section-title { font-size: 13px; font-weight: 600; color: var(--gn-muted); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState } = React;
const { Input, Slider, Select, Button, Form, ConfigProvider } = antd;
const { ThunderboltOutlined, CloudOutlined, ExperimentOutlined, AppstoreOutlined, SettingOutlined, LinkOutlined, KeyOutlined } = icons;
const PROVIDERS = [
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-4o / o1' },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'V3 / R1' },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Sonnet 3.5' },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '通用 API' },
];
const V1ListDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案一IDE 专业列表风格 (更克制无彩色渐变)</div>
<div className="prototype-body">
<div className="section-title">提供商选择</div>
<div style={{ marginBottom: 24, padding: 8, background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0' }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v1-list-item ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32, height: 32, borderRadius: 6, display: 'grid', placeItems: 'center',
background: selected === p.key ? '#1677ff' : '#e6f4ff',
color: selected === p.key ? '#fff' : '#1677ff', fontSize: 16
}}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 500, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)' }}>{p.desc}</div>
</div>
</div>
<div style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selected === p.key ? 'var(--gn-primary)' : '#d9d9d9'}`, padding: 2 }}>
{selected === p.key && <div style={{ width: '100%', height: '100%', background: 'var(--gn-primary)', borderRadius: '50%' }} />}
</div>
</div>
))}
</div>
<div className="section-title">连接配置 (紧凑表单)</div>
<Form layout="vertical" size="middle">
<Form.Item label="API Endpoint">
<Input placeholder="https://api.openai.com/v1" prefix={<LinkOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="API Key">
<Input.Password placeholder="sk-..." prefix={<KeyOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
<Form.Item label="Model Name">
<Input placeholder="gpt-4o" prefix={<AppstoreOutlined style={{color: 'var(--gn-muted)'}}/>} />
</Form.Item>
</Form>
</div>
</div>
);
};
const V2CardDesign = () => {
const [selected, setSelected] = useState('openai');
return (
<div className="prototype-column">
<div className="prototype-header">方案二GoNavi 统一卡片风格 (类似 ConnectionModal)</div>
<div className="prototype-body">
<div className="section-title">选择服务提供商</div>
<div className="v2-card-grid" style={{ marginBottom: 24 }}>
{PROVIDERS.map(p => (
<div key={p.key} className={`v2-card ${selected === p.key ? 'selected' : ''}`} onClick={() => setSelected(p.key)}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ color: selected === p.key ? 'var(--gn-primary)' : 'var(--gn-muted)', fontSize: 20, marginTop: 2 }}>
{p.icon}
</div>
<div>
<div style={{ fontWeight: 600, color: 'var(--gn-text)', fontSize: 14 }}>{p.label}</div>
<div style={{ fontSize: 12, color: 'var(--gn-muted)', marginTop: 4 }}>{p.desc}</div>
</div>
</div>
</div>
))}
</div>
<div style={{ padding: 20, borderRadius: 12, border: '1px solid var(--gn-border)', background: '#fafafa' }}>
<div className="section-title" style={{ marginTop: 0 }}>认证与设置</div>
<Form layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} size="middle">
<Form.Item label="Endpoint" style={{ marginBottom: 16 }}>
<Input placeholder="https://api..." />
</Form.Item>
<Form.Item label="API Key" style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item label="模型名称" style={{ marginBottom: 0 }}>
<Input placeholder="例如 gpt-4o" />
</Form.Item>
</Form>
</div>
</div>
</div>
);
};
const App = () => (
<ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}>
<div style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 24, margin: 0 }}>AI 设置 UI 重构探讨</h1>
<p style={{ color: 'var(--gn-muted)' }}>当前设计带有太多渐变和鲜艳色彩"AI 味"以下是遵循 GoNavi 本身设计规范克制专业的两个方案</p>
</div>
<div className="prototype-container">
<V1ListDesign />
<V2CardDesign />
</div>
</ConfigProvider>
);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,15 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.12.0",
"clsx": "^2.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-resizable": "^3.1.3",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"sql-formatter": "^15.7.0",
"uuid": "^9.0.1",
"zustand": "^4.4.7"

View File

@@ -1 +1 @@
0f60775ad0a6b251a4320748f196a712
30f0a7ce75c113ec7a46f3b09f9a37f7

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Tooltip } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
@@ -9,6 +9,8 @@ import ConnectionModal from './components/ConnectionModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LogPanel from './components/LogPanel';
import AIChatPanel from './components/AIChatPanel';
import AISettingsModal from './components/AISettingsModal';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
@@ -92,6 +94,9 @@ function App() {
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const toggleAIPanel = useStore(state => state.toggleAIPanel);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const globalProxyInvalidHintShownRef = React.useRef(false);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
@@ -1108,6 +1113,7 @@ function App() {
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [isAISettingsOpen, setIsAISettingsOpen] = useState(false);
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
@@ -1620,11 +1626,24 @@ function App() {
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: isSidebarNarrow ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}>{isSidebarUltraCompact ? null : '工具'}</Button>
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>{isSidebarUltraCompact ? null : '代理'}</Button>
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>{isSidebarUltraCompact ? null : '主题'}</Button>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>{isSidebarUltraCompact ? null : '关于'}</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<Tooltip title="工具"><Button type="text" icon={<ToolOutlined />} style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)} /></Tooltip>
<Tooltip title="代理"><Button type="text" icon={<GlobalOutlined />} style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)} /></Tooltip>
<Tooltip title="主题"><Button type="text" icon={<SkinOutlined />} style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)} /></Tooltip>
<Tooltip title="关于"><Button type="text" icon={<InfoCircleOutlined />} style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)} /></Tooltip>
<div style={{ width: 1, height: 16, background: 'rgba(128,128,128,0.2)', margin: '0 4px' }} />
<Tooltip title="AI 助手">
<Button
type="text"
icon={<RobotOutlined />}
onClick={toggleAIPanel}
style={{
...utilityButtonStyle,
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : utilityButtonStyle.color,
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)') : 'transparent'
}}
/>
</Tooltip>
</div>
</div>
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
@@ -1638,6 +1657,7 @@ function App() {
</div>
</div>
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: 58 }}>
<Sidebar onEditConnection={handleEditConnection} />
</div>
@@ -1696,9 +1716,14 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
<TabManager />
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
<TabManager />
</div>
{aiPanelVisible && (
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
)}
</div>
{isLogPanelOpen && (
<LogPanel
@@ -1797,6 +1822,12 @@ function App() {
onClose={() => setIsDriverModalOpen(false)}
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
/>
<AISettingsModal
open={isAISettingsOpen}
onClose={() => setIsAISettingsOpen(false)}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
<Modal
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
open={isAboutOpen}

View File

@@ -0,0 +1,366 @@
.ai-chat-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
border-left: 1px solid rgba(128, 128, 128, 0.12);
position: relative;
}
/* Resize Handle */
.ai-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background 0.15s ease;
}
.ai-resize-handle:hover,
.ai-resize-handle.active {
background: rgba(22, 119, 255, 0.5);
}
/* Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
.ai-chat-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.ai-chat-header-left .ai-logo {
width: 28px;
height: 28px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.ai-chat-header-left .ai-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.01em;
}
.ai-chat-header-right {
display: flex;
align-items: center;
gap: 4px;
}
/* Messages Area */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-chat-messages::-webkit-scrollbar {
width: 5px;
}
.ai-chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-messages::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 3px;
}
/* Welcome */
.ai-chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 40px 20px;
text-align: center;
flex: 1;
}
.ai-chat-welcome .welcome-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: grid;
place-items: center;
font-size: 28px;
}
.ai-chat-welcome .welcome-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
}
.ai-chat-welcome .quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.ai-chat-welcome .quick-action-btn {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid;
}
.ai-chat-welcome .quick-action-btn:hover {
background: rgba(99, 102, 241, 0.12) !important;
border-color: rgba(99, 102, 241, 0.3) !important;
color: #818cf8 !important;
}
/* IDE Style Messages */
.ai-ide-message {
padding: 12px 16px;
animation: ai-msg-in 0.2s ease-out;
}
@keyframes ai-msg-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-ide-message-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.ai-ide-message-content {
font-size: 13px;
line-height: 1.6;
word-break: break-word;
/* Remove pre-wrap here, as it conflicts with ReactMarkdown's block rendering */
}
/* Markdown Styles Override */
.ai-markdown-content {
white-space: normal;
}
.ai-markdown-content p {
margin: 0 0 10px;
}
.ai-markdown-content p:last-child {
margin-bottom: 0;
}
.ai-markdown-content h1,
.ai-markdown-content h2,
.ai-markdown-content h3,
.ai-markdown-content h4,
.ai-markdown-content h5,
.ai-markdown-content h6 {
margin: 16px 0 8px;
line-height: 1.4;
font-weight: 600;
}
.ai-markdown-content h1:first-child,
.ai-markdown-content h2:first-child,
.ai-markdown-content h3:first-child,
.ai-markdown-content h4:first-child,
.ai-markdown-content h5:first-child,
.ai-markdown-content h6:first-child {
margin-top: 0;
}
.ai-markdown-content pre {
margin: 10px 0;
border-radius: 4px;
padding: 10px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
overflow-x: auto;
border: 1px solid rgba(128, 128, 128, 0.15);
background: rgba(0, 0, 0, 0.2);
}
.ai-markdown-content code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: rgba(128, 128, 128, 0.15);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.95em;
}
.ai-markdown-content ul, .ai-markdown-content ol {
margin: 0 0 10px;
padding-left: 20px;
}
.ai-markdown-content li {
margin-bottom: 4px;
}
/* Advanced Typing/Blinker indicator */
.ai-blinking-cursor {
display: inline-block;
width: 6px;
height: 14px;
background-color: currentColor;
border-radius: 1px;
vertical-align: middle;
margin-left: 4px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes ai-dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* History Drawer Styles */
.ai-history-list::-webkit-scrollbar {
width: 4px;
}
.ai-history-list::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.2);
border-radius: 4px;
}
.ai-history-list:hover::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.4);
}
.ai-history-item:hover {
background: rgba(128, 128, 128, 0.08) !important;
}
.ai-history-item .ai-history-delete-btn {
opacity: 0;
transition: opacity 0.2s, background 0.2s;
}
.ai-history-item:hover .ai-history-delete-btn,
.ai-history-item.active .ai-history-delete-btn {
opacity: 1;
}
/* Input Area */
.ai-chat-input-area {
padding: 12px 16px 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
flex-shrink: 0;
}
/* Textarea scrollbar */
.ai-chat-input-wrapper textarea {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar {
width: 4px;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-input-wrapper textarea::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.ai-chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
border-radius: 6px;
border: 1px solid transparent;
border-bottom-color: rgba(128, 128, 128, 0.4);
padding: 6px 10px;
transition: all 0.2s ease;
background: transparent !important;
box-shadow: none !important;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--ant-primary-color, #1677ff) !important;
background: rgba(128, 128, 128, 0.05) !important;
}
.ai-chat-input-wrapper textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
font-size: 13px;
line-height: 1.5;
min-height: 28px;
max-height: 200px;
padding: 0;
font-family: inherit;
overflow-y: auto;
}
.ai-chat-input-wrapper textarea::placeholder {
opacity: 0.4;
}
.ai-chat-send-btn {
width: 26px;
height: 26px;
border-radius: 4px;
display: grid;
place-items: center;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.ai-chat-send-btn:hover {
transform: scale(1.06);
}
.ai-chat-send-btn:active {
transform: scale(0.96);
}
.ai-chat-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.ai-ide-message:hover .ai-message-actions {
opacity: 1 !important;
}

View File

@@ -0,0 +1,848 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Button, Tooltip, Select, Drawer, Input } from 'antd';
import { CloseOutlined, ClearOutlined, SendOutlined, RobotOutlined, SettingOutlined, UserOutlined, CheckOutlined, CopyOutlined, DatabaseOutlined, HistoryOutlined, DeleteOutlined, PlusOutlined, MenuFoldOutlined, PlayCircleOutlined, EditOutlined, ReloadOutlined, DownOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { AIChatMessage } from '../types';
import { EventsOn, EventsOff } from '../../wailsjs/runtime';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import './AIChatPanel.css';
interface AIChatPanelProps {
width?: number;
darkMode: boolean;
bgColor?: string;
onClose: () => void;
onOpenSettings?: () => void;
onWidthChange?: (width: number) => void;
overlayTheme: OverlayWorkbenchTheme;
}
const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const CodeCopyBtn = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
return (
<span
className="ai-code-copy-btn"
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
opacity: copied ? 1 : 0.6,
transition: 'opacity 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }}
>
{copied ? <CheckOutlined style={{ color: '#52c41a' }} /> : <CopyOutlined />}
<span style={{ marginLeft: 4 }}>{copied ? '已复制' : '复制代码'}</span>
</span>
);
};
const CodeRunBtn = ({ text }: { text: string }) => {
return (
<Tooltip title="将该段 SQL 注入查询工作区(可快捷修改或执行)">
<span
className="ai-code-run-btn"
onClick={() => {
window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: { sql: text, runImmediately: false } }));
}}
style={{
cursor: 'pointer', display: 'flex', alignItems: 'center',
opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981'
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }}
>
<PlayCircleOutlined />
<span style={{ marginLeft: 4 }}></span>
</span>
</Tooltip>
);
};
export const AIChatPanel: React.FC<AIChatPanelProps> = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => {
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [activeProvider, setActiveProvider] = useState<any>(null);
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [loadingModels, setLoadingModels] = useState(false);
const [panelWidth, setPanelWidth] = useState(width);
const [isResizing, setIsResizing] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeStartX = useRef(0);
const resizeStartWidth = useRef(0);
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiChatSessions = useStore(state => state.aiChatSessions);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
const createNewAISession = useStore(state => state.createNewAISession);
const deleteAISession = useStore(state => state.deleteAISession);
const addAIChatMessage = useStore(state => state.addAIChatMessage);
const updateAIChatMessage = useStore(state => state.updateAIChatMessage);
const deleteAIChatMessage = useStore(state => state.deleteAIChatMessage);
const truncateAIChatMessages = useStore(state => state.truncateAIChatMessages);
const clearAIChatHistory = useStore(state => state.clearAIChatHistory);
const activeContext = useStore(state => state.activeContext);
const connections = useStore(state => state.connections);
useEffect(() => {
if (!aiActiveSessionId) {
createNewAISession();
}
}, [aiActiveSessionId, createNewAISession]);
const sid = aiActiveSessionId || 'session-fallback';
const getConnectionName = useCallback(() => {
if (!activeContext?.connectionId) return '';
const conn = connections.find(c => c.id === activeContext.connectionId);
return conn ? conn.name : '';
}, [activeContext, connections]);
const activeConnName = getConnectionName();
const messages = aiChatHistory[sid] || [];
// 主题色
const textColor = overlayTheme.titleText;
const mutedColor = overlayTheme.mutedText;
const borderColor = overlayTheme.divider;
const assistantBubbleBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)';
const userBubbleBg = overlayTheme.iconBg;
const inputWrapperBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
const quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
const quickActionBorder = overlayTheme.sectionBorder;
// 获取并监听活动 Provider
const loadActiveProvider = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) return;
const [provRes, activeRes] = await Promise.all([
Service.AIGetProviders?.(),
Service.AIGetActiveProvider?.(),
]);
if (Array.isArray(provRes) && activeRes) {
const current = provRes.find((p: any) => p.id === activeRes);
setActiveProvider(current || null);
}
} catch (e) { console.warn('Failed to load active provider', e); }
}, []);
useEffect(() => { loadActiveProvider(); }, [loadActiveProvider]);
// 模型切换
const handleModelChange = async (val: string) => {
if (!activeProvider) return;
try {
const Service = (window as any).go?.aiservice?.Service;
const payload = { ...activeProvider, model: val };
await Service?.AISaveProvider?.(payload);
setActiveProvider(payload);
} catch (e) { console.warn('Failed to update provider model', e); }
};
// 动态获取模型列表
const fetchDynamicModels = useCallback(async () => {
try {
setLoadingModels(true);
const Service = (window as any).go?.aiservice?.Service;
if (!Service) return;
const result = await Service.AIListModels?.();
if (result?.success && Array.isArray(result.models) && result.models.length > 0) {
console.log('[AI Chat] Dynamic models fetched:', result.models.length, 'models. First 10:', result.models.slice(0, 10));
setDynamicModels(result.models);
}
} catch (e) {
console.warn('Failed to fetch models', e);
} finally {
setLoadingModels(false);
}
}, []);
// 自动滚动到底部(增加对发送状态的判定,实现完美跟随)
useEffect(() => {
if (sending) {
// 流式输出期间,改用 auto 避免动画累加导致的卡顿漂移
messagesEndRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
} else {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
}, [messages, sending]);
// 面板初次打开时,自动聚焦输入框
useEffect(() => {
const timer = setTimeout(() => {
textareaRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
// 监听从 QueryEditor 注入的 prompt
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.prompt) {
setInput(detail.prompt);
// 自动聚焦输入框并调整高度setInput 不触发 onChange需手动重算
setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
}, 50);
}
};
window.addEventListener('gonavi:ai:inject-prompt', handler);
return () => window.removeEventListener('gonavi:ai:inject-prompt', handler);
}, []);
// 流式监听
useEffect(() => {
const eventName = `ai:stream:${sid}`;
let assistantMsgId = '';
const handler = (data: { content?: string; done?: boolean; error?: string }) => {
console.log('[AI Chat] Stream event received:', JSON.stringify(data));
if (data.error) {
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, {
content: `❌ 错误: ${data.error}`,
loading: false,
});
} else {
// 尚未创建 assistant 消息时,新建一条错误消息
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
content: `❌ 错误: ${data.error}`,
timestamp: Date.now(),
});
}
assistantMsgId = '';
setSending(false);
return;
}
if (data.content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
content: data.content,
timestamp: Date.now(),
loading: true,
});
} else {
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find(m => m.id === assistantMsgId);
updateAIChatMessage(sid, assistantMsgId, {
content: (existing?.content || '') + data.content,
});
}
}
if (data.done) {
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { loading: false });
}
assistantMsgId = '';
setSending(false);
}
};
EventsOn(eventName, handler);
console.log('[AI Chat] Listening on event:', eventName);
return () => {
EventsOff(eventName);
};
}, [addAIChatMessage, updateAIChatMessage, sid]);
// ---- 列表滚动逻辑 ----
const handleScrollMessages = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
setShowScrollBottom(!isNearBottom);
}, []);
const scrollToMessagesBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
// ---- 气泡快捷操作 ----
const handleEditMessage = useCallback((msg: AIChatMessage) => {
truncateAIChatMessages(sid, msg.id);
deleteAIChatMessage(sid, msg.id);
setInput(msg.content);
setTimeout(() => textareaRef.current?.focus(), 50);
}, [sid, truncateAIChatMessages, deleteAIChatMessage]);
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
const aiIndex = historyLocal.findIndex(m => m.id === msg.id);
if (aiIndex <= 0) return;
let lastUserMsgIndex = -1;
for (let i = aiIndex - 1; i >= 0; i--) {
if (historyLocal[i].role === 'user') {
lastUserMsgIndex = i;
break;
}
}
if (lastUserMsgIndex >= 0) {
const userMsg = historyLocal[lastUserMsgIndex];
truncateAIChatMessages(sid, userMsg.id); // 保留到该 userInput 后,丢弃之前生成的失败回复
setSending(true);
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content }));
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
await Service.AIChatStream(sid, messagesPayload);
} else if (Service?.AIChatSend) {
const result = await Service.AIChatSend(messagesPayload);
addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `${result?.error || '未知错误'}`,
timestamp: Date.now()
});
setSending(false);
} else {
setSending(false);
}
} catch(e: any) {
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${e?.message || e}`, timestamp: Date.now() });
setSending(false);
}
}
}, [sid, truncateAIChatMessages, addAIChatMessage]);
const handleSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput('');
setSending(true);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; // 回车发送后重置高度
textareaRef.current.focus(); // 保持焦点以便连续对话
}
const userMsg: AIChatMessage = {
id: genId(),
role: 'user',
content: text,
timestamp: Date.now(),
};
addAIChatMessage(sid, userMsg);
// 构建消息列表发给后端
const allMessages = [...messages, userMsg].map(m => ({
role: m.role,
content: m.content,
}));
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
console.log('[AI Chat] Calling AIChatStream, sessionId:', sid, 'messages:', allMessages.length);
await Service.AIChatStream(sid, allMessages);
} else if (Service?.AIChatSend) {
const result = await Service.AIChatSend(allMessages);
const assistantMsg: AIChatMessage = {
id: genId(),
role: 'assistant',
content: result?.success ? result.content : `${result?.error || '未知错误'}`,
timestamp: Date.now(),
};
addAIChatMessage(sid, assistantMsg);
setSending(false);
} else {
const assistantMsg: AIChatMessage = {
id: genId(),
role: 'assistant',
content: '❌ AI Service 未就绪',
timestamp: Date.now(),
};
addAIChatMessage(sid, assistantMsg);
setSending(false);
}
} catch (e: any) {
const errMsg: AIChatMessage = {
id: genId(),
role: 'assistant',
content: `❌ 发送失败: ${e?.message || e}`,
timestamp: Date.now(),
};
addAIChatMessage(sid, errMsg);
setSending(false);
}
}, [input, sending, messages, addAIChatMessage, sid]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
const handleStop = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatCancel) {
await Service.AIChatCancel(sid);
}
} catch (e) {
console.warn('Failed to stop chat stream', e);
}
setSending(false);
}, [sid]);
const handleClear = useCallback(() => {
createNewAISession();
}, [createNewAISession]);
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
const el = e.target;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}, []);
const quickActions = [
{ label: '📝 生成 SQL', prompt: '请根据当前数据库表结构生成一条查询语句:' },
{ label: '🔍 解释 SQL', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
{ label: '⚡ 优化建议', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
];
// ---- 拖拽调整宽度 ----
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
resizeStartX.current = e.clientX;
resizeStartWidth.current = panelWidth;
}, [panelWidth]);
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
// 面板在右侧,鼠标向左移动增大宽度
const delta = resizeStartX.current - e.clientX;
const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700);
setPanelWidth(newWidth);
onWidthChange?.(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing, onWidthChange]);
return (
<div className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
{/* 拖拽手柄 */}
<div
className={`ai-resize-handle${isResizing ? ' active' : ''}`}
onMouseDown={handleResizeStart}
/>
{/* Header */}
<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-left" style={{ gap: 8 }}>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={() => setHistoryOpen(true)} style={{ color: mutedColor }} />
</Tooltip>
<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 }}>
<RobotOutlined />
</div>
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
</div>
{/* Messages */}
<div className="ai-chat-messages" onScroll={handleScrollMessages}>
{messages.length === 0 ? (
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
GoNavi AI
</div>
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
SQL
</div>
<div className="quick-actions">
{quickActions.map(action => (
<div
key={action.label}
className="quick-action-btn"
style={{
background: quickActionBg,
borderColor: quickActionBorder,
color: textColor,
}}
onClick={() => setInput(action.prompt)}
>
{action.label}
</div>
))}
</div>
</div>
) : (
messages.map(msg => {
const isUser = msg.role === 'user';
return (
<div key={msg.id} className="ai-ide-message" style={{
borderBottom: 'none',
padding: '8px 16px',
}}>
<div style={{
background: isUser ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
borderRadius: 12,
padding: '14px 16px',
}}>
<div className="ai-ide-message-header" style={{
color: isUser ? overlayTheme.mutedText : overlayTheme.titleText,
marginBottom: isUser ? 6 : 10,
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
}}>
<div>
{isUser
? <><UserOutlined /> <span>You</span></>
: <><RobotOutlined style={{ color: overlayTheme.iconColor }} /> <span>GoNavi AI</span></>}
</div>
{/* 气泡操作栏 */}
<div className="ai-message-actions" style={{ display: 'flex', gap: 8, opacity: 0, transition: 'opacity 0.2s', padding: '0 4px' }}>
{isUser ? (
<Tooltip title="编辑此条消息(移除其后所有记录并重新发送)">
<EditOutlined className="ai-action-icon" onClick={() => handleEditMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
) : (
<Tooltip title="重新生成(移除此条并触发上次用户输入重发)">
<ReloadOutlined className="ai-action-icon" onClick={() => handleRetryMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
)}
<Tooltip title="删除单条消息">
<DeleteOutlined className="ai-action-icon" onClick={() => deleteAIChatMessage(sid, msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
</Tooltip>
</div>
</div>
<div className="ai-ide-message-content ai-markdown-content" style={{ color: textColor }}>
{isUser ? (
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 13 }}>{msg.content}</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<div className="ai-code-block-container" style={{ margin: '12px 0', border: overlayTheme.sectionBorder, borderRadius: 6, overflow: 'hidden' }}>
<div className="ai-code-header" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '6px 12px', background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
fontSize: 12, color: overlayTheme.mutedText
}}>
<span style={{ fontFamily: 'monospace' }}>{match[1]}</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
{match[1] === 'sql' && <CodeRunBtn text={String(children).replace(/\n$/, '')} />}
<CodeCopyBtn text={String(children).replace(/\n$/, '')} />
</div>
</div>
<SyntaxHighlighter
style={darkMode ? vscDarkPlus as any : vs as any}
language={match[1]}
PreTag="div"
customStyle={{ margin: 0, borderRadius: 0, background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.02)' }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}}
>
{msg.content}
</ReactMarkdown>
)}
{msg.loading && (
<span className="ai-blinking-cursor" style={{ background: overlayTheme.iconColor }} />
)}
</div>
</div>
</div>
);
})
)}
{sending && !messages.some(m => m.role === 'assistant' && m.loading) && (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '4px 0' }}>
<div style={{
background: assistantBubbleBg,
borderRadius: 12,
padding: '12px 16px',
maxWidth: '85%',
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<span style={{ color: mutedColor, fontSize: 13 }}></span>
<span className="ai-thinking-dots" style={{ display: 'inline-flex', gap: 3 }}>
<span style={{ width: 4, height: 4, borderRadius: '50%', background: overlayTheme.iconColor, animation: 'ai-dot-bounce 1.4s infinite ease-in-out', animationDelay: '0s' }} />
<span style={{ width: 4, height: 4, borderRadius: '50%', background: overlayTheme.iconColor, animation: 'ai-dot-bounce 1.4s infinite ease-in-out', animationDelay: '0.2s' }} />
<span style={{ width: 4, height: 4, borderRadius: '50%', background: overlayTheme.iconColor, animation: 'ai-dot-bounce 1.4s infinite ease-in-out', animationDelay: '0.4s' }} />
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Scroll to bottom button */}
{showScrollBottom && (
<div
onClick={scrollToMessagesBottom}
style={{
position: 'absolute',
bottom: 120,
right: 20,
width: 32,
height: 32,
borderRadius: '50%',
background: darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: textColor,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 10,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; }}
>
<DownOutlined style={{ fontSize: 14 }} />
</div>
)}
{/* Input */}
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: '8px 4px 8px'
}}>
<Input.TextArea
ref={textareaRef as any}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown as any}
placeholder="输入消息... (Enter 发送Shift+Enter 换行)"
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
variant="filled"
value={activeProvider.model || (dynamicModels.length > 0 ? dynamicModels[0] : activeProvider.models?.[0])}
onChange={handleModelChange}
onDropdownVisibleChange={(open) => { if (open) fetchDynamicModels(); }}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
style={{ width: 130, fontSize: 11, background: 'transparent' }}
dropdownStyle={{ minWidth: 200 }}
showSearch
placeholder="选择模型"
/>
)}
</div>
{sending ? (
<button
className="ai-chat-send-btn ai-chat-stop-btn"
onClick={handleStop}
title="停止生成"
style={{
background: 'rgba(255,77,79,0.1)',
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
width: 26, height: 26, borderRadius: 6, padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
}}
>
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
</button>
) : (
<button
className="ai-chat-send-btn"
onClick={handleSend}
disabled={!input.trim()}
title="发送"
style={{
background: input.trim() ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
color: input.trim() ? overlayTheme.iconColor : mutedColor,
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: input.trim() ? 'pointer' : 'not-allowed', flexShrink: 0
}}
>
<SendOutlined />
</button>
)}
</div>
</div>
</div>
{/* 历史对话抽屉 */}
<Drawer
placement="left"
closable={false}
onClose={() => setHistoryOpen(false)}
open={historyOpen}
getContainer={false}
style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
width={260}
bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
>
{/* 侧拉面板头部 */}
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor }}></span>
<Tooltip title="收起">
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={() => setHistoryOpen(false)} style={{ color: mutedColor }} />
</Tooltip>
</div>
{/* 新建对话按钮 */}
<div style={{ padding: '0 12px 16px' }}>
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => { createNewAISession(); setHistoryOpen(false); }}
style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
>
</Button>
</div>
{/* 列表容器 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
{aiChatSessions.length === 0 ? (
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}></div>
) : (
aiChatSessions.map(session => (
<div
key={session.id}
className={`ai-history-item ${sid === session.id ? 'active' : ''}`}
onClick={() => { setAIActiveSessionId(session.id); setHistoryOpen(false); }}
style={{
padding: '10px 12px',
borderRadius: 6,
marginBottom: 4,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: sid === session.id ? (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)') : 'transparent',
transition: 'background 0.2s',
}}
>
<div style={{ overflow: 'hidden', flex: 1, paddingRight: 8 }}>
<div style={{ fontSize: 13, color: textColor, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: sid === session.id ? 600 : 'normal' }}>
{session.title || '新对话'}
</div>
<div style={{ fontSize: 11, color: mutedColor, marginTop: 4 }}>
{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<Tooltip title="删除">
<Button
className="ai-history-delete-btn"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
deleteAISession(session.id);
}}
style={{ display: sid === session.id ? 'inline-flex' : undefined }}
/>
</Tooltip>
</div>
))
)}
</div>
</Drawer>
</div>
);
};
export default AIChatPanel;

View File

@@ -0,0 +1,570 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Button, Input, Select, Form, message, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {
open: boolean;
onClose: () => void;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
}
// 预设配置:每个预设映射到后端 typeopenai/anthropic/gemini/custom并附带默认 URL 和 Model
interface ProviderPreset {
key: string;
label: string;
icon: React.ReactNode;
desc: string;
color: string;
backendType: AIProviderType;
defaultBaseUrl: string;
defaultModel: string;
models: string[];
}
const PROVIDER_PRESETS: ProviderPreset[] = [
{ key: 'openai', label: 'OpenAI', icon: <ThunderboltOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.4', models: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-5.3'] },
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: ['deepseek-chat', 'deepseek-reasoner'] },
{ key: 'qwen', label: '通义千问', icon: <CloudOutlined />, desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen3.5-max', models: ['qwen3.5-max', 'qwen3-plus', 'qwen3-turbo'] },
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-5', models: ['glm-5', 'glm-5-turbo', 'glm-4.7-flash'] },
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 系列', color: '#0d9488', backendType: 'openai', defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModel: 'kimi-k2.5', models: ['kimi-k2.5', 'kimi-k2-turbo-preview', 'kimi-k2-thinking'] },
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet 4.6', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-sonnet-4-6', models: ['claude-opus-4-6', 'claude-sonnet-4-6'] },
{ key: 'gemini', label: 'Gemini', icon: <CloudOutlined />, desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: ['gemini-3.1-pro', 'gemini-2.5-flash', 'gemini-2.5-pro'] },
{ key: 'ollama', label: 'Ollama', icon: <AppstoreOutlined />, desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
{ key: 'custom', label: '自定义', icon: <AppstoreOutlined />, desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] },
];
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [
{ label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' },
{ label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE危险操作需二次确认', color: '#f59e0b', icon: '⚠️' },
{ label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL高危操作自动告警', color: '#ef4444', icon: '🔓' },
];
const CONTEXT_OPTIONS: { label: string; value: AIContextLevel; desc: string; icon: string }[] = [
{ label: '仅 Schema', value: 'schema_only', desc: '只传递表/列结构信息给 AI', icon: '📋' },
{ label: '含采样数据', value: 'with_samples', desc: '包含少量采样数据帮助 AI 理解数据特征', icon: '📊' },
{ label: '含查询结果', value: 'with_results', desc: '传递最近的查询结果作为上下文', icon: '📑' },
];
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [form] = Form.useForm();
// 主题色
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)';
const sectionLabelColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
const inputBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
// Hook 必须在组件顶层调用,不能在条件分支内
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const loadConfig = useCallback(async () => {
try {
const Service = (window as any).go?.aiservice?.Service;
if (!Service) { console.warn('[AI] Service not found on window.go'); return; }
const [provRes, safeRes, ctxRes, promptsRes] = await Promise.all([
Service.AIGetProviders?.() || [],
Service.AIGetSafetyLevel?.() || 'readonly',
Service.AIGetContextLevel?.() || 'schema_only',
Service.AIGetBuiltinPrompts?.() || {},
]);
console.log('[AI] AIGetProviders result:', JSON.stringify(provRes), 'isArray:', Array.isArray(provRes));
if (Array.isArray(provRes)) {
setProviders(provRes);
const activeRes = await Service.AIGetActiveProvider?.();
console.log('[AI] AIGetActiveProvider result:', activeRes);
if (activeRes) setActiveProviderId(activeRes);
}
if (safeRes) setSafetyLevel(safeRes);
if (ctxRes) setContextLevel(ctxRes);
if (promptsRes) setBuiltinPrompts(promptsRes);
} catch (e) { console.warn('Failed to load AI config', e); }
}, []);
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
const handleAddProvider = () => {
const preset = findPreset('openai');
const newProvider: AIProviderConfig = {
id: '', type: preset.backendType, name: '', apiKey: '',
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
maxTokens: 4096, temperature: 0.7,
};
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
setIsEditing(true);
setTestStatus('idle');
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
};
const handleEditProvider = (p: AIProviderConfig) => {
// 尝试根据 baseUrl 和 type 推断 preset
const matchedPreset = PROVIDER_PRESETS.find(pr => pr.backendType === p.type && p.baseUrl?.includes(new URL(pr.defaultBaseUrl || 'http://x').hostname))
|| PROVIDER_PRESETS.find(pr => pr.backendType === p.type)
|| findPreset('custom');
setEditingProvider(p);
setIsEditing(true);
setTestStatus('idle');
form.setFieldsValue({ ...p, presetKey: matchedPreset.key, apiFormat: p.apiFormat || 'openai' });
};
const handleDeleteProvider = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AIDeleteProvider?.(id);
void message.success('已删除'); void loadConfig();
} catch (e: any) { void message.error(e?.message || '删除失败'); }
};
const handleSaveProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const Service = (window as any).go?.aiservice?.Service;
// 构建 payload处理 model/models 逻辑
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
const preset = findPreset(values.presetKey);
const resolvedModels = isCustomLike ? (values.models || []) : preset.models;
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
const finalModel = isCustomLike ? fallbackModel : (values.model || fallbackModel);
// 内置供应商自动使用 preset label 作为名称
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
const payload = {
...editingProvider,
...values,
name: finalName,
model: finalModel,
models: resolvedModels,
apiFormat: values.apiFormat || 'openai',
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void message.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
} catch (e: any) {
if (e?.errorFields) { /* antd form validation error, ignore */ }
else void message.error(e?.message || '保存失败');
} finally { setLoading(false); }
};
const handleSetActive = async (id: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetActiveProvider?.(id);
setActiveProviderId(id); void message.success('已切换');
} catch (e: any) { void message.error(e?.message || '切换失败'); }
};
const handleSafetyChange = async (level: AISafetyLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetSafetyLevel?.(level);
setSafetyLevel(level);
} catch (e) { /* ignore */ }
};
const handleContextChange = async (level: AIContextLevel) => {
try {
const Service = (window as any).go?.aiservice?.Service;
await Service?.AISetContextLevel?.(level);
setContextLevel(level);
} catch (e) { /* ignore */ }
};
const handleTestProvider = async () => {
try {
const values = await form.validateFields();
setLoading(true);
setTestStatus('idle');
const Service = (window as any).go?.aiservice?.Service;
const res = await Service?.AITestProvider?.({ ...values, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 });
if (res?.success) { setTestStatus('success'); void message.success('连接成功 ✅'); }
else { setTestStatus('error'); void message.error(`测试失败: ${res?.message || '未知错误'}`); }
} catch (e: any) { setTestStatus('error'); void message.error(e?.message || '测试失败'); }
finally { setLoading(false); }
};
const handlePresetChange = (presetKey: string) => {
const preset = findPreset(presetKey);
form.setFieldsValue({
presetKey,
type: preset.backendType,
baseUrl: preset.defaultBaseUrl,
model: preset.defaultModel,
});
};
// ---- 字段装饰器样式 ----
const fieldGroupStyle: React.CSSProperties = {
padding: '14px 16px', borderRadius: 12, border: `1px solid ${cardBorder}`,
background: cardBg, marginBottom: 12,
};
const fieldLabelStyle: React.CSSProperties = {
fontSize: 10, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em',
color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6,
};
// ===== Provider 列表 =====
const renderProviderList = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{providers.length === 0 && (
<div style={{
textAlign: 'center', padding: '36px 20px', color: overlayTheme.mutedText, fontSize: 13,
border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg,
}}>
<RobotOutlined style={{ fontSize: 32, marginBottom: 12, opacity: 0.3, display: 'block' }} />
AI Provider<br />
<span style={{ fontSize: 12, opacity: 0.6 }}>使 AI </span>
</div>
)}
{providers.map(p => {
const matchedPreset = PROVIDER_PRESETS.find(pr => pr.backendType === p.type && p.baseUrl?.includes(new URL(pr.defaultBaseUrl || 'http://x').hostname))
|| PROVIDER_PRESETS.find(pr => pr.backendType === p.type)
|| findPreset('custom');
const isActive = p.id === activeProviderId;
return (
<div key={p.id} onClick={() => handleSetActive(p.id)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${isActive ? overlayTheme.selectedText : cardBorder}`,
background: isActive ? overlayTheme.selectedBg : cardBg,
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center',
background: isActive ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)'),
color: isActive ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, flexShrink: 0, transition: 'all 0.2s ease',
}}>
{matchedPreset.icon || <ApiOutlined />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{p.name || p.type}
{isActive && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 12 }} />}
</div>
<div style={{ fontSize: 11, color: overlayTheme.mutedText, marginTop: 3, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{matchedPreset.label}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span style={{ fontFamily: 'monospace', fontSize: 11 }}>{p.model}</span>
</div>
</div>
<Space size={2}>
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />}
onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
style={{ color: overlayTheme.mutedText }} />
</Tooltip>
<Popconfirm title="确认删除?" onConfirm={() => handleDeleteProvider(p.id)}
okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
<Button type="text" size="small" icon={<DeleteOutlined />} danger
onClick={e => e.stopPropagation()} />
</Popconfirm>
</Space>
</div>
);
})}
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddProvider}
style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
Provider
</Button>
</div>
);
// ===== Provider 编辑表单 =====
const renderProviderForm = () => {
const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
return (
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 14, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑 Provider' : '添加 Provider'}
</span>
</div>
<Form form={form} layout="vertical" size="small" requiredMark={false}>
{/* Provider 类型选择 - 卡片式 */}
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<AppstoreOutlined style={{ fontSize: 12 }} />
</div>
<Form.Item name="presetKey" noStyle>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
{PROVIDER_PRESETS.map(pt => (
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
style={{
padding: '12px 14px', borderRadius: 12, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<div style={{
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease',
}}>
{pt.icon}
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.2 }}>{pt.label}</div>
<div style={{ fontSize: 9, color: overlayTheme.mutedText, marginTop: 1, lineHeight: 1.2 }}>{pt.desc}</div>
</div>
</div>
))}
</div>
</Form.Item>
<Form.Item name="type" hidden><Input /></Form.Item>
</div>
{/* 基本信息 - 仅自定义/Ollama 显示 */}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<RobotOutlined style={{ fontSize: 12 }} />
</div>
<Form.Item name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 10 }}>
<Input placeholder="例如:我的 GPT-4o / DeepSeek"
prefix={<span style={{ color: overlayTheme.mutedText, fontSize: 12, marginRight: 4 }}></span>}
style={{ borderRadius: 10, background: inputBg }} />
</Form.Item>
{presetKeyFromForm === 'custom' && (
<Form.Item name="apiFormat" style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: overlayTheme.mutedText, fontSize: 12, whiteSpace: 'nowrap' }}>API </span>
<div style={{ display: 'flex', gap: 4 }}>
{[{ value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }, { value: 'claude-cli', label: 'Claude CLI (代理)' }].map(fmt => (
<div
key={fmt.value}
onClick={() => form.setFieldsValue({ apiFormat: fmt.value })}
style={{
padding: '3px 12px', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${watchedApiFormat === fmt.value ? overlayTheme.selectedText : cardBorder}`,
background: watchedApiFormat === fmt.value ? overlayTheme.selectedBg : 'transparent',
color: watchedApiFormat === fmt.value ? overlayTheme.selectedText : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}
>
{fmt.label}
</div>
))}
</div>
</div>
</Form.Item>
)}
<div style={{ marginBottom: 10 }}>
<Form.Item name="models" style={{ marginBottom: 4 }}>
<Select mode="tags" placeholder="配置指定的模型ID非必填可稍后在聊天窗口直接选择" style={{ width: '100%' }} />
</Form.Item>
<div style={{ fontSize: 11, color: overlayTheme.mutedText }}></div>
</div>
<Form.Item name="model" hidden><Input /></Form.Item>
</div>
)}
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
{/* 认证信息 */}
<div style={fieldGroupStyle}>
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 12 }} /> &
</div>
<Form.Item name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: (presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') ? 10 : 0 }}>
<Input.Password placeholder="sk-... / 你的 API Key"
prefix={<span style={{ color: overlayTheme.mutedText, fontSize: 12, marginRight: 4 }}>Key</span>}
style={{ borderRadius: 10, background: inputBg }} />
</Form.Item>
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item name="baseUrl" style={{ marginBottom: 0 }}>
<Input placeholder={findPreset(presetKeyFromForm).defaultBaseUrl || 'https://...'}
prefix={<span style={{ color: overlayTheme.mutedText, fontSize: 12, marginRight: 4 }}>URL</span>}
suffix={<LinkOutlined style={{ color: overlayTheme.mutedText, fontSize: 12 }} />}
style={{ borderRadius: 10, background: inputBg }} />
</Form.Item>
)}
</div>
{/* 操作按钮 */}
<div style={{
display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4, paddingTop: 12,
borderTop: `1px solid ${cardBorder}`,
}}>
<Button onClick={handleTestProvider} loading={loading} style={{ borderRadius: 10 }}
icon={testStatus === 'success' ? <CheckOutlined style={{ color: '#22c55e' }} /> : undefined}>
{testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
</Button>
<Button type="primary" onClick={handleSaveProvider} loading={loading}
style={{ borderRadius: 10, fontWeight: 600 }}>
</Button>
</div>
</Form>
</div>
);
};
// ===== 安全控制 =====
const renderSafetySettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI SQL
</div>
{SAFETY_OPTIONS.map(opt => {
const active = safetyLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleSafetyChange(opt.value)} style={{
padding: '14px 16px', borderRadius: 14, cursor: 'pointer', transition: 'all 0.2s ease',
border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`,
background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : 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 ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'),
color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText,
transition: 'all 0.2s ease',
}}>
{opt.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor, fontSize: 12 }} />}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 3, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
// ===== 上下文级别 =====
const renderContextSettings = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginBottom: 4 }}>
AI
</div>
{CONTEXT_OPTIONS.map(opt => {
const active = contextLevel === opt.value;
return (
<div key={opt.value} onClick={() => handleContextChange(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: 13, color: overlayTheme.titleText, display: 'flex', alignItems: 'center', gap: 8 }}>
{opt.label}
{active && <CheckOutlined style={{ color: overlayTheme.iconColor, fontSize: 12 }} />}
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 3, lineHeight: '1.5' }}>{opt.desc}</div>
</div>
</div>
);
})}
</div>
);
const renderBuiltinPrompts = () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginBottom: 4 }}>
GoNavi AI
</div>
{Object.entries(builtinPrompts).map(([title, promptText]) => (
<div key={title} style={{
padding: '12px', borderRadius: 12, border: `1px solid ${cardBorder}`, background: cardBg,
}}>
<div style={{ fontWeight: 700, fontSize: 13, color: overlayTheme.titleText, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<RobotOutlined style={{ color: overlayTheme.iconColor }} /> {title}
</div>
<div style={{
background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)',
padding: '10px 12px', borderRadius: 8, fontSize: 12, color: overlayTheme.mutedText,
whiteSpace: 'pre-wrap', fontFamily: 'monospace', lineHeight: 1.5,
userSelect: 'text', border: darkMode ? '1px solid rgba(255,255,255,0.03)' : '1px solid rgba(0,0,0,0.02)'
}}>
{promptText}
</div>
</div>
))}
</div>
);
const tabItems = [
{ key: 'providers', label: <span><ApiOutlined /> Provider</span>, children: isEditing ? renderProviderForm() : renderProviderList() },
{ key: 'safety', label: <span><SafetyCertificateOutlined /> </span>, children: renderSafetySettings() },
{ key: 'context', label: <span><RobotOutlined /> </span>, children: renderContextSettings() },
{ key: 'prompts', label: <span><ExperimentOutlined /> </span>, children: renderBuiltinPrompts() },
];
const modalShellStyle = {
background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: 12, display: 'grid', placeItems: 'center',
background: overlayTheme.iconBg, color: overlayTheme.iconColor, fontSize: 18, flexShrink: 0,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: overlayTheme.titleText }}>AI </div>
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
AI
</div>
</div>
</div>
}
open={open}
onCancel={onClose}
footer={null}
width={540}
styles={{
content: modalShellStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 4 },
body: { paddingTop: 0, height: 520, overflowY: 'auto', overflowX: 'hidden' },
}}
>
<Tabs items={tabItems} size="small" />
</Modal>
);
};
export default AISettingsModal;

View File

@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import {
DndContext,
@@ -4642,6 +4642,43 @@ const DataGrid: React.FC<DataGridProps> = ({
</>
)}
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
<Tooltip title="一键借助 AI 智能分析当前查询页数据">
<Button
icon={<RobotOutlined />}
style={{
background: darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))',
borderColor: darkMode ? 'rgba(16,185,129,0.3)' : 'rgba(16,185,129,0.4)',
color: '#10b981',
fontWeight: 500,
boxShadow: darkMode ? '0 2px 8px rgba(16,185,129,0.1)' : '0 2px 6px rgba(16,185,129,0.05)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.25), rgba(16,185,129,0.1))' : 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))';
e.currentTarget.style.borderColor = '#10b981';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = darkMode ? 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(16,185,129,0.05))' : 'linear-gradient(135deg, rgba(16,185,129,0.1), rgba(16,185,129,0.02))';
e.currentTarget.style.borderColor = darkMode ? 'rgba(16,185,129,0.3)' : 'rgba(16,185,129,0.4)';
}}
onClick={() => {
const sampleData = mergedDisplayData.slice(0, 10);
const prompt = `请帮我分析以下查询结果数据(取前 ${sampleData.length} 条示例):\n\`\`\`json\n${JSON.stringify(sampleData, null, 2)}\n\`\`\`\n\n请分析数据特征、发现规律或者给出一些业务上的洞察。`;
const store = useStore.getState();
const wasClosed = !store.aiPanelVisible;
if (wasClosed) store.setAIPanelVisible(true);
// 如果面板刚打开,需要等待组件挂载完成后再注入 prompt
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
}}
>
AI
</Button>
</Tooltip>
</>
{isDuckDBConnection && onRequestTotalCount && (
<>
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
@@ -202,8 +202,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// Result Sets
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
const [activeResultKey, setActiveResultKey] = useState<string>('');
const [loading, setLoading] = useState(false);
const [executionError, setExecutionError] = useState<string>('');
const [, setCurrentQueryId] = useState<string>('');
const runSeqRef = useRef(0);
const currentQueryIdRef = useRef('');
@@ -465,6 +465,36 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// 应用透明主题(主题已在 main.tsx 全局注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
// 注册 AI 右键菜单操作
const aiActions = [
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
{ id: 'ai.explainSQL', label: '🤖 AI 解释 SQL', useSelection: true, prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n{SQL}\n```' },
{ id: 'ai.optimizeSQL', label: '🤖 AI 优化 SQL', useSelection: true, prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n{SQL}\n```' },
];
aiActions.forEach(action => {
editor.addAction({
id: action.id,
label: action.label,
contextMenuGroupId: '9_ai',
contextMenuOrder: 1,
run: (ed: any) => {
const selection = ed.getModel()?.getValueInRange(ed.getSelection());
let prompt = action.prompt;
if (action.useSelection && selection) {
prompt = prompt.replace('{SQL}', selection);
}
// 打开 AI 面板并填入 prompt
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
// 通过自定义事件将 prompt 发送到 AI 面板
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
},
});
});
// 全局只注册一次 SQL completion provider避免多 tab 重复注册导致补全项重复
if (!sqlCompletionRegistered) {
sqlCompletionRegistered = true;
@@ -835,6 +865,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}
};
const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => {
const editor = editorRef.current;
const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || '';
const fullSQL = getCurrentQuery();
const prompts: Record<string, string> = {
generate: '请根据当前数据库表结构生成查询语句:',
explain: `请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``,
optimize: `请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``,
schema: '请分析当前数据库的表结构并给出优化建议。',
};
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } }));
};
const formatSettingsMenu: MenuProps['items'] = [
{
key: 'upper',
@@ -1430,9 +1479,10 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// 清除旧查询ID
clearQueryId();
}
const runSeq = ++runSeqRef.current;
setLoading(true);
const runStartTime = Date.now();
const runSeq = ++runSeqRef.current;
setLoading(true);
setExecutionError('');
const runStartTime = Date.now();
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
@@ -1489,7 +1539,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
if (shellConvert.recognized) {
if (shellConvert.error) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + shellConvert.error);
setExecutionError(prefix + shellConvert.error);
setResultSets([]);
setActiveResultKey('');
return;
@@ -1522,7 +1572,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
});
if (!res.success) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
message.error(prefix + res.message);
setExecutionError(prefix + res.message);
setResultSets([]);
setActiveResultKey('');
return;
@@ -1644,7 +1694,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
return;
}
message.error(res.message);
setExecutionError(res.message);
setResultSets([]);
setActiveResultKey('');
return;
@@ -1882,6 +1932,42 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
};
}, [activeTabId, tab.id, handleRun]);
// 监听并处理外部注入的 SQL 代码 (如 AI 面板)
useEffect(() => {
const handleInsertSql = (e: CustomEvent) => {
if (activeTabId !== tab.id || !e.detail?.sql) return;
const sqlText = e.detail.sql;
const editor = editorRef.current;
if (editor && (window as any).monaco) {
const position = editor.getPosition();
if (position) {
const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n');
const startRange = new (window as any).monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
editor.executeEdits('ai-insert', [{
range: startRange,
text: '\n' + mText,
forceMoveMarkers: true
}]);
editor.focus();
if (e.detail.runImmediately) {
const endPosition = editor.getPosition();
editor.setSelection(new (window as any).monaco.Range(
position.lineNumber + 1, 1,
endPosition.lineNumber, endPosition.column
));
setTimeout(() => handleRun(), 50);
}
}
} else {
setQuery((prev: string) => prev ? prev + '\n' + sqlText : sqlText);
}
};
window.addEventListener('gonavi:insert-sql', handleInsertSql as EventListener);
return () => window.removeEventListener('gonavi:insert-sql', handleInsertSql as EventListener);
}, [activeTabId, tab.id, handleRun]);
const resolveDefaultQueryName = () => {
const rawTitle = String(tab.title || '').trim();
if (!rawTitle || rawTitle.startsWith('新建查询')) {
@@ -2067,6 +2153,16 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
<Button icon={<SettingOutlined />} />
</Dropdown>
</Button.Group>
<Dropdown menu={{ items: [
{ key: 'ai-generate', label: '生成 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('generate') },
{ key: 'ai-explain', label: '解释 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('explain') },
{ key: 'ai-optimize', label: '优化 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('optimize') },
{ type: 'divider' as const },
{ key: 'ai-schema', label: 'Schema 分析', icon: <RobotOutlined />, onClick: () => handleAIAction('schema') },
] }} placement="bottomRight">
<Button icon={<RobotOutlined />} style={{ color: '#818cf8' }}>AI</Button>
</Dropdown>
</div>
<div style={{ height: editorHeight, minHeight: '100px' }}>
@@ -2168,6 +2264,35 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
})()
}))}
/>
) : executionError ? (
<div style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<CloseOutlined />
<span></span>
</div>
<div className="custom-scrollbar" style={{ padding: 16, background: darkMode ? '#2d1a1a' : '#fff2f0', border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`, borderRadius: 6, color: darkMode ? '#ffa39e' : '#cf1322', fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '40vh', overflow: 'auto' }}>
{executionError}
</div>
<div style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<RobotOutlined />}
style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
onClick={() => {
const errSql = getCurrentQuery();
const prompt = `我在执行以下 SQL 时遇到了错误:\n\`\`\`sql\n${errSql}\n\`\`\`\n\n数据库报错信息如下\n\`\`\`text\n${executionError}\n\`\`\`\n\n请帮我分析错误原因并给出修改建议。`;
const store = useStore.getState();
const wasClosed = !store.aiPanelVisible;
if (wasClosed) store.setAIPanelVisible(true);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
}}
>
AI
</Button>
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }} />
)}

View File

@@ -1455,6 +1455,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
// 保证用户直接双击节点未触发 onClick/onSelect 时也能强行拿到选中状态
const { type, dataRef, key: nodeKey, title } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: title });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const { id, dbName, schemaName } = node.dataRef;
addTab({
@@ -3702,117 +3710,87 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '4px 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search
ref={searchInputRef}
placeholder="搜索..."
onChange={onSearch}
size="small"
style={{ flex: 1, minWidth: 0 }}
/>
<Popover
content={searchScopePopoverContent}
trigger="click"
placement="bottomRight"
open={isSearchScopePopoverOpen}
onOpenChange={setIsSearchScopePopoverOpen}
styles={{ body: { padding: 0, borderRadius: 18, overflow: 'hidden' } }}
>
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
<Button
size="small"
style={{
minWidth: 86,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingInline: 10,
borderRadius: 10,
borderColor: darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.12)',
background: darkMode ? bgMain : 'rgba(255,255,255,0.92)',
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
boxShadow: isSearchScopePopoverOpen
? (darkMode ? '0 0 0 1px rgba(255,214,102,0.22) inset' : '0 0 0 1px rgba(24,144,255,0.24) inset')
: 'none',
backdropFilter: darkMode ? 'blur(10px)' : 'none',
flexShrink: 0,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', color: searchScopes.includes('smart') ? '#ffd666' : (darkMode ? 'rgba(255,255,255,0.72)' : 'rgba(22,32,51,0.72)') }}>
<FilterOutlined />
</span>
<span style={{ fontWeight: 700, color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033' }}></span>
<span
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
<Input
ref={searchInputRef}
placeholder="搜索..."
onChange={onSearch}
size="small"
prefix={<SearchOutlined style={{ color: darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)', marginRight: 4 }} />}
style={{
borderRadius: 6,
border: 'none',
background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)',
boxShadow: 'none',
padding: '4px 8px',
color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)',
}}
suffix={
<Popover
content={searchScopePopoverContent}
trigger="click"
placement="bottomRight"
open={isSearchScopePopoverOpen}
onOpenChange={setIsSearchScopePopoverOpen}
styles={{ body: { padding: 0, borderRadius: 16, overflow: 'hidden' } }}
>
<Tooltip title={`搜索范围:${searchScopeSummary}`}>
<div
style={{
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
display: 'inline-flex',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 700,
lineHeight: 1,
background: searchScopes.includes('smart')
? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)')
: (darkMode ? 'rgba(118,169,250,0.18)' : 'rgba(24,144,255,0.12)'),
gap: 4,
cursor: 'pointer',
padding: '2px 6px',
borderRadius: 4,
background: isSearchScopePopoverOpen
? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.06)')
: 'transparent',
transition: 'background 0.2s',
color: searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? '#91caff' : '#1677ff'),
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'),
}}
onMouseEnter={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)';
}
}}
onMouseLeave={(e) => {
if (!isSearchScopePopoverOpen) {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = searchScopes.includes('smart')
? (darkMode ? '#ffd666' : '#1677ff')
: (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)');
}
}}
>
{searchScopes.includes('smart') ? '智' : searchScopes.length}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', color: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(22,32,51,0.4)', fontSize: 12 }}>
<DownOutlined />
</span>
</Button>
</Tooltip>
</Popover>
</div>
<FilterOutlined style={{ fontSize: 13 }} />
<span style={{ fontSize: 12, fontWeight: 500 }}>
{searchScopes.includes('smart') ? '智' : searchScopes.length}
</span>
</div>
</Tooltip>
</Popover>
}
/>
</div>
{/* Toolbar */}
<div style={{ padding: '4px 10px', borderBottom: 'none', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Button
size="small"
icon={<FolderOpenOutlined />}
onClick={() => {
setRenameViewTarget(null); // Create mode
createTagForm.resetFields();
setIsCreateTagModalOpen(true);
}}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchOperationModal()}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<CheckSquareOutlined />}
onClick={() => openBatchDatabaseModal()}
style={{ flex: '1 1 auto' }}
>
</Button>
<Button
size="small"
icon={<FileAddOutlined />}
onClick={handleOpenSQLFileFromToolbar}
style={{ flex: '1 1 auto' }}
>
SQL文件
</Button>
<div style={{ padding: '6px 16px', display: 'flex', gap: 8, justifyContent: 'space-between', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)' }}>
<Tooltip title="新建组">
<Button size="small" type="text" icon={<FolderOpenOutlined />} onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="批量操作表">
<Button size="small" type="text" icon={<TableOutlined />} onClick={() => openBatchOperationModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="批量操作库">
<Button size="small" type="text" icon={<DatabaseOutlined />} onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
<Tooltip title="运行外部SQL文件">
<Button size="small" type="text" icon={<FileAddOutlined />} onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
</Tooltip>
</div>
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>

View File

@@ -138,6 +138,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const darkMode = theme === 'dark';
const [tables, setTables] = useState<TableStatRow[]>([]);
@@ -195,6 +196,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const openTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
@@ -203,10 +205,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
dbName: tab.dbName,
tableName,
});
}, [connection, tab.dbName, addTab]);
}, [connection, tab.dbName, addTab, setActiveContext]);
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
@@ -217,7 +220,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
initialTab: 'columns',
readOnly: false,
});
}, [connection, tab.dbName, addTab]);
}, [connection, tab.dbName, addTab, setActiveContext]);
const buildConfig = useCallback(() => {
if (!connection) return null;
@@ -383,6 +386,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag } from './types';
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage } from './types';
import {
ShortcutAction,
ShortcutBinding,
@@ -424,6 +424,12 @@ interface AppState {
windowState: 'normal' | 'fullscreen' | 'maximized';
sidebarWidth: number;
// AI 运行时与持久化状态
aiPanelVisible: boolean;
aiChatHistory: Record<string, AIChatMessage[]>; // sessionId -> messages
aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表
aiActiveSessionId: string | null;
addConnection: (conn: SavedConnection) => void;
updateConnection: (conn: SavedConnection) => void;
removeConnection: (id: string) => void;
@@ -475,6 +481,18 @@ interface AppState {
setWindowBounds: (bounds: { width: number; height: number; x: number; y: number }) => void;
setWindowState: (state: 'normal' | 'fullscreen' | 'maximized') => void;
setSidebarWidth: (width: number) => void;
// AI actions
toggleAIPanel: () => void;
setAIPanelVisible: (visible: boolean) => void;
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
updateAIChatMessage: (sessionId: string, messageId: string, updates: Partial<AIChatMessage>) => void;
deleteAIChatMessage: (sessionId: string, messageId: string) => void;
truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void;
clearAIChatHistory: (sessionId: string) => void;
deleteAISession: (sessionId: string) => void;
createNewAISession: () => void;
setAIActiveSessionId: (sessionId: string | null) => void;
}
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
@@ -671,6 +689,12 @@ export const useStore = create<AppState>()(
windowState: 'normal' as const,
sidebarWidth: 330,
// AI 运行状态
aiPanelVisible: false,
aiChatHistory: {},
aiChatSessions: [],
aiActiveSessionId: null,
addConnection: (conn) => set((state) => ({ connections: [...state.connections, conn] })),
updateConnection: (conn) => set((state) => ({
connections: state.connections.map(c => c.id === conn.id ? conn : c)
@@ -950,6 +974,83 @@ export const useStore = create<AppState>()(
setWindowState: (state) => set({ windowState: state }),
setSidebarWidth: (width) => set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
// AI actions
toggleAIPanel: () => set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
addAIChatMessage: (sessionId, message) => set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId] || [];
history[sessionId] = [...messages, message];
let newSessions = [...state.aiChatSessions];
const existingSession = newSessions.find(s => s.id === sessionId);
if (!existingSession) {
// 生成标题(首个 user message 内容前 20 字符)
let title = message.role === 'user' ? message.content : '新的对话';
if (title.length > 20) {
title = title.substring(0, 20) + '...';
}
newSessions.unshift({ id: sessionId, title, updatedAt: Date.now() });
} else {
// 提至最新
newSessions = newSessions.filter(s => s.id !== sessionId);
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
}
return { aiChatHistory: history, aiChatSessions: newSessions };
}),
updateAIChatMessage: (sessionId, messageId, updates) => set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId];
if (!messages) return state;
history[sessionId] = messages.map(m =>
m.id === messageId ? { ...m, ...updates } : m
);
let newSessions = [...state.aiChatSessions];
const existingSession = newSessions.find(s => s.id === sessionId);
if (existingSession) {
newSessions = newSessions.filter(s => s.id !== sessionId);
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
}
return { aiChatHistory: history, aiChatSessions: newSessions };
}),
deleteAIChatMessage: (sessionId, messageId) => set((state) => {
const history = { ...state.aiChatHistory };
if (history[sessionId]) {
history[sessionId] = history[sessionId].filter(m => m.id !== messageId);
}
return { aiChatHistory: history };
}),
truncateAIChatMessages: (sessionId, upToMessageId) => set((state) => {
const history = { ...state.aiChatHistory };
const messages = history[sessionId];
if (messages) {
const idx = messages.findIndex(m => m.id === upToMessageId);
if (idx >= 0) {
history[sessionId] = messages.slice(0, idx + 1);
}
}
return { aiChatHistory: history };
}),
clearAIChatHistory: (sessionId) => set((state) => {
const history = { ...state.aiChatHistory };
delete history[sessionId];
return { aiChatHistory: history };
}),
deleteAISession: (sessionId) => set((state) => {
const history = { ...state.aiChatHistory };
delete history[sessionId];
const newSessions = state.aiChatSessions.filter(s => s.id !== sessionId);
const newActive = state.aiActiveSessionId === sessionId ? null : state.aiActiveSessionId;
return { aiChatHistory: history, aiChatSessions: newSessions, aiActiveSessionId: newActive };
}),
createNewAISession: () => set(() => {
const newId = `session-${Date.now()}`;
return { aiActiveSessionId: newId };
}),
setAIActiveSessionId: (sessionId) => set({ aiActiveSessionId: sessionId }),
}),
{
name: 'lite-db-storage', // name of the item in the storage (must be unique)
@@ -985,6 +1086,10 @@ export const useStore = create<AppState>()(
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
nextState.windowState = sanitizeWindowState(state.windowState);
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
nextState.aiChatHistory = (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {};
nextState.aiChatSessions = Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [];
return nextState as AppState;
},
merge: (persistedState, currentState) => {
@@ -1014,6 +1119,9 @@ export const useStore = create<AppState>()(
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
aiChatHistory: (state.aiChatHistory && typeof state.aiChatHistory === 'object') ? state.aiChatHistory : {},
aiChatSessions: Array.isArray(state.aiChatSessions) ? state.aiChatSessions : [],
};
},
partialize: (state) => ({
@@ -1038,6 +1146,9 @@ export const useStore = create<AppState>()(
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
aiChatHistory: state.aiChatHistory,
aiChatSessions: state.aiChatSessions,
}), // Don't persist logs
}
)

View File

@@ -183,3 +183,39 @@ export interface StreamEntry {
id: string;
fields: Record<string, string>;
}
// --- AI Types ---
export type AIProviderType = 'openai' | 'anthropic' | 'gemini' | 'custom';
export type AISafetyLevel = 'readonly' | 'readwrite' | 'full';
export type AIContextLevel = 'schema_only' | 'with_samples' | 'with_results';
export interface AIProviderConfig {
id: string;
type: AIProviderType;
name: string;
apiKey: string;
baseUrl: string;
model: string;
models?: string[];
apiFormat?: string; // custom 专用: openai | anthropic | gemini
headers?: Record<string, string>;
maxTokens: number;
temperature: number;
}
export interface AIChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
loading?: boolean;
}
export interface AISafetyResult {
allowed: boolean;
operationType: 'query' | 'dml' | 'ddl' | 'other';
requiresConfirm: boolean;
warningMessage?: string;
}

View File

@@ -0,0 +1,38 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';
import {context} from '../models';
export function AIChatCancel(arg1:string):Promise<void>;
export function AIChatSend(arg1:Array<Record<string, string>>):Promise<Record<string, any>>;
export function AIChatStream(arg1:string,arg2:Array<Record<string, string>>):Promise<void>;
export function AICheckSQL(arg1:string):Promise<ai.SafetyResult>;
export function AIDeleteProvider(arg1:string):Promise<void>;
export function AIGetActiveProvider():Promise<string>;
export function AIGetBuiltinPrompts():Promise<Record<string, string>>;
export function AIGetContextLevel():Promise<string>;
export function AIGetProviders():Promise<Array<ai.ProviderConfig>>;
export function AIGetSafetyLevel():Promise<string>;
export function AIListModels():Promise<Record<string, any>>;
export function AISaveProvider(arg1:ai.ProviderConfig):Promise<void>;
export function AISetActiveProvider(arg1:string):Promise<void>;
export function AISetContextLevel(arg1:string):Promise<void>;
export function AISetSafetyLevel(arg1:string):Promise<void>;
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
export function Startup(arg1:context.Context):Promise<void>;

View File

@@ -0,0 +1,71 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AIChatCancel(arg1) {
return window['go']['aiservice']['Service']['AIChatCancel'](arg1);
}
export function AIChatSend(arg1) {
return window['go']['aiservice']['Service']['AIChatSend'](arg1);
}
export function AIChatStream(arg1, arg2) {
return window['go']['aiservice']['Service']['AIChatStream'](arg1, arg2);
}
export function AICheckSQL(arg1) {
return window['go']['aiservice']['Service']['AICheckSQL'](arg1);
}
export function AIDeleteProvider(arg1) {
return window['go']['aiservice']['Service']['AIDeleteProvider'](arg1);
}
export function AIGetActiveProvider() {
return window['go']['aiservice']['Service']['AIGetActiveProvider']();
}
export function AIGetBuiltinPrompts() {
return window['go']['aiservice']['Service']['AIGetBuiltinPrompts']();
}
export function AIGetContextLevel() {
return window['go']['aiservice']['Service']['AIGetContextLevel']();
}
export function AIGetProviders() {
return window['go']['aiservice']['Service']['AIGetProviders']();
}
export function AIGetSafetyLevel() {
return window['go']['aiservice']['Service']['AIGetSafetyLevel']();
}
export function AIListModels() {
return window['go']['aiservice']['Service']['AIListModels']();
}
export function AISaveProvider(arg1) {
return window['go']['aiservice']['Service']['AISaveProvider'](arg1);
}
export function AISetActiveProvider(arg1) {
return window['go']['aiservice']['Service']['AISetActiveProvider'](arg1);
}
export function AISetContextLevel(arg1) {
return window['go']['aiservice']['Service']['AISetContextLevel'](arg1);
}
export function AISetSafetyLevel(arg1) {
return window['go']['aiservice']['Service']['AISetSafetyLevel'](arg1);
}
export function AITestProvider(arg1) {
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
}
export function Startup(arg1) {
return window['go']['aiservice']['Service']['Startup'](arg1);
}

View File

@@ -4,6 +4,7 @@ import {connection} from '../models';
import {time} from '../models';
import {sync} from '../models';
import {redis} from '../models';
import {context} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
@@ -197,6 +198,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function Startup(arg1:context.Context):Promise<void>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;

View File

@@ -386,6 +386,10 @@ export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
export function Startup(arg1) {
return window['go']['app']['App']['Startup'](arg1);
}
export function TestConnection(arg1) {
return window['go']['app']['App']['TestConnection'](arg1);
}

View File

@@ -1,3 +1,58 @@
export namespace ai {
export class ProviderConfig {
id: string;
type: string;
name: string;
apiKey: string;
baseUrl: string;
model: string;
models?: string[];
apiFormat?: string;
headers?: Record<string, string>;
maxTokens: number;
temperature: number;
static createFrom(source: any = {}) {
return new ProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.type = source["type"];
this.name = source["name"];
this.apiKey = source["apiKey"];
this.baseUrl = source["baseUrl"];
this.model = source["model"];
this.models = source["models"];
this.apiFormat = source["apiFormat"];
this.headers = source["headers"];
this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"];
}
}
export class SafetyResult {
allowed: boolean;
operationType: string;
requiresConfirm: boolean;
warningMessage?: string;
static createFrom(source: any = {}) {
return new SafetyResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.allowed = source["allowed"];
this.operationType = source["operationType"];
this.requiresConfirm = source["requiresConfirm"];
this.warningMessage = source["warningMessage"];
}
}
}
export namespace connection {
export class UpdateRow {