-
-
} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}>{isSidebarUltraCompact ? null : '工具'}
-
} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}>{isSidebarUltraCompact ? null : '代理'}
-
} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}>{isSidebarUltraCompact ? null : '主题'}
-
} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}>{isSidebarUltraCompact ? null : '关于'}
+
+
} style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)} />
+
} style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)} />
+
} style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)} />
+
} style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)} />
+
+
+ }
+ 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'
+ }}
+ />
+
@@ -1743,9 +1762,14 @@ function App() {
title="拖动调整宽度"
/>
-
-
-
+
+
+
+
+
+ {aiPanelVisible && (
+
setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
+ )}
{isLogPanelOpen && (
setIsDriverModalOpen(false)}
onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)}
/>
+ setIsAISettingsOpen(false)}
+ darkMode={darkMode}
+ overlayTheme={overlayTheme}
+ />
, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
open={isAboutOpen}
diff --git a/frontend/src/components/AIChatPanel.css b/frontend/src/components/AIChatPanel.css
new file mode 100644
index 00000000..08ebc1c3
--- /dev/null
+++ b/frontend/src/components/AIChatPanel.css
@@ -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;
+}
diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx
new file mode 100644
index 00000000..1f4cc406
--- /dev/null
+++ b/frontend/src/components/AIChatPanel.tsx
@@ -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 (
+ {
+ 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 ? : }
+ {copied ? '已复制' : '复制代码'}
+
+ );
+};
+
+const CodeRunBtn = ({ text }: { text: string }) => {
+ return (
+
+ {
+ 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'; }}
+ >
+
+ 插入
+
+
+ );
+};
+
+export const AIChatPanel: React.FC = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => {
+ const [input, setInput] = useState('');
+ const [sending, setSending] = useState(false);
+ const [activeProvider, setActiveProvider] = useState(null);
+ const [dynamicModels, setDynamicModels] = useState([]);
+ 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(null);
+ const textareaRef = useRef(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) => {
+ 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) => {
+ 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 (
+
+ {/* 拖拽手柄 */}
+
+ {/* Header */}
+
+
+
+ } onClick={() => setHistoryOpen(true)} style={{ color: mutedColor }} />
+
+
+
+
+
GoNavi AI
+
+
+
+ } onClick={handleClear} style={{ color: mutedColor }} />
+
+
+ } onClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} style={{ color: mutedColor }} />
+
+
+ } onClick={onClose} style={{ color: mutedColor }} />
+
+
+
+
+ {/* Messages */}
+
+ {messages.length === 0 ? (
+
+
+
+ 你好,我是 GoNavi AI
+
+
+ 我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。
+
+
+ {quickActions.map(action => (
+
setInput(action.prompt)}
+ >
+ {action.label}
+
+ ))}
+
+
+ ) : (
+ messages.map(msg => {
+ const isUser = msg.role === 'user';
+ return (
+
+
+
+
+ {isUser
+ ? <> You>
+ : <> GoNavi AI>}
+
+ {/* 气泡操作栏 */}
+
+ {isUser ? (
+
+ handleEditMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
+
+ ) : (
+
+ handleRetryMessage(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} />
+
+ )}
+
+ 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} />
+
+
+
+
+ {isUser ? (
+
{msg.content}
+ ) : (
+
+
+
{match[1]}
+
+ {match[1] === 'sql' && }
+
+
+
+
+ {String(children).replace(/\n$/, '')}
+
+
+ ) : (
+
+ {children}
+
+ );
+ }
+ }}
+ >
+ {msg.content}
+
+ )}
+ {msg.loading && (
+
+ )}
+
+
+
+ );
+ })
+ )}
+ {sending && !messages.some(m => m.role === 'assistant' && m.loading) && (
+
+ )}
+
+
+
+ {/* Scroll to bottom button */}
+ {showScrollBottom && (
+ { 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)'; }}
+ >
+
+
+ )}
+
+ {/* Input */}
+
+
+
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' }}
+ />
+
+
+ {activeConnName && (
+
+
+
+
+ {activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
+
+
+
+ )}
+
+ {activeProvider && (
+
+
+ {sending ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* 历史对话抽屉 */}
+ setHistoryOpen(false)}
+ open={historyOpen}
+ getContainer={false}
+ style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }}
+ width={260}
+ bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }}
+ >
+ {/* 侧拉面板头部 */}
+
+ 对话历史
+
+ } onClick={() => setHistoryOpen(false)} style={{ color: mutedColor }} />
+
+
+
+ {/* 新建对话按钮 */}
+
+ }
+ onClick={() => { createNewAISession(); setHistoryOpen(false); }}
+ style={{ borderColor: borderColor, color: textColor, background: 'transparent' }}
+ >
+ 开启新对话
+
+
+
+ {/* 列表容器 */}
+
+ {aiChatSessions.length === 0 ? (
+
暂无对话记录
+ ) : (
+ aiChatSessions.map(session => (
+
{ 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',
+ }}
+ >
+
+
+ {session.title || '新对话'}
+
+
+ {new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
+
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ deleteAISession(session.id);
+ }}
+ style={{ display: sid === session.id ? 'inline-flex' : undefined }}
+ />
+
+
+ ))
+ )}
+
+
+
+ );
+};
+
+export default AIChatPanel;
diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx
new file mode 100644
index 00000000..60331678
--- /dev/null
+++ b/frontend/src/components/AISettingsModal.tsx
@@ -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: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] },
+ { key: 'custom', label: '自定义', icon: , 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 = ({ open, onClose, darkMode, overlayTheme }) => {
+ const [providers, setProviders] = useState([]);
+ const [activeProviderId, setActiveProviderId] = useState('');
+ const [safetyLevel, setSafetyLevel] = useState('readonly');
+ const [contextLevel, setContextLevel] = useState('schema_only');
+ const [editingProvider, setEditingProvider] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
+ const [builtinPrompts, setBuiltinPrompts] = useState>({});
+ 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 = () => (
+
+ {providers.length === 0 && (
+
+
+ 暂未配置 AI Provider
+ 添加一个以开始使用 AI 助手
+
+ )}
+ {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 (
+
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,
+ }}>
+
+ {matchedPreset.icon ||
}
+
+
+
+ {p.name || p.type}
+ {isActive && }
+
+
+ {matchedPreset.label}
+ ·
+ {p.model}
+
+
+
+
+ }
+ onClick={e => { e.stopPropagation(); handleEditProvider(p); }}
+ style={{ color: overlayTheme.mutedText }} />
+
+ handleDeleteProvider(p.id)}
+ okButtonProps={{ danger: true }} okText="删除" cancelText="取消">
+ } danger
+ onClick={e => e.stopPropagation()} />
+
+
+
+ );
+ })}
+
} onClick={handleAddProvider}
+ style={{ borderRadius: 12, height: 42, borderColor: darkMode ? 'rgba(255,255,255,0.12)' : undefined }}>
+ 添加 Provider
+
+
+ );
+
+ // ===== Provider 编辑表单 =====
+ const renderProviderForm = () => {
+ const presetKeyFromForm = watchedPresetKey || (editingProvider as any)?.presetKey || 'openai';
+ return (
+
+ {/* 顶部返回 */}
+
+
+
+ {editingProvider?.id ? '编辑 Provider' : '添加 Provider'}
+
+
+
+
+
+
+ {/* 认证信息 */}
+
+
+ 认证 & 连接
+
+
+ Key}
+ style={{ borderRadius: 10, background: inputBg }} />
+
+ {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
+
+ URL}
+ suffix={}
+ style={{ borderRadius: 10, background: inputBg }} />
+
+ )}
+
+
+
+
+ {/* 操作按钮 */}
+
+ : undefined}>
+ {testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'}
+
+
+
+
+
+ );
+ };
+
+ // ===== 安全控制 =====
+ const renderSafetySettings = () => (
+
+
+ 控制 AI 可执行的 SQL 操作类型,保护数据安全
+
+ {SAFETY_OPTIONS.map(opt => {
+ const active = safetyLevel === opt.value;
+ return (
+
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,
+ }}>
+
+ {opt.icon}
+
+
+
+ {opt.label}
+ {active && }
+
+
{opt.desc}
+
+
+ );
+ })}
+
+ );
+
+ // ===== 上下文级别 =====
+ const renderContextSettings = () => (
+
+
+ 控制发送给 AI 的数据库上下文信息量
+
+ {CONTEXT_OPTIONS.map(opt => {
+ const active = contextLevel === opt.value;
+ return (
+
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,
+ }}>
+
+ {opt.icon}
+
+
+
+ {opt.label}
+ {active && }
+
+
{opt.desc}
+
+
+ );
+ })}
+
+ );
+
+ const renderBuiltinPrompts = () => (
+
+
+ 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。
+
+ {Object.entries(builtinPrompts).map(([title, promptText]) => (
+
+
+ {title}
+
+
+ {promptText}
+
+
+ ))}
+
+ );
+
+ const tabItems = [
+ { key: 'providers', label: Provider, children: isEditing ? renderProviderForm() : renderProviderList() },
+ { key: 'safety', label: 安全控制, children: renderSafetySettings() },
+ { key: 'context', label: 上下文, children: renderContextSettings() },
+ { key: 'prompts', label: 内置提示词, children: renderBuiltinPrompts() },
+ ];
+
+ const modalShellStyle = {
+ background: overlayTheme.shellBg, border: overlayTheme.shellBorder,
+ boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter,
+ };
+
+ return (
+
+
+
+
+
+
AI 设置
+
+ 配置 AI 模型、安全级别和上下文选项
+
+
+
+ }
+ 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' },
+ }}
+ >
+
+
+ );
+};
+
+export default AISettingsModal;
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index bb3f3538..0ec7de6e 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -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
= ({
>
)}
+ <>
+
+
+ }
+ 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 数据洞察
+
+
+ >
+
{isDuckDBConnection && onRequestTotalCount && (
<>
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 5f665607..3bdb46ea 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -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([]);
const [activeResultKey, setActiveResultKey] = useState('');
-
const [loading, setLoading] = useState(false);
+ const [executionError, setExecutionError] = useState('');
const [, setCurrentQueryId] = useState('');
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 = {
+ 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 }) => {
} />
+
+ , onClick: () => handleAIAction('generate') },
+ { key: 'ai-explain', label: '解释 SQL', icon: , onClick: () => handleAIAction('explain') },
+ { key: 'ai-optimize', label: '优化 SQL', icon: , onClick: () => handleAIAction('optimize') },
+ { type: 'divider' as const },
+ { key: 'ai-schema', label: 'Schema 分析', icon: , onClick: () => handleAIAction('schema') },
+ ] }} placement="bottomRight">
+ } style={{ color: '#818cf8' }}>AI
+
@@ -2168,6 +2264,35 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
})()
}))}
/>
+ ) : executionError ? (
+
+
+
+ 执行失败
+
+
+ {executionError}
+
+
+ }
+ 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 诊断
+
+
+
) : (
)}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index d0f4e476..09524cde 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -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 (
-
-
-
-
-
-
+
+
+ }
+ />
{/* Toolbar */}
-
-
}
- onClick={() => {
- setRenameViewTarget(null); // Create mode
- createTagForm.resetFields();
- setIsCreateTagModalOpen(true);
- }}
- style={{ flex: '1 1 auto' }}
- >
- 新建组
-
-
}
- onClick={() => openBatchOperationModal()}
- style={{ flex: '1 1 auto' }}
- >
- 批量操作表
-
-
}
- onClick={() => openBatchDatabaseModal()}
- style={{ flex: '1 1 auto' }}
- >
- 批量操作库
-
-
}
- onClick={handleOpenSQLFileFromToolbar}
- style={{ flex: '1 1 auto' }}
- >
- 运行外部SQL文件
-
+
+
+ } onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
+
+
+ } onClick={() => openBatchOperationModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
+
+
+ } onClick={() => openBatchDatabaseModal()} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
+
+
+ } onClick={handleOpenSQLFileFromToolbar} style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }} />
+
diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx
index 4a937835..f1d641d5 100644
--- a/frontend/src/components/TableOverview.tsx
+++ b/frontend/src/components/TableOverview.tsx
@@ -138,6 +138,7 @@ const TableOverview: React.FC
= ({ 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([]);
@@ -195,6 +196,7 @@ const TableOverview: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ tab }) => {
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: , onClick: () => {
+ setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index 6a871828..e671270b 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -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; // 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) => 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()(
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()(
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()(
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()(
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()(
windowBounds: state.windowBounds,
windowState: state.windowState,
sidebarWidth: state.sidebarWidth,
+
+ aiChatHistory: state.aiChatHistory,
+ aiChatSessions: state.aiChatSessions,
}), // Don't persist logs
}
)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ea108674..072d65cb 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -183,3 +183,39 @@ export interface StreamEntry {
id: string;
fields: Record;
}
+
+// --- 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;
+ 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;
+}
+
diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts
new file mode 100644
index 00000000..872b5f50
--- /dev/null
+++ b/frontend/wailsjs/go/aiservice/Service.d.ts
@@ -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;
+
+export function AIChatSend(arg1:Array>):Promise>;
+
+export function AIChatStream(arg1:string,arg2:Array>):Promise;
+
+export function AICheckSQL(arg1:string):Promise;
+
+export function AIDeleteProvider(arg1:string):Promise;
+
+export function AIGetActiveProvider():Promise;
+
+export function AIGetBuiltinPrompts():Promise>;
+
+export function AIGetContextLevel():Promise;
+
+export function AIGetProviders():Promise>;
+
+export function AIGetSafetyLevel():Promise;
+
+export function AIListModels():Promise>;
+
+export function AISaveProvider(arg1:ai.ProviderConfig):Promise;
+
+export function AISetActiveProvider(arg1:string):Promise;
+
+export function AISetContextLevel(arg1:string):Promise;
+
+export function AISetSafetyLevel(arg1:string):Promise;
+
+export function AITestProvider(arg1:ai.ProviderConfig):Promise>;
+
+export function Startup(arg1:context.Context):Promise;
diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js
new file mode 100644
index 00000000..2e3dcf47
--- /dev/null
+++ b/frontend/wailsjs/go/aiservice/Service.js
@@ -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);
+}
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index d6fb6a4a..2baf781a 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -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;
@@ -197,6 +198,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise;
+export function Startup(arg1:context.Context):Promise;
+
export function TestConnection(arg1:connection.ConnectionConfig):Promise;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index 495177ec..f0e3782a 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -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);
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 9a25b590..258b1485 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -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;
+ 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 {
diff --git a/internal/ai/context/builder.go b/internal/ai/context/builder.go
new file mode 100644
index 00000000..9c4e75a6
--- /dev/null
+++ b/internal/ai/context/builder.go
@@ -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 窗口函数 等)。`
+}
+
diff --git a/internal/ai/context/collector.go b/internal/ai/context/collector.go
new file mode 100644
index 00000000..bfa6c36d
--- /dev/null
+++ b/internal/ai/context/collector.go
@@ -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"`
+}
diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go
new file mode 100644
index 00000000..035d2fca
--- /dev/null
+++ b/internal/ai/provider/anthropic.go
@@ -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
+}
diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go
new file mode 100644
index 00000000..824e4136
--- /dev/null
+++ b/internal/ai/provider/claude_cli.go
@@ -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"`
+}
diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go
new file mode 100644
index 00000000..7900ec27
--- /dev/null
+++ b/internal/ai/provider/custom.go
@@ -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)
+}
diff --git a/internal/ai/provider/gemini.go b/internal/ai/provider/gemini.go
new file mode 100644
index 00000000..0c5eee71
--- /dev/null
+++ b/internal/ai/provider/gemini.go
@@ -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
+}
diff --git a/internal/ai/provider/openai.go b/internal/ai/provider/openai.go
new file mode 100644
index 00000000..ff674a9a
--- /dev/null
+++ b/internal/ai/provider/openai.go
@@ -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
+}
diff --git a/internal/ai/provider/openai_test.go b/internal/ai/provider/openai_test.go
new file mode 100644
index 00000000..94671a45
--- /dev/null
+++ b/internal/ai/provider/openai_test.go
@@ -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)
+ }
+}
diff --git a/internal/ai/provider/provider.go b/internal/ai/provider/provider.go
new file mode 100644
index 00000000..e9f1d8e1
--- /dev/null
+++ b/internal/ai/provider/provider.go
@@ -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
+}
diff --git a/internal/ai/provider/registry.go b/internal/ai/provider/registry.go
new file mode 100644
index 00000000..2cd0c08c
--- /dev/null
+++ b/internal/ai/provider/registry.go
@@ -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)
+ }
+}
diff --git a/internal/ai/safety/classifier.go b/internal/ai/safety/classifier.go
new file mode 100644
index 00000000..dfd9816c
--- /dev/null
+++ b/internal/ai/safety/classifier.go
@@ -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)
+}
diff --git a/internal/ai/safety/classifier_test.go b/internal/ai/safety/classifier_test.go
new file mode 100644
index 00000000..280b2613
--- /dev/null
+++ b/internal/ai/safety/classifier_test.go
@@ -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")
+ }
+}
diff --git a/internal/ai/safety/guard.go b/internal/ai/safety/guard.go
new file mode 100644
index 00000000..ca31bf24
--- /dev/null
+++ b/internal/ai/safety/guard.go
@@ -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
+ }
+}
diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go
new file mode 100644
index 00000000..52af6ba2
--- /dev/null
+++ b/internal/ai/service/service.go
@@ -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] + "..."
+}
diff --git a/internal/ai/types.go b/internal/ai/types.go
new file mode 100644
index 00000000..eb55a6f7
--- /dev/null
+++ b/internal/ai/types.go
@@ -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"`
+}
diff --git a/main.go b/main.go
index 02cedcb2..4e3bc591 100644
--- a/main.go
+++ b/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,