mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-21 04:41:48 +08:00
Merge branch 'dev' into pr-294
This commit is contained in:
182
frontend/ai_ui_mockups_wip.html
Normal file
182
frontend/ai_ui_mockups_wip.html
Normal 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>
|
||||
1616
frontend/package-lock.json
generated
1616
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
0f60775ad0a6b251a4320748f196a712
|
||||
30f0a7ce75c113ec7a46f3b09f9a37f7
|
||||
@@ -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';
|
||||
@@ -94,6 +96,9 @@ function App() {
|
||||
const [hasAppliedInitialGlobalProxy, setHasAppliedInitialGlobalProxy] = useState(false);
|
||||
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);
|
||||
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasAppliedInitialGlobalProxy);
|
||||
|
||||
@@ -1119,6 +1124,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 条日志行(微增)”限制
|
||||
@@ -1631,11 +1637,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 }}>
|
||||
@@ -1743,9 +1762,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
|
||||
@@ -1844,6 +1868,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}
|
||||
|
||||
366
frontend/src/components/AIChatPanel.css
Normal file
366
frontend/src/components/AIChatPanel.css
Normal 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;
|
||||
}
|
||||
848
frontend/src/components/AIChatPanel.tsx
Normal file
848
frontend/src/components/AIChatPanel.tsx
Normal 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;
|
||||
570
frontend/src/components/AISettingsModal.tsx
Normal file
570
frontend/src/components/AISettingsModal.tsx
Normal 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;
|
||||
}
|
||||
|
||||
// 预设配置:每个预设映射到后端 type(openai/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;
|
||||
@@ -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' }} />
|
||||
|
||||
@@ -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 }} />
|
||||
)}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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: '新建查询',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
38
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
Normal file
38
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
Normal 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>;
|
||||
71
frontend/wailsjs/go/aiservice/Service.js
Normal file
71
frontend/wailsjs/go/aiservice/Service.js
Normal 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);
|
||||
}
|
||||
3
frontend/wailsjs/go/app/App.d.ts
vendored
3
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
213
internal/ai/context/builder.go
Normal file
213
internal/ai/context/builder.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package aicontext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PromptTemplate AI 能力类型
|
||||
type PromptTemplate string
|
||||
|
||||
const (
|
||||
PromptSQLGenerate PromptTemplate = "sql_generate"
|
||||
PromptSQLExplain PromptTemplate = "sql_explain"
|
||||
PromptSQLOptimize PromptTemplate = "sql_optimize"
|
||||
PromptDataAnalyze PromptTemplate = "data_analyze"
|
||||
PromptSchemaInsight PromptTemplate = "schema_insight"
|
||||
PromptGeneralChat PromptTemplate = "general_chat"
|
||||
)
|
||||
|
||||
// GetBuiltinPrompts 获取所有内置系统提示词集合,用于前端展示
|
||||
func GetBuiltinPrompts() map[string]string {
|
||||
return map[string]string{
|
||||
"通用聊天助手": buildGeneralChatPrompt(),
|
||||
"SQL 生成器": buildSQLGeneratePrompt(),
|
||||
"SQL 解析器": buildSQLExplainPrompt(),
|
||||
"SQL 优化器": buildSQLOptimizePrompt(),
|
||||
"数据洞察分析": buildDataAnalyzePrompt(),
|
||||
"表结构审查": buildSchemaInsightPrompt(),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildSystemPrompt 根据模板类型和上下文构建 System Prompt
|
||||
func BuildSystemPrompt(template PromptTemplate, dbCtx *DatabaseContext) string {
|
||||
var prompt string
|
||||
|
||||
switch template {
|
||||
case PromptSQLGenerate:
|
||||
prompt = buildSQLGeneratePrompt()
|
||||
case PromptSQLExplain:
|
||||
prompt = buildSQLExplainPrompt()
|
||||
case PromptSQLOptimize:
|
||||
prompt = buildSQLOptimizePrompt()
|
||||
case PromptDataAnalyze:
|
||||
prompt = buildDataAnalyzePrompt()
|
||||
case PromptSchemaInsight:
|
||||
prompt = buildSchemaInsightPrompt()
|
||||
case PromptGeneralChat:
|
||||
prompt = buildGeneralChatPrompt()
|
||||
default:
|
||||
prompt = buildGeneralChatPrompt()
|
||||
}
|
||||
|
||||
if dbCtx != nil {
|
||||
prompt += "\n\n" + FormatDatabaseContext(dbCtx)
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// FormatDatabaseContext 将数据库上下文格式化为 LLM 友好的文本
|
||||
func FormatDatabaseContext(ctx *DatabaseContext) string {
|
||||
if ctx == nil || len(ctx.Tables) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("## 当前数据库上下文\n\n数据库类型: %s\n数据库名: %s\n\n",
|
||||
ctx.DatabaseType, ctx.DatabaseName))
|
||||
|
||||
b.WriteString("### 表结构\n\n")
|
||||
for _, table := range ctx.Tables {
|
||||
b.WriteString(fmt.Sprintf("#### 表: %s", table.Name))
|
||||
if table.Comment != "" {
|
||||
b.WriteString(fmt.Sprintf(" (%s)", table.Comment))
|
||||
}
|
||||
if table.RowCount > 0 {
|
||||
b.WriteString(fmt.Sprintf(" [约 %d 行]", table.RowCount))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString("| 列名 | 类型 | 可空 | 主键 | 备注 |\n")
|
||||
b.WriteString("|------|------|------|------|------|\n")
|
||||
for _, col := range table.Columns {
|
||||
nullable := "否"
|
||||
if col.Nullable {
|
||||
nullable = "是"
|
||||
}
|
||||
pk := ""
|
||||
if col.PrimaryKey {
|
||||
pk = "✓"
|
||||
}
|
||||
comment := col.Comment
|
||||
if comment == "" {
|
||||
comment = "-"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s |\n",
|
||||
col.Name, col.Type, nullable, pk, comment))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(table.Indexes) > 0 {
|
||||
b.WriteString("**索引:**\n")
|
||||
for _, idx := range table.Indexes {
|
||||
unique := ""
|
||||
if idx.Unique {
|
||||
unique = " (唯一)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s: [%s]%s\n",
|
||||
idx.Name, strings.Join(idx.Columns, ", "), unique))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(table.SampleRows) > 0 {
|
||||
b.WriteString(fmt.Sprintf("**采样数据 (%d 行):**\n\n", len(table.SampleRows)))
|
||||
if len(table.SampleRows) > 0 {
|
||||
// 使用第一行的 key 作为标题
|
||||
first := table.SampleRows[0]
|
||||
var keys []string
|
||||
for k := range first {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
b.WriteString("| " + strings.Join(keys, " | ") + " |\n")
|
||||
b.WriteString("|" + strings.Repeat("------|", len(keys)) + "\n")
|
||||
for _, row := range table.SampleRows {
|
||||
var vals []string
|
||||
for _, k := range keys {
|
||||
vals = append(vals, fmt.Sprintf("%v", row[k]))
|
||||
}
|
||||
b.WriteString("| " + strings.Join(vals, " | ") + " |\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildSQLGeneratePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位顶级的数据库开发专家和 SQL 查询构建师。根据用户的自然语言需求,生成精准、优雅、高性能的 SQL 查询或 Redis 命令。
|
||||
|
||||
严苛输出规则:
|
||||
1. 首要目标是输出纯粹的代码:始终将代码放在正确语言标识(如 sql 或 bash)的 markdown 代码块中。
|
||||
2. 保持精简:不要添加过多的前置闲聊,直奔主题。
|
||||
3. 保护生产安全:优先使用参数化查询或安全防范写法避免 SQL 注入。对于未指定条件的 DELETE/UPDATE 语句,必须提出强烈的红线警告!!
|
||||
4. 性能至上:对大型查询默认添加合理的 LIMIT 限制(如 LIMIT 100),在 JOIN 和聚合时优先选择最高效的范式写法。
|
||||
5. 适度注释:对于存在复杂逻辑嵌套的代码,请在代码块内使用单行注释简要说明思路。`
|
||||
}
|
||||
|
||||
func buildSQLExplainPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位深耕数据库领域多年的资深开发工程师。请用专业、条理分明且深入浅出的开发者语言向用户全盘解析 SQL 语句的底层意图与执行逻辑。
|
||||
|
||||
解析规范:
|
||||
1. 宏观逻辑解构:用简短的一句话概括这条 SQL 在业务上想要解决什么问题。
|
||||
2. 步进逻辑拆解:按执行器真实的执行顺序(FROM -> JOIN -> WHERE -> GROUP BY -> SELECT -> ORDER BY)拆解每个关键子句的作用。
|
||||
3. 性能排雷点:敏锐指出可能存在的性能陷阱(如隐式类型转换、没有走索引的函数调用、潜在的笛卡尔积/全表扫描等)。
|
||||
4. 严谨的排版:使用列表呈现关键点,重点词汇加粗,确保长文不累赘。`
|
||||
}
|
||||
|
||||
func buildSQLOptimizePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一名曾主导过千万级高并发系统的全栈性能工程专家与高级 DBA。请对用户提供的原始 SQL 进行冷酷、精确的诊断并开出性能重构处方。
|
||||
|
||||
诊断与处方要求:
|
||||
1. 性能瓶颈透视:精准点出当前语句死穴(不合理的驱动表、无法利用覆盖索引、多此一举的子查询等)。
|
||||
2. 重构版本的 SQL:如果存在性能提升空间,直接向用户展示彻底优化过的高性能写法,并确保逻辑等价性。
|
||||
3. 剖析原因:不仅要告诉用户“怎么改”,更要说清楚执行器“为什么这样会更快”。
|
||||
4. 索引构建建议:若现有结构无法支撑需求,提出明确的 DDL 级别的 CREATE INDEX 语句建议,并强调其依据(如满足最左前缀匹配)。
|
||||
5. 优先级评估:在回答的最后标注本次优化建议的紧迫性(高:阻断级/锁表风险;中:吞吐量瓶颈;低:长效微调)。`
|
||||
}
|
||||
|
||||
func buildDataAnalyzePrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位具备极致敏锐商业嗅觉的高级数据分析专家。你将审视用户通过查询得到的数据样本,从中提炼出蕴含的真金白银般的信息。
|
||||
|
||||
洞察目标:
|
||||
1. 硬统计:总观数据行数、核心数值指标(极值、平均值、聚合中位数等)的冰冷现实。
|
||||
2. 趋势与异动:如果数据带有时间戳,敏锐捕捉其上升或下降趋势;如果有异类离群值,将其高亮标注。
|
||||
3. 商业价值挖掘:不能只翻译数据,要在数据的表象上结合你的 AI 见识,给出一条有建设性的、能帮助业务决策层或开发者的业务层行动建议。
|
||||
4. 展现格式:你的分析应该是“标题 + 浓缩要点”的极简研报形式,杜绝毫无波澜的流水账。`
|
||||
}
|
||||
|
||||
func buildSchemaInsightPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一位统筹数据库宏观生命周期的首席数据库架构师。在这个环节里,你需要对用户提供的数据库表结构执行最严厉的范式与前瞻性审查。
|
||||
|
||||
审查视界:
|
||||
1. 规范化博弈:是否存在明显的反三范式设计?这种冗余是否有助于性能(适当的反范式),还是纯粹的设计失误?
|
||||
2. 索引健壮性审查:评估主键选择(如自增、UUID 的利弊),是否存在冗余索引阻碍写入?以及是否遗漏了高频的联合索引。
|
||||
3. 物理容量前瞻:审视数据类型分配(如使用过大的 VARCHAR、没必要的 BIGINT 等可能带来的空间挥霍)。
|
||||
4. 代码级指引:如果存在结构性缺陷,不要只发牢骚,直接给出包含具体优化的 ALTER TABLE 结构修改建议脚本。`
|
||||
}
|
||||
|
||||
func buildGeneralChatPrompt() string {
|
||||
return `你是 GoNavi AI 助手,一款深度集成在数据库/缓存客户端(GoNavi)内部的专属智能专家系统。
|
||||
你的目标是成为开发者、DBA 和数据科学家最得力的超级外脑,提供专业、精准、具有前瞻性的数据端解决方案。
|
||||
|
||||
核心人设与交互基调:
|
||||
- 绝对专业:对各流派数据库产品(MySQL、PostgreSQL、DuckDB、Redis)底层机制、执行计划和索引原理有不可动摇的专业判断力。
|
||||
- 直击痛点:谢绝套话与无效寒暄,若用户的意图明确,首屏直接给出可以直接粘贴运行的优雅代码。
|
||||
- 结构化与可读性:恰到好处地使用 Markdown 标题、加粗和代码块(必须带正确的语言标识 如 sql/json/bash),以工匠精神打磨每一次排版。
|
||||
- 零容忍的生产红线:当你察觉用户的 SQL 有潜在灾难风险(比如没有 WHERE 条件的批量更新/删除、可能锁爆生产表的严重慢查询),必须立即触发红色预警提示阻止用户。
|
||||
|
||||
你的综合能力版图:
|
||||
1. 📝 自然语言驱动:翻译人类意图为精准的查询语句。
|
||||
2. 🔍 底层原理解析:剥丝抽茧分析查询背后的执行逻辑与性能隐患。
|
||||
3. ⚡ 专家级调优:指出并化解性能瓶颈,给出覆盖全维度的索引调优思路。
|
||||
4. 📊 数据洞察炼金:不仅聚合数据,更能从结果集中挖掘商业维度的深度规律。
|
||||
5. 🏗️ 架构先知视界:全局审阅表结构设计局限,提出抗数据膨胀级别的架构演进方案。
|
||||
|
||||
互动守则:
|
||||
- 永远使用专业、具有合作感且充满信心的中文与用户探讨问题。
|
||||
- 当被要求提供任何数据库代码时,需结合相关数据库引擎的最佳实践。如果不清楚当前方言版本,请以标准实现为主基调并好心指出版别差异(如 MySQL 8 窗口函数 等)。`
|
||||
}
|
||||
|
||||
42
internal/ai/context/collector.go
Normal file
42
internal/ai/context/collector.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package aicontext
|
||||
|
||||
// DatabaseContext 数据库上下文信息,传递给 AI 辅助上下文理解
|
||||
type DatabaseContext struct {
|
||||
DatabaseType string `json:"databaseType"` // mysql, postgres 等
|
||||
DatabaseName string `json:"databaseName"`
|
||||
Tables []TableContext `json:"tables"`
|
||||
}
|
||||
|
||||
// TableContext 表的上下文信息
|
||||
type TableContext struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Columns []ColumnInfo `json:"columns"`
|
||||
Indexes []IndexInfo `json:"indexes,omitempty"`
|
||||
SampleRows []map[string]interface{} `json:"sampleRows,omitempty"`
|
||||
RowCount int64 `json:"rowCount,omitempty"`
|
||||
}
|
||||
|
||||
// ColumnInfo 列信息
|
||||
type ColumnInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
PrimaryKey bool `json:"primaryKey"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// IndexInfo 索引信息
|
||||
type IndexInfo struct {
|
||||
Name string `json:"name"`
|
||||
Columns []string `json:"columns"`
|
||||
Unique bool `json:"unique"`
|
||||
}
|
||||
|
||||
// QueryResultContext 查询结果上下文
|
||||
type QueryResultContext struct {
|
||||
SQL string `json:"sql"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
RowCount int `json:"rowCount"`
|
||||
}
|
||||
293
internal/ai/provider/anthropic.go
Normal file
293
internal/ai/provider/anthropic.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAnthropicBaseURL = "https://api.anthropic.com"
|
||||
defaultAnthropicModel = "claude-3-5-sonnet-20241022"
|
||||
anthropicAPIVersion = "2023-06-01"
|
||||
)
|
||||
|
||||
// AnthropicProvider 实现 Anthropic Claude API 的 Provider
|
||||
type AnthropicProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAnthropicProvider 创建 Anthropic Provider 实例
|
||||
func NewAnthropicProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultAnthropicBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
model = defaultAnthropicModel
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &AnthropicProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: openAIHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "Anthropic"
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
System string `json:"system,omitempty"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Delta *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"delta,omitempty"`
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := make([]anthropicMessage, len(messages))
|
||||
for i, m := range messages {
|
||||
anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := anthropicRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: anthropicMsgs,
|
||||
System: systemMsg,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result anthropicResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Anthropic 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("Anthropic API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
return nil, fmt.Errorf("Anthropic 返回空响应")
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: result.Content[0].Text,
|
||||
TokensUsed: ai.TokenUsage{
|
||||
PromptTokens: result.Usage.InputTokens,
|
||||
CompletionTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemMsg, messages := extractSystemMessage(req.Messages)
|
||||
anthropicMsgs := make([]anthropicMessage, len(messages))
|
||||
for i, m := range messages {
|
||||
anthropicMsgs[i] = anthropicMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := anthropicRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: anthropicMsgs,
|
||||
System: systemMsg,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: temperature,
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event anthropicStreamEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "content_block_delta":
|
||||
if event.Delta != nil && event.Delta.Text != "" {
|
||||
callback(ai.StreamChunk{Content: event.Delta.Text})
|
||||
}
|
||||
case "message_stop":
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := p.baseURL + "/v1/messages"
|
||||
if strings.HasSuffix(p.baseURL, "/v1") {
|
||||
url = p.baseURL + "/messages"
|
||||
}
|
||||
|
||||
// 调试日志:打印实际请求信息
|
||||
bodyStr := string(jsonBody)
|
||||
if len(bodyStr) > 500 {
|
||||
bodyStr = bodyStr[:500] + "..."
|
||||
}
|
||||
fmt.Printf("[Anthropic DEBUG] URL: %s\n", url)
|
||||
fmt.Printf("[Anthropic DEBUG] BaseURL: %s\n", p.baseURL)
|
||||
fmt.Printf("[Anthropic DEBUG] Body: %s\n", bodyStr)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", p.config.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
||||
|
||||
// 仅官方 API 发 beta 特性头(代理不发,避免触发 Claude Code 验证)
|
||||
isOfficialAPI := p.baseURL == defaultAnthropicBaseURL || strings.Contains(p.baseURL, "anthropic.com")
|
||||
if isOfficialAPI {
|
||||
httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14,output-128k-2025-02-19,prompt-caching-2024-07-31")
|
||||
}
|
||||
|
||||
// 自定义 headers(用于兼容各类代理服务)
|
||||
for k, v := range p.config.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("Anthropic API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// extractSystemMessage 从消息列表中提取 system 消息(Anthropic 要求 system 作为独立字段)
|
||||
func extractSystemMessage(messages []ai.Message) (string, []ai.Message) {
|
||||
var systemParts []string
|
||||
var remaining []ai.Message
|
||||
for _, m := range messages {
|
||||
if m.Role == "system" {
|
||||
systemParts = append(systemParts, m.Content)
|
||||
} else {
|
||||
remaining = append(remaining, m)
|
||||
}
|
||||
}
|
||||
return strings.Join(systemParts, "\n\n"), remaining
|
||||
}
|
||||
227
internal/ai/provider/claude_cli.go
Normal file
227
internal/ai/provider/claude_cli.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
ai "GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求
|
||||
// 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务
|
||||
type ClaudeCLIProvider struct {
|
||||
config ai.ProviderConfig
|
||||
}
|
||||
|
||||
// NewClaudeCLIProvider 创建 ClaudeCLIProvider 实例
|
||||
func NewClaudeCLIProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
return &ClaudeCLIProvider{config: config}, nil
|
||||
}
|
||||
|
||||
func (p *ClaudeCLIProvider) Name() string {
|
||||
return "ClaudeCLI"
|
||||
}
|
||||
|
||||
func (p *ClaudeCLIProvider) Validate() error {
|
||||
_, err := exec.LookPath("claude")
|
||||
if err != nil {
|
||||
return fmt.Errorf("未找到 claude 命令,请先安装 Claude Code CLI: npm install -g @anthropic-ai/claude-code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Chat 非流式聊天:调用 claude -p "prompt" --output-format json
|
||||
func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"}
|
||||
if p.config.Model != "" {
|
||||
args = append(args, "--model", p.config.Model)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "claude", args...)
|
||||
p.setEnv(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("claude CLI 执行失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON 输出
|
||||
var result struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
// 如果 JSON 解析失败,直接返回原始文本
|
||||
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{Content: result.Result}, nil
|
||||
}
|
||||
|
||||
// ChatStream 流式聊天:调用 claude -p "prompt" --output-format stream-json
|
||||
func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prompt := buildPrompt(req.Messages)
|
||||
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
|
||||
if p.config.Model != "" {
|
||||
args = append(args, "--model", p.config.Model)
|
||||
}
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "claude", args...)
|
||||
p.setEnv(cmd)
|
||||
|
||||
// 关闭 stdin,防止 claude CLI 等待输入
|
||||
cmd.Stdin = nil
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 stdout 管道失败: %w", err)
|
||||
}
|
||||
|
||||
// 捕获 stderr
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("启动 claude CLI 失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid)
|
||||
|
||||
// 立即通知前端:AI 正在思考(避免用户以为卡死)
|
||||
callback(ai.StreamChunk{Content: "💭 *正在思考...*\n\n"})
|
||||
|
||||
// 逐行读取流式 JSON 输出
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Line: %s\n", line[:min(len(line), 200)])
|
||||
|
||||
var event cliStreamEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Non-JSON line: %s\n", line)
|
||||
continue
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "assistant":
|
||||
// 助手消息开始或文本内容
|
||||
if event.Message.Content != nil {
|
||||
for _, block := range event.Message.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
callback(ai.StreamChunk{Content: block.Text})
|
||||
}
|
||||
}
|
||||
}
|
||||
case "content_block_delta":
|
||||
// 增量文本
|
||||
if event.Delta.Text != "" {
|
||||
callback(ai.StreamChunk{Content: event.Delta.Text})
|
||||
}
|
||||
case "result":
|
||||
// 最终结果事件 — 不发送 content(assistant 事件已包含),只标记完成
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
case "error":
|
||||
callback(ai.StreamChunk{Error: event.Error.Message, Done: true})
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
stderrStr := strings.TrimSpace(stderrBuf.String())
|
||||
fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr)
|
||||
|
||||
if waitErr != nil {
|
||||
errMsg := fmt.Sprintf("claude CLI 异常退出: %v", waitErr)
|
||||
if stderrStr != "" {
|
||||
errMsg = fmt.Sprintf("claude CLI 异常退出: %s", stderrStr)
|
||||
}
|
||||
callback(ai.StreamChunk{Error: errMsg, Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// setEnv 设置 Claude CLI 的环境变量
|
||||
func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) {
|
||||
env := cmd.Environ()
|
||||
if p.config.BaseURL != "" {
|
||||
baseURL := strings.TrimRight(p.config.BaseURL, "/")
|
||||
env = append(env, "ANTHROPIC_BASE_URL="+baseURL)
|
||||
}
|
||||
if p.config.APIKey != "" {
|
||||
env = append(env, "ANTHROPIC_API_KEY="+p.config.APIKey)
|
||||
}
|
||||
cmd.Env = env
|
||||
}
|
||||
|
||||
// buildPrompt 将消息列表拼接为适合 claude -p 的提示文本
|
||||
func buildPrompt(messages []ai.Message) string {
|
||||
if len(messages) == 1 {
|
||||
return messages[0].Content
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, m := range messages {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
sb.WriteString("[System]\n")
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n")
|
||||
case "user":
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n")
|
||||
case "assistant":
|
||||
sb.WriteString("[Previous Assistant Response]\n")
|
||||
sb.WriteString(m.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// cliStreamEvent Claude CLI stream-json 输出的事件结构
|
||||
type cliStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"message,omitempty"`
|
||||
Delta struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"delta,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
74
internal/ai/provider/custom.go
Normal file
74
internal/ai/provider/custom.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// CustomProvider 自定义 Provider,根据 apiFormat 选择底层协议
|
||||
// 支持 openai / anthropic / gemini 三种 API 格式
|
||||
type CustomProvider struct {
|
||||
inner Provider
|
||||
name string
|
||||
}
|
||||
|
||||
// NewCustomProvider 创建自定义 Provider 实例
|
||||
func NewCustomProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL")
|
||||
}
|
||||
|
||||
// 根据 apiFormat 决定使用哪个底层协议,默认 openai
|
||||
apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat))
|
||||
if apiFormat == "" {
|
||||
apiFormat = "openai"
|
||||
}
|
||||
|
||||
var innerProvider Provider
|
||||
var err error
|
||||
switch apiFormat {
|
||||
case "anthropic":
|
||||
innerProvider, err = NewAnthropicProvider(config)
|
||||
case "gemini":
|
||||
innerProvider, err = NewGeminiProvider(config)
|
||||
case "claude-cli":
|
||||
innerProvider, err = NewClaudeCLIProvider(config)
|
||||
default: // "openai" 及其他
|
||||
innerProvider, err = NewOpenAIProvider(config)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
name = "Custom"
|
||||
}
|
||||
|
||||
return &CustomProvider{
|
||||
inner: innerProvider,
|
||||
name: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomProvider) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *CustomProvider) Validate() error {
|
||||
if strings.TrimSpace(p.inner.(interface{ Name() string }).Name()) == "" {
|
||||
// 对自定义 Provider,API Key 可选(部分本地服务不需要)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CustomProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
return p.inner.Chat(ctx, req)
|
||||
}
|
||||
|
||||
func (p *CustomProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
return p.inner.ChatStream(ctx, req, callback)
|
||||
}
|
||||
267
internal/ai/provider/gemini.go
Normal file
267
internal/ai/provider/gemini.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGeminiBaseURL = "https://generativelanguage.googleapis.com"
|
||||
defaultGeminiModel = "gemini-2.0-flash"
|
||||
)
|
||||
|
||||
// GeminiProvider 实现 Google Gemini API 的 Provider
|
||||
type GeminiProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGeminiProvider 创建 Gemini Provider 实例
|
||||
func NewGeminiProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultGeminiBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
model = defaultGeminiModel
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &GeminiProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: openAIHTTPTimeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "Gemini"
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type geminiRequest struct {
|
||||
Contents []geminiContent `json:"contents"`
|
||||
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
|
||||
GenerationConfig geminiGenConfig `json:"generationConfig,omitempty"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type geminiGenConfig struct {
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
}
|
||||
|
||||
type geminiResponse struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata *struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
geminiReq := p.buildRequest(req)
|
||||
|
||||
url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s",
|
||||
p.baseURL, p.config.Model, p.config.APIKey)
|
||||
|
||||
respBody, err := p.doRequest(ctx, url, geminiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result geminiResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 Gemini 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("Gemini API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
|
||||
return nil, fmt.Errorf("Gemini 返回空响应")
|
||||
}
|
||||
|
||||
var tokens ai.TokenUsage
|
||||
if result.UsageMetadata != nil {
|
||||
tokens = ai.TokenUsage{
|
||||
PromptTokens: result.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: result.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: result.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
var textParts []string
|
||||
for _, part := range result.Candidates[0].Content.Parts {
|
||||
if part.Text != "" {
|
||||
textParts = append(textParts, part.Text)
|
||||
}
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: strings.Join(textParts, ""),
|
||||
TokensUsed: tokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
geminiReq := p.buildRequest(req)
|
||||
|
||||
url := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse&key=%s",
|
||||
p.baseURL, p.config.Model, p.config.APIKey)
|
||||
|
||||
respBody, err := p.doRequest(ctx, url, geminiReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var chunk geminiResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(chunk.Candidates) > 0 && len(chunk.Candidates[0].Content.Parts) > 0 {
|
||||
for _, part := range chunk.Candidates[0].Content.Parts {
|
||||
if part.Text != "" {
|
||||
callback(ai.StreamChunk{Content: part.Text})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) buildRequest(req ai.ChatRequest) geminiRequest {
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
var systemInstruction *geminiContent
|
||||
var contents []geminiContent
|
||||
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
systemInstruction = &geminiContent{
|
||||
Parts: []geminiPart{{Text: m.Content}},
|
||||
}
|
||||
continue
|
||||
}
|
||||
role := m.Role
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
contents = append(contents, geminiContent{
|
||||
Role: role,
|
||||
Parts: []geminiPart{{Text: m.Content}},
|
||||
})
|
||||
}
|
||||
|
||||
return geminiRequest{
|
||||
Contents: contents,
|
||||
SystemInstruction: systemInstruction,
|
||||
GenerationConfig: geminiGenConfig{
|
||||
Temperature: temperature,
|
||||
MaxOutputTokens: maxTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) doRequest(ctx context.Context, url string, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 Gemini 失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("Gemini API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
316
internal/ai/provider/openai.go
Normal file
316
internal/ai/provider/openai.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
defaultOpenAIModel = "gpt-4o"
|
||||
defaultOpenAIMaxTokens = 4096
|
||||
defaultOpenAITemperature = 0.7
|
||||
openAIHTTPTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// OpenAIProvider 实现 OpenAI / OpenAI 兼容 API 的 Provider
|
||||
type OpenAIProvider struct {
|
||||
config ai.ProviderConfig
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewOpenAIProvider 创建 OpenAI Provider 实例
|
||||
func NewOpenAIProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultOpenAIBaseURL
|
||||
}
|
||||
// 确保 baseURL 包含 /v1 路径(兼容用户只填域名的情况,如 https://anyrouter.top)
|
||||
if !strings.HasSuffix(baseURL, "/v1") && !strings.Contains(baseURL, "/v1/") {
|
||||
baseURL = baseURL + "/v1"
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
model = defaultOpenAIModel
|
||||
}
|
||||
maxTokens := config.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = defaultOpenAIMaxTokens
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = defaultOpenAITemperature
|
||||
}
|
||||
|
||||
normalized := config
|
||||
normalized.BaseURL = baseURL
|
||||
normalized.Model = model
|
||||
normalized.MaxTokens = maxTokens
|
||||
normalized.Temperature = temperature
|
||||
|
||||
return &OpenAIProvider{
|
||||
config: normalized,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: openAIHTTPTimeout,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string {
|
||||
if strings.TrimSpace(p.config.Name) != "" {
|
||||
return p.config.Name
|
||||
}
|
||||
return "OpenAI"
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Validate() error {
|
||||
if strings.TrimSpace(p.config.APIKey) == "" {
|
||||
return fmt.Errorf("API Key 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openAIChatRequest OpenAI API 请求体
|
||||
type openAIChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openAIChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type openAIChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// openAIChatResponse OpenAI API 响应体
|
||||
type openAIChatResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// openAIStreamChunk SSE 流式响应片段
|
||||
type openAIStreamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages := make([]openAIChatMessage, len(req.Messages))
|
||||
for i, m := range req.Messages {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := openAIChatRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: messages,
|
||||
Temperature: temperature,
|
||||
MaxTokens: maxTokens,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
var result openAIChatResponse
|
||||
if err := json.NewDecoder(respBody).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析 OpenAI 响应失败: %w", err)
|
||||
}
|
||||
if result.Error != nil && result.Error.Message != "" {
|
||||
return nil, fmt.Errorf("OpenAI API 错误: %s", result.Error.Message)
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return nil, fmt.Errorf("OpenAI 返回空响应")
|
||||
}
|
||||
|
||||
return &ai.ChatResponse{
|
||||
Content: result.Choices[0].Message.Content,
|
||||
TokensUsed: ai.TokenUsage{
|
||||
PromptTokens: result.Usage.PromptTokens,
|
||||
CompletionTokens: result.Usage.CompletionTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messages := make([]openAIChatMessage, len(req.Messages))
|
||||
for i, m := range req.Messages {
|
||||
messages[i] = openAIChatMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
|
||||
temperature := req.Temperature
|
||||
if temperature <= 0 {
|
||||
temperature = p.config.Temperature
|
||||
}
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = p.config.MaxTokens
|
||||
}
|
||||
|
||||
body := openAIChatRequest{
|
||||
Model: p.config.Model,
|
||||
Messages: messages,
|
||||
Temperature: temperature,
|
||||
MaxTokens: maxTokens,
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
respBody, err := p.doRequest(ctx, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer respBody.Close()
|
||||
|
||||
receivedContent := false
|
||||
scanner := bufio.NewScanner(respBody)
|
||||
// 增大 scanner buffer,防止长行被截断
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
// 非 SSE 数据行,可能是错误信息,记录日志
|
||||
if strings.Contains(line, "error") || strings.Contains(line, "Error") {
|
||||
callback(ai.StreamChunk{Error: fmt.Sprintf("服务端返回异常: %s", line), Done: true})
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
var chunk openAIStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // 跳过格式异常的行
|
||||
}
|
||||
if chunk.Error != nil && chunk.Error.Message != "" {
|
||||
callback(ai.StreamChunk{Error: fmt.Sprintf("API 错误: %s", chunk.Error.Message), Done: true})
|
||||
return nil
|
||||
}
|
||||
if len(chunk.Choices) > 0 {
|
||||
content := chunk.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
receivedContent = true
|
||||
callback(ai.StreamChunk{Content: content})
|
||||
}
|
||||
if chunk.Choices[0].FinishReason != nil {
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("读取 OpenAI 流式响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 如果流正常结束但没有收到任何内容,可能是 API 响应格式不兼容
|
||||
if !receivedContent {
|
||||
callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 API 端点和模型是否正确", Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
callback(ai.StreamChunk{Done: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := p.baseURL + "/chat/completions"
|
||||
|
||||
// 调试日志
|
||||
bodyStr := string(jsonBody)
|
||||
if len(bodyStr) > 500 {
|
||||
bodyStr = bodyStr[:500] + "..."
|
||||
}
|
||||
fmt.Printf("[OpenAI DEBUG] URL: %s\n", url)
|
||||
fmt.Printf("[OpenAI DEBUG] BaseURL: %s\n", p.baseURL)
|
||||
fmt.Printf("[OpenAI DEBUG] Body: %s\n", bodyStr)
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 HTTP 请求失败: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
|
||||
// 自定义 headers(用于兼容各类 OpenAI 兼容服务)
|
||||
for k, v := range p.config.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求到 %s 失败: %w", url, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("OpenAI API 返回错误 (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
86
internal/ai/provider/openai_test.go
Normal file
86
internal/ai/provider/openai_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAIProvider_Validate_MissingAPIKey(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{Type: "openai", Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Fatal("expected validation error for missing API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Validate_Valid(t *testing.T) {
|
||||
p, err := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test-key", Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected constructor error: %v", err)
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Name_Custom(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", Name: "My OpenAI", APIKey: "sk-test",
|
||||
})
|
||||
if p.Name() != "My OpenAI" {
|
||||
t.Fatalf("expected name 'My OpenAI', got '%s'", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_Name_Default(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test",
|
||||
})
|
||||
if p.Name() != "OpenAI" {
|
||||
t.Fatalf("expected default name 'OpenAI', got '%s'", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_DefaultBaseURL(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test", Model: "gpt-4o",
|
||||
})
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.baseURL != "https://api.openai.com/v1" {
|
||||
t.Fatalf("expected default base URL, got '%s'", op.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_CustomBaseURL(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test", BaseURL: "https://my-proxy.com/v1",
|
||||
})
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.baseURL != "https://my-proxy.com/v1" {
|
||||
t.Fatalf("expected custom base URL, got '%s'", op.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_DefaultModel(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test",
|
||||
})
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.config.Model != "gpt-4o" {
|
||||
t.Fatalf("expected default model 'gpt-4o', got '%s'", op.config.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
|
||||
p, _ := NewOpenAIProvider(ai.ProviderConfig{
|
||||
Type: "openai", APIKey: "sk-test",
|
||||
})
|
||||
op := p.(*OpenAIProvider)
|
||||
if op.config.MaxTokens != 4096 {
|
||||
t.Fatalf("expected default max tokens 4096, got %d", op.config.MaxTokens)
|
||||
}
|
||||
}
|
||||
19
internal/ai/provider/provider.go
Normal file
19
internal/ai/provider/provider.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// Provider AI 模型提供者接口
|
||||
type Provider interface {
|
||||
// Chat 发送消息并获取完整响应
|
||||
Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error)
|
||||
// ChatStream 发送消息并以流式返回
|
||||
ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error
|
||||
// Name 返回 Provider 名称
|
||||
Name() string
|
||||
// Validate 校验配置是否有效
|
||||
Validate() error
|
||||
}
|
||||
25
internal/ai/provider/registry.go
Normal file
25
internal/ai/provider/registry.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// NewProvider 根据配置创建 Provider 实例
|
||||
func NewProvider(config ai.ProviderConfig) (Provider, error) {
|
||||
providerType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
switch providerType {
|
||||
case "openai":
|
||||
return NewOpenAIProvider(config)
|
||||
case "anthropic":
|
||||
return NewAnthropicProvider(config)
|
||||
case "gemini":
|
||||
return NewGeminiProvider(config)
|
||||
case "custom":
|
||||
return NewCustomProvider(config)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 AI Provider 类型: %s", config.Type)
|
||||
}
|
||||
}
|
||||
101
internal/ai/safety/classifier.go
Normal file
101
internal/ai/safety/classifier.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// ClassifySQL 分类 SQL 语句的操作类型
|
||||
func ClassifySQL(sql string) ai.SQLOperationType {
|
||||
keyword := leadingSQLKeyword(sql)
|
||||
switch keyword {
|
||||
case "select", "with", "show", "describe", "desc", "explain", "pragma", "values":
|
||||
return ai.SQLOpQuery
|
||||
case "insert", "update", "delete", "replace", "merge", "upsert":
|
||||
return ai.SQLOpDML
|
||||
case "create", "alter", "drop", "truncate", "rename":
|
||||
return ai.SQLOpDDL
|
||||
default:
|
||||
return ai.SQLOpOther
|
||||
}
|
||||
}
|
||||
|
||||
// IsHighRiskSQL 判断 SQL 是否为高风险语句
|
||||
func IsHighRiskSQL(sql string) (bool, string) {
|
||||
keyword := leadingSQLKeyword(sql)
|
||||
normalized := strings.ToLower(sql)
|
||||
|
||||
switch keyword {
|
||||
case "drop":
|
||||
return true, "⚠️ 高危操作:DROP 语句将永久删除数据库对象"
|
||||
case "truncate":
|
||||
return true, "⚠️ 高危操作:TRUNCATE 将清空表中所有数据"
|
||||
case "delete":
|
||||
if !containsWhereClause(normalized) {
|
||||
return true, "⚠️ 高危操作:DELETE 语句缺少 WHERE 条件,将删除所有数据"
|
||||
}
|
||||
case "update":
|
||||
if !containsWhereClause(normalized) {
|
||||
return true, "⚠️ 高危操作:UPDATE 语句缺少 WHERE 条件,将更新所有记录"
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// containsWhereClause 简单判断 SQL 是否包含 WHERE 子句
|
||||
func containsWhereClause(normalizedSQL string) bool {
|
||||
return strings.Contains(normalizedSQL, " where ") ||
|
||||
strings.Contains(normalizedSQL, "\nwhere ") ||
|
||||
strings.Contains(normalizedSQL, "\twhere ")
|
||||
}
|
||||
|
||||
// leadingSQLKeyword 提取 SQL 语句的首个关键字(跳过注释和空白)
|
||||
func leadingSQLKeyword(query string) string {
|
||||
text := strings.TrimSpace(query)
|
||||
for len(text) > 0 {
|
||||
trimmed := strings.TrimLeft(text, " \t\r\n")
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
text = trimmed
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(text, "--"):
|
||||
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||
text = text[idx+1:]
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(text, "#"):
|
||||
if idx := strings.IndexByte(text, '\n'); idx >= 0 {
|
||||
text = text[idx+1:]
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
case strings.HasPrefix(text, "/*"):
|
||||
if idx := strings.Index(text, "*/"); idx >= 0 {
|
||||
text = text[idx+2:]
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
for i, r := range text {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(text[:i])
|
||||
}
|
||||
return strings.ToLower(text)
|
||||
}
|
||||
145
internal/ai/safety/classifier_test.go
Normal file
145
internal/ai/safety/classifier_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifySQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
sql string
|
||||
want ai.SQLOperationType
|
||||
}{
|
||||
{"SELECT * FROM users", ai.SQLOpQuery},
|
||||
{" select id from t", ai.SQLOpQuery},
|
||||
{"SHOW TABLES", ai.SQLOpQuery},
|
||||
{"DESCRIBE users", ai.SQLOpQuery},
|
||||
{"DESC users", ai.SQLOpQuery},
|
||||
{"EXPLAIN SELECT 1", ai.SQLOpQuery},
|
||||
{"WITH cte AS (SELECT 1) SELECT * FROM cte", ai.SQLOpQuery},
|
||||
{"PRAGMA table_info(t)", ai.SQLOpQuery},
|
||||
{"VALUES (1, 2)", ai.SQLOpQuery},
|
||||
{"INSERT INTO users VALUES (1)", ai.SQLOpDML},
|
||||
{"UPDATE users SET name='x'", ai.SQLOpDML},
|
||||
{"DELETE FROM users WHERE id=1", ai.SQLOpDML},
|
||||
{"REPLACE INTO users VALUES (1)", ai.SQLOpDML},
|
||||
{"MERGE INTO t USING s ON t.id=s.id", ai.SQLOpDML},
|
||||
{"CREATE TABLE t (id INT)", ai.SQLOpDDL},
|
||||
{"ALTER TABLE t ADD col INT", ai.SQLOpDDL},
|
||||
{"DROP TABLE t", ai.SQLOpDDL},
|
||||
{"TRUNCATE TABLE t", ai.SQLOpDDL},
|
||||
{"RENAME TABLE old TO new", ai.SQLOpDDL},
|
||||
{"/* comment */ SELECT 1", ai.SQLOpQuery},
|
||||
{"-- comment\nDELETE FROM t", ai.SQLOpDML},
|
||||
{"-- line1\n-- line2\nSELECT 1", ai.SQLOpQuery},
|
||||
{"/* block */ -- line\nUPDATE t SET x=1", ai.SQLOpDML},
|
||||
{"", ai.SQLOpOther},
|
||||
{" ", ai.SQLOpOther},
|
||||
{"-- only comment", ai.SQLOpOther},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ClassifySQL(tt.sql)
|
||||
if got != tt.want {
|
||||
t.Errorf("ClassifySQL(%q) = %s, want %s", tt.sql, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHighRiskSQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
sql string
|
||||
highRisk bool
|
||||
}{
|
||||
{"DROP TABLE users", true},
|
||||
{"DROP DATABASE test", true},
|
||||
{"TRUNCATE TABLE users", true},
|
||||
{"DELETE FROM users", true}, // 无 WHERE
|
||||
{"DELETE FROM users WHERE id=1", false}, // 有 WHERE
|
||||
{"UPDATE users SET name='x'", true}, // 无 WHERE
|
||||
{"UPDATE users SET name='x' WHERE id=1", false}, // 有 WHERE
|
||||
{"SELECT * FROM users", false},
|
||||
{"INSERT INTO users VALUES (1)", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
highRisk, _ := IsHighRiskSQL(tt.sql)
|
||||
if highRisk != tt.highRisk {
|
||||
t.Errorf("IsHighRiskSQL(%q) = %v, want %v", tt.sql, highRisk, tt.highRisk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_ReadOnly(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionReadOnly)
|
||||
tests := []struct {
|
||||
sql string
|
||||
allowed bool
|
||||
}{
|
||||
{"SELECT * FROM t", true},
|
||||
{"INSERT INTO t VALUES (1)", false},
|
||||
{"UPDATE t SET x=1", false},
|
||||
{"DELETE FROM t", false},
|
||||
{"DROP TABLE t", false},
|
||||
{"CREATE TABLE t (id INT)", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := g.Check(tt.sql)
|
||||
if result.Allowed != tt.allowed {
|
||||
t.Errorf("Guard[readonly].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_ReadWrite(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionReadWrite)
|
||||
tests := []struct {
|
||||
sql string
|
||||
allowed bool
|
||||
confirm bool
|
||||
}{
|
||||
{"SELECT * FROM t", true, false},
|
||||
{"INSERT INTO t VALUES (1)", true, true},
|
||||
{"UPDATE t SET x=1", true, true}, // 允许但需确认
|
||||
{"DELETE FROM t WHERE id=1", true, true}, // 允许但需确认
|
||||
{"DROP TABLE t", false, true}, // DDL 不允许
|
||||
{"CREATE TABLE t (id INT)", false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := g.Check(tt.sql)
|
||||
if result.Allowed != tt.allowed {
|
||||
t.Errorf("Guard[readwrite].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
|
||||
}
|
||||
if result.RequiresConfirm != tt.confirm {
|
||||
t.Errorf("Guard[readwrite].Check(%q).RequiresConfirm = %v, want %v", tt.sql, result.RequiresConfirm, tt.confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_Full(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionFull)
|
||||
tests := []struct {
|
||||
sql string
|
||||
allowed bool
|
||||
}{
|
||||
{"SELECT * FROM t", true},
|
||||
{"INSERT INTO t VALUES (1)", true},
|
||||
{"DROP TABLE t", true},
|
||||
{"CREATE TABLE t (id INT)", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
result := g.Check(tt.sql)
|
||||
if result.Allowed != tt.allowed {
|
||||
t.Errorf("Guard[full].Check(%q).Allowed = %v, want %v", tt.sql, result.Allowed, tt.allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuard_HighRiskWarning(t *testing.T) {
|
||||
g := NewGuard(ai.PermissionFull)
|
||||
result := g.Check("DELETE FROM users")
|
||||
if result.WarningMessage == "" {
|
||||
t.Error("expected high-risk warning for DELETE without WHERE")
|
||||
}
|
||||
if !result.RequiresConfirm {
|
||||
t.Error("expected RequiresConfirm for high-risk SQL")
|
||||
}
|
||||
}
|
||||
71
internal/ai/safety/guard.go
Normal file
71
internal/ai/safety/guard.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"GoNavi-Wails/internal/ai"
|
||||
)
|
||||
|
||||
// Guard AI SQL 安全策略守卫
|
||||
type Guard struct {
|
||||
permissionLevel ai.SQLPermissionLevel
|
||||
}
|
||||
|
||||
// NewGuard 创建安全策略守卫
|
||||
func NewGuard(level ai.SQLPermissionLevel) *Guard {
|
||||
return &Guard{permissionLevel: level}
|
||||
}
|
||||
|
||||
// SetPermissionLevel 设置权限级别
|
||||
func (g *Guard) SetPermissionLevel(level ai.SQLPermissionLevel) {
|
||||
g.permissionLevel = level
|
||||
}
|
||||
|
||||
// GetPermissionLevel 获取当前权限级别
|
||||
func (g *Guard) GetPermissionLevel() ai.SQLPermissionLevel {
|
||||
return g.permissionLevel
|
||||
}
|
||||
|
||||
// Check 检查 AI 生成的 SQL 是否在允许范围内
|
||||
func (g *Guard) Check(sql string) ai.SafetyResult {
|
||||
opType := ClassifySQL(sql)
|
||||
allowed := g.isAllowed(opType)
|
||||
requiresConfirm := g.requiresConfirmation(opType)
|
||||
warningMessage := ""
|
||||
|
||||
if isHighRisk, msg := IsHighRiskSQL(sql); isHighRisk {
|
||||
warningMessage = msg
|
||||
requiresConfirm = true
|
||||
}
|
||||
|
||||
return ai.SafetyResult{
|
||||
Allowed: allowed,
|
||||
OperationType: opType,
|
||||
RequiresConfirm: requiresConfirm,
|
||||
WarningMessage: warningMessage,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) isAllowed(opType ai.SQLOperationType) bool {
|
||||
switch g.permissionLevel {
|
||||
case ai.PermissionReadOnly:
|
||||
return opType == ai.SQLOpQuery
|
||||
case ai.PermissionReadWrite:
|
||||
return opType == ai.SQLOpQuery || opType == ai.SQLOpDML
|
||||
case ai.PermissionFull:
|
||||
return opType == ai.SQLOpQuery || opType == ai.SQLOpDML || opType == ai.SQLOpDDL
|
||||
default:
|
||||
return opType == ai.SQLOpQuery
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) requiresConfirmation(opType ai.SQLOperationType) bool {
|
||||
switch opType {
|
||||
case ai.SQLOpQuery:
|
||||
return false
|
||||
case ai.SQLOpDML:
|
||||
return true // DML 始终需要确认
|
||||
case ai.SQLOpDDL:
|
||||
return true // DDL 始终需要确认
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
573
internal/ai/service/service.go
Normal file
573
internal/ai/service/service.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package aiservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/ai"
|
||||
aicontext "GoNavi-Wails/internal/ai/context"
|
||||
"GoNavi-Wails/internal/ai/provider"
|
||||
"GoNavi-Wails/internal/ai/safety"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
"github.com/google/uuid"
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// Service AI 服务,作为 Wails Binding 暴露给前端
|
||||
type Service struct {
|
||||
ctx context.Context
|
||||
mu sync.RWMutex
|
||||
providers []ai.ProviderConfig
|
||||
activeProvider string // active provider ID
|
||||
safetyLevel ai.SQLPermissionLevel
|
||||
contextLevel ai.ContextLevel
|
||||
guard *safety.Guard
|
||||
configDir string // 配置存储目录
|
||||
cancelFuncs map[string]context.CancelFunc // 记录每个 session 的 context 取消函数
|
||||
}
|
||||
|
||||
// NewService 创建 AI Service 实例
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
providers: make([]ai.ProviderConfig, 0),
|
||||
safetyLevel: ai.PermissionReadOnly,
|
||||
contextLevel: ai.ContextSchemaOnly,
|
||||
guard: safety.NewGuard(ai.PermissionReadOnly),
|
||||
cancelFuncs: make(map[string]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Startup Wails 生命周期回调
|
||||
func (s *Service) Startup(ctx context.Context) {
|
||||
s.ctx = ctx
|
||||
s.configDir = resolveConfigDir()
|
||||
s.loadConfig()
|
||||
logger.Infof("AI Service 启动完成,已加载 %d 个 Provider", len(s.providers))
|
||||
}
|
||||
|
||||
// --- Provider 管理 ---
|
||||
|
||||
// AIGetProviders 获取所有 Provider 配置
|
||||
func (s *Service) AIGetProviders() []ai.ProviderConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]ai.ProviderConfig, len(s.providers))
|
||||
copy(result, s.providers)
|
||||
return result
|
||||
}
|
||||
|
||||
// AISaveProvider 保存/更新 Provider 配置
|
||||
func (s *Service) AISaveProvider(config ai.ProviderConfig) error {
|
||||
fmt.Printf("[AISaveProvider DEBUG] ID: %s, Model: %s\n", config.ID, config.Model)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if strings.TrimSpace(config.ID) == "" {
|
||||
config.ID = "provider-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, p := range s.providers {
|
||||
if p.ID == config.ID {
|
||||
s.providers[i] = config
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.providers = append(s.providers, config)
|
||||
}
|
||||
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
// AIDeleteProvider 删除 Provider
|
||||
func (s *Service) AIDeleteProvider(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
newProviders := make([]ai.ProviderConfig, 0, len(s.providers))
|
||||
for _, p := range s.providers {
|
||||
if p.ID != id {
|
||||
newProviders = append(newProviders, p)
|
||||
}
|
||||
}
|
||||
s.providers = newProviders
|
||||
|
||||
if s.activeProvider == id {
|
||||
s.activeProvider = ""
|
||||
if len(s.providers) > 0 {
|
||||
s.activeProvider = s.providers[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
return s.saveConfig()
|
||||
}
|
||||
|
||||
// AITestProvider 测试 Provider 配置是否可用
|
||||
func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{} {
|
||||
// 如果传入脱敏的 key,使用已保存的 key
|
||||
s.mu.RLock()
|
||||
if isMaskedAPIKey(config.APIKey) {
|
||||
for _, p := range s.providers {
|
||||
if p.ID == config.ID {
|
||||
config.APIKey = p.APIKey
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
p, err := provider.NewProvider(config)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*1000*1000*1000) // 30s
|
||||
defer cancel()
|
||||
|
||||
resp, err := p.Chat(ctx, ai.ChatRequest{
|
||||
Messages: []ai.Message{
|
||||
{Role: "user", Content: "Hi, please respond with 'OK' to confirm the connection is working."},
|
||||
},
|
||||
MaxTokens: 10,
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": fmt.Sprintf("连接测试失败: %s", err.Error())}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("连接成功!模型响应: %s", truncateString(resp.Content, 100)),
|
||||
}
|
||||
}
|
||||
|
||||
// AISetActiveProvider 设置活动 Provider
|
||||
func (s *Service) AISetActiveProvider(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.activeProvider = id
|
||||
_ = s.saveConfig()
|
||||
}
|
||||
|
||||
// AIGetActiveProvider 获取活动 Provider ID
|
||||
func (s *Service) AIGetActiveProvider() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.activeProvider
|
||||
}
|
||||
|
||||
// AIGetBuiltinPrompts 返回内部置的各类系统提示词,用于前端展示或查询
|
||||
func (s *Service) AIGetBuiltinPrompts() map[string]string {
|
||||
return aicontext.GetBuiltinPrompts()
|
||||
}
|
||||
|
||||
// AIListModels 获取当前活跃 Provider 的可用模型列表
|
||||
func (s *Service) AIListModels() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
var config ai.ProviderConfig
|
||||
found := false
|
||||
for _, p := range s.providers {
|
||||
if p.ID == s.activeProvider {
|
||||
config = p
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
return map[string]interface{}{"success": false, "models": []string{}, "error": "未找到活跃 Provider"}
|
||||
}
|
||||
|
||||
models, err := fetchModels(config)
|
||||
if err != nil {
|
||||
// 回退到配置中的静态模型列表
|
||||
if len(config.Models) > 0 {
|
||||
return map[string]interface{}{"success": true, "models": config.Models, "source": "static"}
|
||||
}
|
||||
return map[string]interface{}{"success": false, "models": []string{}, "error": err.Error()}
|
||||
}
|
||||
|
||||
return map[string]interface{}{"success": true, "models": models, "source": "api"}
|
||||
}
|
||||
|
||||
// fetchModels 从供应商 API 获取可用模型列表
|
||||
func fetchModels(config ai.ProviderConfig) ([]string, error) {
|
||||
providerType := config.Type
|
||||
if providerType == "custom" && config.APIFormat != "" {
|
||||
providerType = config.APIFormat
|
||||
}
|
||||
|
||||
switch providerType {
|
||||
case "openai":
|
||||
return fetchOpenAIModels(config)
|
||||
case "anthropic":
|
||||
// Anthropic 没有公开的 /models 端点,返回硬编码列表
|
||||
return []string{"claude-opus-4-6", "claude-sonnet-4-6"}, nil
|
||||
case "gemini":
|
||||
return fetchGeminiModels(config)
|
||||
default:
|
||||
return fetchOpenAIModels(config)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchOpenAIModels 获取 OpenAI 兼容 API 的模型列表
|
||||
func fetchOpenAIModels(config ai.ProviderConfig) ([]string, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
// 确保 baseURL 以 /v1 结尾
|
||||
if !strings.HasSuffix(baseURL, "/v1") {
|
||||
baseURL = baseURL + "/v1"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", baseURL+"/models", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求模型列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析模型列表失败: %w", err)
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
models = append(models, m.ID)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// fetchGeminiModels 获取 Gemini API 的模型列表
|
||||
func fetchGeminiModels(config ai.ProviderConfig) ([]string, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://generativelanguage.googleapis.com"
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", baseURL+"/v1beta/models?key="+config.APIKey, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求模型列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"` // e.g. "models/gemini-2.5-flash"
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("解析模型列表失败: %w", err)
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
// 去掉 "models/" 前缀
|
||||
name := m.Name
|
||||
if strings.HasPrefix(name, "models/") {
|
||||
name = strings.TrimPrefix(name, "models/")
|
||||
}
|
||||
models = append(models, name)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// --- 安全控制 ---
|
||||
|
||||
// AIGetSafetyLevel 获取当前安全级别
|
||||
func (s *Service) AIGetSafetyLevel() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return string(s.safetyLevel)
|
||||
}
|
||||
|
||||
// AISetSafetyLevel 设置安全级别
|
||||
func (s *Service) AISetSafetyLevel(level string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch ai.SQLPermissionLevel(level) {
|
||||
case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull:
|
||||
s.safetyLevel = ai.SQLPermissionLevel(level)
|
||||
default:
|
||||
s.safetyLevel = ai.PermissionReadOnly
|
||||
}
|
||||
s.guard.SetPermissionLevel(s.safetyLevel)
|
||||
_ = s.saveConfig()
|
||||
}
|
||||
|
||||
// --- 上下文控制 ---
|
||||
|
||||
// AIGetContextLevel 获取上下文传递级别
|
||||
func (s *Service) AIGetContextLevel() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return string(s.contextLevel)
|
||||
}
|
||||
|
||||
// AISetContextLevel 设置上下文传递级别
|
||||
func (s *Service) AISetContextLevel(level string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch ai.ContextLevel(level) {
|
||||
case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults:
|
||||
s.contextLevel = ai.ContextLevel(level)
|
||||
default:
|
||||
s.contextLevel = ai.ContextSchemaOnly
|
||||
}
|
||||
_ = s.saveConfig()
|
||||
}
|
||||
|
||||
// --- AI 对话 ---
|
||||
|
||||
// AIChatSend 同步发送 AI 对话(非流式)
|
||||
func (s *Service) AIChatSend(messages []map[string]string) map[string]interface{} {
|
||||
p, err := s.getActiveProvider()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "error": err.Error()}
|
||||
}
|
||||
|
||||
var aiMessages []ai.Message
|
||||
for _, m := range messages {
|
||||
aiMessages = append(aiMessages, ai.Message{Role: m["role"], Content: m["content"]})
|
||||
}
|
||||
|
||||
resp, err := p.Chat(context.Background(), ai.ChatRequest{Messages: aiMessages})
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "error": err.Error()}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"content": resp.Content,
|
||||
"tokensUsed": map[string]int{
|
||||
"promptTokens": resp.TokensUsed.PromptTokens,
|
||||
"completionTokens": resp.TokensUsed.CompletionTokens,
|
||||
"totalTokens": resp.TokensUsed.TotalTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AIChatStream 流式发送 AI 对话(通过 EventsEmit 推送)
|
||||
func (s *Service) AIChatStream(sessionID string, messages []map[string]string) {
|
||||
streamCtx, cancel := context.WithCancel(context.Background())
|
||||
s.mu.Lock()
|
||||
s.cancelFuncs[sessionID] = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.cancelFuncs, sessionID)
|
||||
s.mu.Unlock()
|
||||
cancel() // 确保释放
|
||||
}()
|
||||
|
||||
p, err := s.getActiveProvider()
|
||||
if err != nil {
|
||||
wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"done": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var aiMessages []ai.Message
|
||||
for _, m := range messages {
|
||||
aiMessages = append(aiMessages, ai.Message{Role: m["role"], Content: m["content"]})
|
||||
}
|
||||
|
||||
err = p.ChatStream(streamCtx, ai.ChatRequest{Messages: aiMessages}, func(chunk ai.StreamChunk) {
|
||||
wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{
|
||||
"content": chunk.Content,
|
||||
"done": chunk.Done,
|
||||
"error": chunk.Error,
|
||||
})
|
||||
})
|
||||
|
||||
// 当 context 被主动 cancel 的时候,不把这个视为向外抛的 error
|
||||
if err != nil && err != context.Canceled {
|
||||
wailsRuntime.EventsEmit(s.ctx, "ai:stream:"+sessionID, map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"done": true,
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AIChatCancel 立即终止某个 Session 的流式对话请求
|
||||
func (s *Service) AIChatCancel(sessionID string) {
|
||||
s.mu.RLock()
|
||||
cancel, ok := s.cancelFuncs[sessionID]
|
||||
s.mu.RUnlock()
|
||||
if ok && cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// AICheckSQL 检查 SQL 的安全性
|
||||
func (s *Service) AICheckSQL(sql string) ai.SafetyResult {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.guard.Check(sql)
|
||||
}
|
||||
|
||||
// --- 内部方法 ---
|
||||
|
||||
func (s *Service) getActiveProvider() (provider.Provider, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if s.activeProvider == "" && len(s.providers) > 0 {
|
||||
s.activeProvider = s.providers[0].ID
|
||||
}
|
||||
|
||||
for _, cfg := range s.providers {
|
||||
if cfg.ID == s.activeProvider {
|
||||
return provider.NewProvider(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("未配置 AI Provider,请先在设置中配置")
|
||||
}
|
||||
|
||||
// --- 配置持久化 ---
|
||||
|
||||
type aiConfig struct {
|
||||
Providers []ai.ProviderConfig `json:"providers"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
SafetyLevel string `json:"safetyLevel"`
|
||||
ContextLevel string `json:"contextLevel"`
|
||||
}
|
||||
|
||||
func (s *Service) loadConfig() {
|
||||
path := filepath.Join(s.configDir, "ai_config.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return // 首次启动,无配置文件
|
||||
}
|
||||
|
||||
var cfg aiConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
logger.Error(err, "加载 AI 配置失败")
|
||||
return
|
||||
}
|
||||
|
||||
s.providers = cfg.Providers
|
||||
if s.providers == nil {
|
||||
s.providers = make([]ai.ProviderConfig, 0)
|
||||
}
|
||||
s.activeProvider = cfg.ActiveProvider
|
||||
|
||||
switch ai.SQLPermissionLevel(cfg.SafetyLevel) {
|
||||
case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull:
|
||||
s.safetyLevel = ai.SQLPermissionLevel(cfg.SafetyLevel)
|
||||
default:
|
||||
s.safetyLevel = ai.PermissionReadOnly
|
||||
}
|
||||
s.guard.SetPermissionLevel(s.safetyLevel)
|
||||
|
||||
switch ai.ContextLevel(cfg.ContextLevel) {
|
||||
case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults:
|
||||
s.contextLevel = ai.ContextLevel(cfg.ContextLevel)
|
||||
default:
|
||||
s.contextLevel = ai.ContextSchemaOnly
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) saveConfig() error {
|
||||
cfg := aiConfig{
|
||||
Providers: s.providers,
|
||||
ActiveProvider: s.activeProvider,
|
||||
SafetyLevel: string(s.safetyLevel),
|
||||
ContextLevel: string(s.contextLevel),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化 AI 配置失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(s.configDir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建配置目录失败: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(s.configDir, "ai_config.json")
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("写入 AI 配置失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 工具函数 ---
|
||||
|
||||
func resolveConfigDir() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = "."
|
||||
}
|
||||
return filepath.Join(configDir, "GoNavi")
|
||||
}
|
||||
|
||||
func maskAPIKey(apiKey string) string {
|
||||
if len(apiKey) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return apiKey[:4] + "****" + apiKey[len(apiKey)-4:]
|
||||
}
|
||||
|
||||
func isMaskedAPIKey(apiKey string) bool {
|
||||
return strings.Contains(apiKey, "****")
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
85
internal/ai/types.go
Normal file
85
internal/ai/types.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package ai
|
||||
|
||||
// Message 表示一条对话消息
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system" | "user" | "assistant"
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatRequest AI 对话请求
|
||||
type ChatRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
}
|
||||
|
||||
// ChatResponse AI 对话响应
|
||||
type ChatResponse struct {
|
||||
Content string `json:"content"`
|
||||
TokensUsed TokenUsage `json:"tokensUsed"`
|
||||
}
|
||||
|
||||
// TokenUsage token 用量统计
|
||||
type TokenUsage struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
}
|
||||
|
||||
// StreamChunk 流式响应片段
|
||||
type StreamChunk struct {
|
||||
Content string `json:"content"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderConfig AI Provider 配置
|
||||
type ProviderConfig struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // openai | anthropic | gemini | custom
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"apiKey"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Model string `json:"model"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
// SQLPermissionLevel AI SQL 执行权限级别
|
||||
type SQLPermissionLevel string
|
||||
|
||||
const (
|
||||
PermissionReadOnly SQLPermissionLevel = "readonly"
|
||||
PermissionReadWrite SQLPermissionLevel = "readwrite"
|
||||
PermissionFull SQLPermissionLevel = "full"
|
||||
)
|
||||
|
||||
// ContextLevel AI 上下文传递级别
|
||||
type ContextLevel string
|
||||
|
||||
const (
|
||||
ContextSchemaOnly ContextLevel = "schema_only"
|
||||
ContextWithSamples ContextLevel = "with_samples"
|
||||
ContextWithResults ContextLevel = "with_results"
|
||||
)
|
||||
|
||||
// SQLOperationType SQL 操作类型
|
||||
type SQLOperationType string
|
||||
|
||||
const (
|
||||
SQLOpQuery SQLOperationType = "query" // SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||
SQLOpDML SQLOperationType = "dml" // INSERT, UPDATE, DELETE
|
||||
SQLOpDDL SQLOperationType = "ddl" // CREATE, ALTER, DROP, TRUNCATE
|
||||
SQLOpOther SQLOperationType = "other"
|
||||
)
|
||||
|
||||
// SafetyResult 安全检查结果
|
||||
type SafetyResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
OperationType SQLOperationType `json:"operationType"`
|
||||
RequiresConfirm bool `json:"requiresConfirm"`
|
||||
WarningMessage string `json:"warningMessage,omitempty"`
|
||||
}
|
||||
11
main.go
11
main.go
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
aiservice "GoNavi-Wails/internal/ai/service"
|
||||
"GoNavi-Wails/internal/app"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
@@ -19,6 +21,7 @@ var assets embed.FS
|
||||
func main() {
|
||||
// Create an instance of the app structure
|
||||
application := app.NewApp()
|
||||
aiService := aiservice.NewService()
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
@@ -30,10 +33,14 @@ func main() {
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
|
||||
OnStartup: application.Startup,
|
||||
OnShutdown: application.Shutdown,
|
||||
OnStartup: func(ctx context.Context) {
|
||||
application.Startup(ctx)
|
||||
aiService.Startup(ctx)
|
||||
},
|
||||
OnShutdown: application.Shutdown,
|
||||
Bind: []interface{}{
|
||||
application,
|
||||
aiService,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: true,
|
||||
|
||||
Reference in New Issue
Block a user