import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '../store';
import { EventsOn, EventsOff } from '../../wailsjs/runtime';
import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { AIChatMessage, AIToolCall } from '../types';
import { DownOutlined } from '@ant-design/icons';
import './AIChatPanel.css';
import { AIChatHeader } from './ai/AIChatHeader';
import { AIChatWelcome } from './ai/AIChatWelcome';
import { AIMessageBubble } from './ai/AIMessageBubble';
import { AIChatInput } from './ai/AIChatInput';
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
import type { AIComposerNotice } from '../utils/aiComposerNotice';
import {
buildMissingModelNotice,
buildMissingProviderNotice,
buildModelFetchFailedNotice,
} from '../utils/aiComposerNotice';
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)}`;
export const getDynamicMaxContextChars = (modelName?: string) => {
if (!modelName) return 258000; // 默认 258k (2026主流基线)
const lower = modelName.toLowerCase();
// 「星际杯」- 百万到千万级 Tokens (保守取 2~5M 字符)
if (lower.includes('gemini-1.5-pro') || lower.includes('gemini-2') || lower.includes('gemini-3')) {
return 5000000;
}
// 「超大杯」- 1M Tokens (针对 2026 旗舰:约 1,000,000 字符)
if (lower.includes('glm-5') || lower.includes('claude-4') || lower.includes('claude-3.7') || lower.includes('gpt-5') || lower.includes('qwen3') || lower.includes('deepseek-v4')) {
return 1000000;
}
if (lower.includes('claude-3-opus') || lower.includes('claude-3.5') || lower.includes('glm-4-long') || lower.includes('qwen-long')) {
return 1000000;
}
// 「大杯」- 200K ~ 258K Tokens (针对现代主流:约 258,000 字符)
if (lower.includes('claude') || lower.includes('deepseek') || lower.includes('gpt-4.5') || lower.includes('qwen2.5')) {
return 258000;
}
// 「中杯/小杯」- 128K Tokens (老基线:约 128,000 字符)
if (lower.includes('gpt-4') || lower.includes('gpt-4o') || lower.includes('glm') || lower.includes('z-ai')) {
return 128000;
}
if (lower.includes('qwen')) {
return 128000;
}
// Default fallback
return 258000;
};
// 当超出指定字符上限时触发上下文自建压缩
const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
try {
const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0);
if (chars < maxLimit) return null;
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIChatSend) return null;
const connectingMsgId = genId();
useStore.getState().addAIChatMessage(sid, {
id: connectingMsgId, role: 'assistant', phase: 'connecting', content: '⚙️ 对话已超载,正在启动记忆压缩...', timestamp: Date.now(), loading: true
});
const summaryPrompt = `这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心,请你仔细阅读并以“技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展”为准则,进行高度浓缩的结构化总结。
注意:
1. 客观准确,不能遗漏关键业务逻辑或探索出的表名/字段。
2. 剔除无效执行过程、客套话、JSON返回值本身。
3. 请控制在 1000-2000 字左右,输出纯干货 Markdown。
4. 开头直接输出总结,不要带寒暄。`;
const sysMsg = { role: 'system', content: summaryPrompt };
const result = await Service.AIChatSend([sysMsg, ...messagesPayload]);
if (result?.success && result.content) {
useStore.getState().deleteAIChatMessage(sid, connectingMsgId);
return result.content;
} else {
useStore.getState().updateAIChatMessage(sid, connectingMsgId, { loading: false, phase: 'idle', content: '❌ 记忆压缩失败,将尝试原样接续...' });
}
} catch (e) {
console.error("Compression exception:", e);
}
return null;
};
// 清洗错误信息:去除 HTML 标签、提取关键错误描述、截断过长文本
const sanitizeErrorMsg = (raw: string): string => {
if (!raw || typeof raw !== 'string') return '未知错误';
// 检测 HTML 内容
if (raw.includes(' 内容
const titleMatch = raw.match(/
]*>([^<]+)<\/title>/i);
// 尝试提取 HTTP 状态码
const codeMatch = raw.match(/\b(4\d{2}|5\d{2})\b/);
const title = titleMatch?.[1]?.trim();
const code = codeMatch?.[1];
if (title) return code ? `HTTP ${code}: ${title}` : title;
if (code) return `HTTP ${code} 服务端错误`;
return '服务端返回了异常 HTML 响应(可能是网关超时或服务不可用)';
}
// 截断过长的纯文本错误
if (raw.length > 300) return raw.substring(0, 280) + '...(已截断)';
return raw;
};
const LOCAL_TOOLS = [
{
type: 'function',
function: {
name: 'get_connections',
description: '当需要查询、操作数据库但用户没有选择任何连接上下文时,获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'get_databases',
description: '获取指定连接(connectionId)下的所有数据库(Database/Schema)名。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID (从 get_connections 获取)' }
},
required: ['connectionId']
}
}
},
{
type: 'function',
function: {
name: 'get_tables',
description: '当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
},
required: ['connectionId', 'dbName']
}
}
},
{
type: 'function',
function: {
name: 'get_columns',
description: '获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
tableName: { type: 'string', description: '表名' },
},
required: ['connectionId', 'dbName', 'tableName']
}
}
},
{
type: 'function',
function: {
name: 'get_table_ddl',
description: '获取指定表的完整建表语句(CREATE TABLE DDL),包含字段、索引、约束等完整结构信息。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
tableName: { type: 'string', description: '表名' },
},
required: ['connectionId', 'dbName', 'tableName']
}
}
},
{
type: 'function',
function: {
name: 'execute_sql',
description: '在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。',
parameters: {
type: 'object',
properties: {
connectionId: { type: 'string', description: '连接ID' },
dbName: { type: 'string', description: '数据库名' },
sql: { type: 'string', description: '要执行的 SQL 语句' },
},
required: ['connectionId', 'dbName', 'sql']
}
}
}
];
export const AIChatPanel: React.FC = ({
width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme
}) => {
const [input, setInput] = useState('');
const [draftImages, setDraftImages] = 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 [composerNotice, setComposerNotice] = useState(null);
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 toolCallRoundRef = useRef(0); // 连续失败轮次计数
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
const createNewAISession = useStore(state => state.createNewAISession);
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 updateAISessionTitle = useStore(state => state.updateAISessionTitle);
const activeContext = useStore(state => state.activeContext);
const aiContexts = useStore(state => state.aiContexts);
const connections = useStore(state => state.connections);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
// Auto-Context Injection Hook
useEffect(() => {
if (!aiPanelVisible) return;
const activeTab = tabs.find(t => t.id === activeTabId);
if (activeTab && (activeTab.type === 'table' || activeTab.type === 'design')) {
const { connectionId, dbName, tableName } = activeTab;
if (connectionId && dbName && tableName) {
const connKey = `${connectionId}:${dbName}`;
const currentContexts = useStore.getState().aiContexts[connKey] || [];
if (!currentContexts.find(c => c.dbName === dbName && c.tableName === tableName)) {
const conn = useStore.getState().connections.find(c => c.id === connectionId);
if (conn) {
import('../../wailsjs/go/app/App').then(({ DBShowCreateTable }) => {
DBShowCreateTable(conn.config as any, dbName, tableName).then(res => {
if (res.success && res.data) {
let createSql = '';
if (typeof res.data === 'string') createSql = res.data;
else if (Array.isArray(res.data) && res.data.length > 0) {
const row = res.data[0];
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
}
if (createSql) {
useStore.getState().addAIContext(connKey, { dbName: dbName, tableName, ddl: createSql });
}
}
});
}).catch(err => console.error("Failed to auto-fetch table context", err));
}
}
}
}
}, [aiPanelVisible, activeTabId, tabs]);
useEffect(() => {
if (!aiActiveSessionId) {
createNewAISession();
}
}, [aiActiveSessionId, createNewAISession]);
const sid = aiActiveSessionId || 'session-fallback';
// 面板首次可见时从后端加载会话列表
const sessionsLoadedRef = useRef(false);
useEffect(() => {
if (!aiPanelVisible || sessionsLoadedRef.current) return;
sessionsLoadedRef.current = true;
loadAISessionsFromBackend();
}, [aiPanelVisible]);
// 切换会话时按需从后端加载消息
useEffect(() => {
if (sid && sid !== 'session-fallback') {
loadAISessionFromBackend(sid);
}
}, [sid]);
const messages = aiChatHistory[sid] || [];
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 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 quickActionBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
const quickActionBorder = overlayTheme.sectionBorder;
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]);
// 监听供应商配置变更(来自设置面板的删除/新增/切换操作),重新加载 active provider 并清空已缓存的模型
useEffect(() => {
const handler = () => {
setDynamicModels([]);
setComposerNotice(null);
activeProviderIdRef.current = null;
loadActiveProvider();
};
window.addEventListener('gonavi:ai:provider-changed', handler);
return () => window.removeEventListener('gonavi:ai:provider-changed', handler);
}, [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);
setComposerNotice(null);
} catch (e) { console.warn('Failed to update provider model', e); }
};
const activeProviderIdRef = useRef(null);
useEffect(() => {
if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) {
setDynamicModels([]);
setComposerNotice(null);
activeProviderIdRef.current = activeProvider.id;
}
// 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型
if (!activeProvider) {
setDynamicModels([]);
setComposerNotice(null);
activeProviderIdRef.current = null;
}
}, [activeProvider?.id, activeProvider]);
useEffect(() => {
if (activeProvider?.model && String(activeProvider.model).trim()) {
setComposerNotice(null);
}
}, [activeProvider?.model]);
// dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表
const fetchDynamicModels = useCallback(async () => {
try {
setLoadingModels(true);
setComposerNotice(null);
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) {
const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b));
setDynamicModels(sortedModels);
setComposerNotice(null);
} else if (result && !result.success) {
setDynamicModels([]);
setComposerNotice(buildModelFetchFailedNotice(result.error));
}
} catch (e: any) {
console.warn('Failed to fetch models', e);
setDynamicModels([]);
setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误')));
} finally {
setLoadingModels(false);
}
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' });
}, [messages.length, sending]);
useEffect(() => {
const timer = setTimeout(() => {
textareaRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.prompt) {
setInput(detail.prompt);
setTimeout(() => {
const el = textareaRef.current as any;
if (el) {
el.focus();
}
}, 50);
}
};
window.addEventListener('gonavi:ai:inject-prompt', handler);
return () => window.removeEventListener('gonavi:ai:inject-prompt', handler);
}, []);
useEffect(() => {
const eventName = `ai:stream:${sid}`;
let assistantMsgId = '';
let isFirstCompletion = false;
// 新增:利用 requestAnimationFrame 缓冲高频事件,避免 React 重绘阻塞导致感官吞吐变慢
const streamBuffer = { thinking: '', content: '' };
let flushPending = false;
const flushStreamBuffer = () => {
if (!assistantMsgId) return;
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find(m => m.id === assistantMsgId);
if (!existing) return;
const updates: any = {};
if (streamBuffer.thinking) {
updates.thinking = (existing.thinking || '') + streamBuffer.thinking;
updates.phase = 'thinking';
streamBuffer.thinking = '';
}
if (streamBuffer.content) {
updates.content = (existing.content || '') + streamBuffer.content;
updates.phase = 'generating';
streamBuffer.content = '';
}
if (Object.keys(updates).length > 0) {
updateAIChatMessage(sid, assistantMsgId, updates);
}
flushPending = false;
};
const handler = (data: { content?: string; thinking?: string; tool_calls?: AIToolCall[]; done?: boolean; error?: string }) => {
// Find connecting message if there's no active assistant string
if (!assistantMsgId) {
const history = useStore.getState().aiChatHistory[sid] || [];
const lastMsg = history[history.length - 1];
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.loading && lastMsg.phase === 'connecting') {
assistantMsgId = lastMsg.id;
// 【关键】接管 connecting 消息时,立即清空其过渡文案,防止泄漏到 AI 回复正文
updateAIChatMessage(sid, assistantMsgId, { content: '' });
}
}
if (data.error) {
const cleanErr = sanitizeErrorMsg(data.error);
const rawErr = cleanErr !== data.error ? data.error : undefined;
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr });
} else {
addAIChatMessage(sid, { id: genId(), role: 'assistant', phase: 'idle', content: `❌ 错误: ${cleanErr}`, rawError: rawErr, timestamp: Date.now() });
}
assistantMsgId = '';
setSending(false);
return;
}
if (data.tool_calls && data.tool_calls.length > 0) {
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' });
} else {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'tool_calling', content: '', tool_calls: data.tool_calls, timestamp: Date.now(), loading: true });
}
}
// 处理 thinking(模型思考过程)
if (data.thinking) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'thinking', content: '', thinking: data.thinking, timestamp: Date.now(), loading: true });
if (sending) setSending(false);
} else {
streamBuffer.thinking += data.thinking;
if (sending) setSending(false);
}
}
if (data.content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'generating', content: data.content, timestamp: Date.now(), loading: true });
setSending(false);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
if (currentHistory.length <= 1) isFirstCompletion = true;
} else {
streamBuffer.content += data.content;
if (sending) setSending(false);
}
}
if (streamBuffer.thinking || streamBuffer.content) {
if (!flushPending) {
flushPending = true;
requestAnimationFrame(flushStreamBuffer);
}
}
if (data.done) {
// 如果有残留未 flush 的 buffer,立刻推入状态树
if (streamBuffer.thinking || streamBuffer.content) {
flushStreamBuffer();
}
const doneAssistantId = assistantMsgId;
const doneIsFirst = isFirstCompletion;
assistantMsgId = '';
setTimeout(() => {
// 🔧 清除所有残留的 connecting 过渡气泡的 loading 状态
const currentMsgs = useStore.getState().aiChatHistory[sid] || [];
for (const msg of currentMsgs) {
if (msg.id !== doneAssistantId && msg.loading && msg.phase === 'connecting') {
updateAIChatMessage(sid, msg.id, { loading: false, phase: 'idle' });
}
}
if (doneAssistantId) {
const current = useStore.getState().aiChatHistory[sid];
const existing = current?.find(m => m.id === doneAssistantId);
if (existing && existing.tool_calls && existing.tool_calls.length > 0) {
// 【关键】保持 loading:true 和 phase:'tool_calling',让 UI 能实时展示工具执行进度
nudgeCountRef.current = 0;
setTimeout(() => executeLocalTools(existing.tool_calls!, doneAssistantId), 50);
return;
}
// 自动催促:模型描述了要调用工具但没有 function call
if (existing && nudgeCountRef.current < 2 &&
/(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[::]?\s*$/.test(existing.content || '')) {
nudgeCountRef.current += 1;
// 🔧 关闭当前消息的 loading 状态,消除闪烁光标
updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' });
// 注入 system 催促并重发
(async () => {
try {
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
const messagesPayload = currentHistory.map(m => {
const mapped: any = { role: m.role, content: m.content, images: m.images };
if (m.tool_calls) mapped.tool_calls = m.tool_calls;
if (m.tool_call_id) mapped.tool_call_id = m.tool_call_id;
return mapped;
});
const sysMessages = await buildSystemContextMessages();
// 追加催促消息
messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' });
const allMsg = [...sysMessages, ...messagesPayload];
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, LOCAL_TOOLS);
} catch (e) {
console.error('Nudge failed', e);
setSending(false);
}
})();
return;
}
if (doneIsFirst) generateTitleForSession(sid);
// 正常完成:关闭 loading,消除闪烁光标
const hasContent = !!existing?.content?.trim();
const hasThinking = !!existing?.thinking?.trim();
const hasTools = !!(existing?.tool_calls?.length);
if (!hasContent && !hasThinking && !hasTools) {
updateAIChatMessage(sid, doneAssistantId, { content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', loading: false, phase: 'idle' });
} else {
updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' });
}
} else {
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ 请求中断:未收到任何具体回复。', timestamp: Date.now(), loading: false });
}
setSending(false);
}, 50);
}
};
EventsOn(eventName, handler);
return () => { EventsOff(eventName); };
}, [addAIChatMessage, updateAIChatMessage, sid]);
const generateTitleForSession = async (currentSid: string) => {
try {
const Service = (window as any).go?.aiservice?.Service;
const historyLocal = useStore.getState().aiChatHistory[currentSid] || [];
if (!Service?.AIChatSend || historyLocal.length < 2) return;
const firstUserMsg = historyLocal.find(m => m.role === 'user');
if (firstUserMsg) {
// 取用前 50 个字符截断,防止太长的查询消耗过多 Token
const snippet = firstUserMsg.content.slice(0, 50);
const titleReq = [
{ role: 'system', content: 'You are a summarizer. Provide a short 3-6 word title for this prompt. Do not use quotes, punctuation, or explain. Just the title in the same language as the prompt.' },
{ role: 'user', content: snippet }
];
const res = await Service.AIChatSend(titleReq);
if (res?.success && res.content) {
const cleanTitle = res.content.trim().replace(/^["']|["']$/g, '');
updateAISessionTitle(currentSid, cleanTitle);
}
}
} catch (e) {
console.warn('Failed to auto-generate title', e);
}
};
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);
setSending(true);
const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1);
const messagesPayload = truncatedHistory.map(m => ({ role: m.role, content: m.content, images: m.images }));
try {
const sysMessages = await buildSystemContextMessages();
const allMessages = [...sysMessages, ...messagesPayload];
const Service = (window as any).go?.aiservice?.Service;
if (Service?.AIChatStream) {
await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS);
} else if (Service?.AIChatSend) {
const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS);
const errRaw = result?.error || '未知错误';
const errClean = sanitizeErrorMsg(errRaw);
addAIChatMessage(sid, {
id: genId(), role: 'assistant',
content: result?.success ? result.content : `❌ ${errClean}`,
rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined,
timestamp: Date.now()
});
setSending(false);
} else {
setSending(false);
}
} catch(e: any) {
const rawE = e?.message || String(e);
const cleanE = sanitizeErrorMsg(rawE);
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE}`, rawError: cleanE !== rawE ? rawE : undefined, timestamp: Date.now() });
setSending(false);
}
}
}, [sid, truncateAIChatMessages, addAIChatMessage]);
const buildSystemContextMessages = useCallback(async () => {
// 🔧 性能优化:从 store 实时读取,避免闭包捕获导致的依赖链式重建
const { activeContext: ctx, aiContexts: ctxMap, connections: conns, tabs: allTabs, activeTabId: tabId } = useStore.getState();
const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default';
const activeContextItems = ctxMap[connectionKey] || [];
const systemMessages: { role: string; content: string; images?: string[] }[] = [];
let targetConnId = ctx?.connectionId;
let targetDbName = ctx?.dbName;
if (!targetConnId || !targetDbName) {
const activeTab = allTabs.find(t => t.id === tabId);
if (activeTab && activeTab.connectionId && activeTab.dbName) {
targetConnId = activeTab.connectionId;
targetDbName = activeTab.dbName;
}
}
if (activeContextItems.length > 0) {
const conn = conns.find(c => c.id === targetConnId);
const dbType = conn?.config?.type || 'unknown';
const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1);
const ddlChunks = activeContextItems.map(c => `-- Table: ${c.dbName}.${c.tableName}\n${c.ddl}`).join('\n\n');
systemMessages.push({
role: 'system',
content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType}。请使用 ${dbDisplayType} 方言生成 SQL。以下是用户关联的表结构信息,请在回答时优先参考:\n\n${ddlChunks}`
});
}
else if (targetConnId && targetDbName) {
const conn = conns.find(c => c.id === targetConnId);
const dbType = conn?.config?.type || 'unknown';
const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1);
systemMessages.push({
role: 'system',
content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType},当前数据库名为 ${targetDbName}。如果用户需要查询特定的表或者有关当前库的信息,你可以调用提供的 get_tables 工具来主动获取数据表信息。`
});
}
else {
const connList = conns.map(c => `{id: "${c.id}", name: "${c.name}", type: "${c.config?.type || 'unknown'}"}`).join(', ');
systemMessages.push({
role: 'system',
content: `你是一个专业的数据库助手。用户目前在界面上没有选中任何具体的数据库或数据表用于充当上下文。
重要规则:
1. 如果你需要帮用户寻找目标表,千万不要凭空猜测表名!必须调用工具去获取真实数据。
2. 完整工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL。每一步都不可跳过。
3. 【连接优先级 - 极重要】获取连接列表后,必须按以下优先级依次检索:
- 第一优先:host 为 localhost、127.0.0.1、或包含"本地"的连接
- 第二优先:name 或 host 包含"开发"、"dev"、"local" 的连接,或 host 为 10.x、192.168.x、172.16-31.x 等内网 IP 的连接
- 第三优先:其他连接(如"测试"、"生产"等)
如果在高优先级连接中已找到目标表,直接使用该连接,不再查找低优先级连接。
4. 如果在当前数据库中未找到目标表,必须继续查询其他数据库,不要放弃。
5. 只有当所有可能的数据库都已检查完毕,或者已经明确找到目标表时,才可以停止。
6. 如果是常规问答(不涉及数据库查询)则正常作答即可。
SQL 生成规则(极重要,必须严格遵守):
7. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel,不得写成 pay_channel)。
8. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身。
9. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭。
10. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。
11. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例:
\`\`\`sql
-- @context connectionId=1770778676549 dbName=mkefu_test
SELECT * FROM users WHERE status = 1;
\`\`\`
当前存在的连接:[${connList || '无连接'}]`
});
}
return systemMessages;
}, []); // 零依赖:函数内部通过 useStore.getState() 实时读取
// 记录所有成功的 get_tables 调用结果,用于表级精确匹配
const toolContextMapRef = useRef