Files

1 line
343 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "1847123733/dev-toolbox", "files": {"src/main/services/sqlExpert.ts": {"8d9966653078ad9b494c05f3b95bcf1f3f4af356": {"t": 1785746047.3840098, "n": 53967, "h": "3c956195e473cffd", "c": "/**\n * 企业级分析专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'\nimport { createHash, randomUUID } from 'crypto'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n provider?: string\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface ToolCallRecord {\n id: string\n name: string\n args: Record<string, unknown>\n result?: Record<string, unknown>\n status?: string\n errorMessage?: string\n}\n\ninterface AskAiMessage {\n role: 'user' | 'assistant'\n content: string\n status?: 'done' | 'loading' | 'error' | 'stopped'\n toolCalls?: ToolCallRecord[]\n}\n\ninterface AskAiPayload {\n requestId?: string\n messages: AskAiMessage[]\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\ninterface MemoryEntry {\n id: string\n content: string\n createdAt: string\n updatedAt: string\n source: 'tool' | 'manual'\n}\n\ninterface AgentUsage {\n promptTokens: number\n completionTokens: number\n totalTokens: number\n promptCacheHitTokens: number\n promptCacheMissTokens: number\n}\n\ntype AiProvider = 'deepseek' | 'openai' | 'ark' | 'anthropic'\n\ninterface ModelOptionItem {\n label: string\n value: string\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\nconst activeAiRequests = new Map<string, AbortController>()\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction getMemoriesDir(): string {\n const dir = join(getConfigDir(), 'memories')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction sanitizeFileName(value: string) {\n return value.replace(/[\\\\/:*?\"<>|]+/g, '_').trim()\n}\n\nfunction inferProviderFromUrl(url: string): AiProvider {\n const normalized = String(url || '').toLowerCase()\n if (normalized.includes('api.openai.com')) return 'openai'\n if (normalized.includes('ark.cn-beijing.volces.com') || normalized.includes('volces.com/api/v3')) return 'ark'\n if (normalized.includes('api.anthropic.com')) return 'anthropic'\n return 'deepseek'\n}\n\nfunction normalizeProvider(rawProvider?: string, url?: string): AiProvider {\n const normalized = String(rawProvider || '').trim().toLowerCase()\n if (normalized === 'deepseek' || normalized === 'openai' || normalized === 'ark' || normalized === 'anthropic') {\n return normalized as AiProvider\n }\n return inferProviderFromUrl(String(url || ''))\n}\n\nfunction buildAiRequestErrorMessage(rawMessage: string, provider: AiProvider, model: string): string {\n const message = String(rawMessage || '').trim() || 'AI 请求失败'\n if (provider !== 'ark') return message\n\n const lower = message.toLowerCase()\n const isEndpointOrModelNotFound =\n lower.includes('invalidendpointormodel') ||\n lower.includes('model or endpoint') ||\n lower.includes('does not exist or you do not have access')\n\n if (!isEndpointOrModelNotFound) return message\n\n const hint =\n '当前配置使用 Model ID。该报错通常表示当前账号/项目对该 Model ID 无权限,或该模型未开通。'\n return `${message}\\n${hint}\\n当前 Model: ${model || '(空)'}`\n}\n\nfunction buildModelOptionItems(rawList: any[], provider: AiProvider): ModelOptionItem[] {\n const options: ModelOptionItem[] = []\n const usedValues = new Set<string>()\n const addOption = (label: string, value: string) => {\n const finalLabel = String(label || '').trim()\n const finalValue = String(value || '').trim()\n if (!finalLabel || !finalValue || usedValues.has(finalValue)) return\n usedValues.add(finalValue)\n options.push({ label: finalLabel, value: finalValue })\n }\n\n const collectStringValues = (input: unknown, maxDepth = 4, depth = 0): string[] => {\n if (depth > maxDepth || input == null) return []\n if (typeof input === 'string') return [input]\n if (Array.isArray(input)) return input.flatMap(item => collectStringValues(item, maxDepth, depth + 1))\n if (typeof input === 'object') {\n return Object.values(input as Record<string, unknown>).flatMap(value =>\n collectStringValues(value, maxDepth, depth + 1)\n )\n }\n return []\n }\n\n for (const item of rawList) {\n const directId = String(item?.id || item?.name || '').trim()\n const explicitEndpointId = String(\n item?.endpoint_id ||\n item?.endpointId ||\n item?.endpoint?.id ||\n item?.meta?.endpoint_id ||\n item?.metadata?.endpoint_id ||\n item?.resource_id ||\n ''\n ).trim()\n const modelId = String(item?.model_id || item?.modelId || directId).trim()\n const discoveredEndpointId =\n collectStringValues(item)\n .map(v => String(v || '').trim())\n .find(v => /^ep-/i.test(v)) || ''\n const endpointId = explicitEndpointId || discoveredEndpointId\n\n if (provider === 'ark') {\n // 按用户要求:Ark 下只展示/使用 Model ID,不展示 Endpoint ID。\n if (modelId && !/^ep-/i.test(modelId)) {\n addOption(modelId, modelId)\n } else if (directId && !/^ep-/i.test(directId)) {\n addOption(directId, directId)\n } else if (endpointId && !/^ep-/i.test(endpointId)) {\n addOption(endpointId, endpointId)\n }\n } else {\n addOption(directId, directId)\n }\n }\n\n return options.sort((a, b) => a.label.localeCompare(b.label))\n}\n\nfunction buildApiKeyHash(apiKey: string): string {\n return createHash('sha256').update(String(apiKey || '').trim()).digest('hex').slice(0, 24)\n}\n\nfunction buildMemoryScope(database: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n return db\n}\n\nfunction buildLegacyMemoryScope(database: string, apiKey: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n const apiKeyHash = buildApiKeyHash(apiKey)\n return `${db}__${apiKeyHash}`\n}\n\nfunction getMemoryFilePath(database: string, _apiKey?: string): string {\n const scope = buildMemoryScope(database)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction getLegacyMemoryFilePath(database: string, apiKey: string): string {\n const scope = buildLegacyMemoryScope(database, apiKey)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction normalizeMemoryList(parsed: any): MemoryEntry[] {\n const list = Array.isArray(parsed) ? parsed : parsed?.memories\n if (!Array.isArray(list)) return []\n\n return list\n .filter(item => item && typeof item.content === 'string')\n .map(item => ({\n id: item.id || randomUUID(),\n content: String(item.content),\n createdAt: item.createdAt || new Date().toISOString(),\n updatedAt: item.updatedAt || item.createdAt || new Date().toISOString(),\n source: (item.source === 'tool' ? 'tool' : 'manual') as 'tool' | 'manual'\n }))\n}\n\nfunction migrateLegacyMemoriesToDatabaseScope(database: string, apiKey: string): void {\n const filePath = getMemoryFilePath(database)\n if (existsSync(filePath)) return\n\n const memoriesDir = getMemoriesDir()\n const scope = buildMemoryScope(database)\n const legacyPrefix = `${scope}__`\n const legacyFiles = readdirSync(memoriesDir)\n .filter(name => name.startsWith(legacyPrefix) && name.endsWith('.json'))\n .map(name => join(memoriesDir, name))\n\n const preferredLegacyFile = getLegacyMemoryFilePath(database, apiKey)\n if (existsSync(preferredLegacyFile) && !legacyFiles.includes(preferredLegacyFile)) {\n legacyFiles.unshift(preferredLegacyFile)\n }\n\n if (!legacyFiles.length) return\n\n const merged: MemoryEntry[] = []\n const idSet = new Set<string>()\n\n for (const legacyFile of legacyFiles) {\n try {\n const parsed = JSON.parse(readFileSync(legacyFile, 'utf-8'))\n const list = normalizeMemoryList(parsed)\n for (const item of list) {\n if (idSet.has(item.id)) continue\n idSet.add(item.id)\n merged.push(item)\n }\n } catch {\n // ignore broken legacy file\n }\n }\n\n if (merged.length > 0) {\n writeFileSync(filePath, JSON.stringify(merged, null, 2), 'utf-8')\n }\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<SqlExpertConfig>\n if (!parsed?.db || !parsed?.ai) return null\n return {\n db: parsed.db,\n ai: parsed.ai\n }\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction loadMemories(database: string, apiKey: string) {\n migrateLegacyMemoriesToDatabaseScope(database, apiKey)\n const filePath = getMemoryFilePath(database, apiKey)\n if (!existsSync(filePath)) {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database)\n }\n }\n\n try {\n const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))\n const memories = normalizeMemoryList(parsed)\n\n // 兼容手工编辑:统一写回标准结构,保证后续可直接增量追加。\n writeFileSync(filePath, JSON.stringify(memories, null, 2), 'utf-8')\n\n return {\n memories,\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database)\n }\n } catch {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database)\n }\n }\n}\n\nfunction appendMemory(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'tool'\n }\n\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\nfunction updateMemory(database: string, apiKey: string, memoryId: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const target = memories.find(m => m.id === memoryId)\n if (!target) throw new Error(`未找到 ID 为 ${memoryId} 的记忆`)\n target.content = content\n target.updatedAt = new Date().toISOString()\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry: target, memoryPath }\n}\n\nfunction deleteMemory(database: string, apiKey: string, memoryId: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const index = memories.findIndex(m => m.id === memoryId)\n if (index < 0) throw new Error(`未找到 ID 为 ${memoryId} 的记忆`)\n memories.splice(index, 1)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { memoryPath }\n}\n\nfunction addMemoryManual(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'manual'\n }\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n continue\n }\n if (char === '[') {\n quote = ']'\n continue\n }\n if (char === '(') {\n depth += 1\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n continue\n }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n current += char\n continue\n }\n if (char === '[') {\n quote = ']'\n current += char\n continue\n }\n if (char === '(') {\n depth += 1\n current += char\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n current += char\n continue\n }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => {\n /* ignore */\n })\n connectionPool = null\n }\n}\n\nfunction buildSystemPrompt(schema: string, memories: MemoryEntry[] = []): string {\n const memoryPrompt = memories.map(memory => memory.content).join('\\n\\n')\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 当用户明确要求导出或下载时,必须调用 export_data 工具。\n8. 当用户明确要求图表展示时,必须调用 render_chart 工具。\n9. 当你确认了可复用经验时,可以调用 save_memory 工具保存为本地记忆。\n10. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 历史记忆\n${memoryPrompt || '当前暂无已沉淀的可复用记忆。'}\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\`\n`\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description:\n '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: {\n type: 'string',\n description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。'\n },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: {\n type: 'array',\n items: { type: 'string' },\n description: '要查询结构的多个表名列表。'\n },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'render_chart',\n description: '根据真实查询结果绘制图表。支持 line、bar、pie、line_bar',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n type: {\n type: 'string',\n enum: ['line', 'bar', 'pie', 'line_bar']\n },\n title: { type: 'string' },\n xAxisData: { type: 'array', items: { type: 'string' } },\n series: { type: 'array' },\n reason: { type: 'string' }\n },\n required: ['type', 'title', 'series']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'save_memory',\n description: '保存可复用经验到本地记忆文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n content: { type: 'string' },\n reason: { type: 'string' }\n },\n required: ['content']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'export_data',\n description: '执行只读 SQL 查询并导出完整结果为 CSV 文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string' },\n reason: { type: 'string' },\n fileName: { type: 'string' }\n },\n required: ['sql']\n }\n }\n }\n ]\n}\n\nfunction stripToolMarkers(content: string): string {\n return String(content || '').replace(/:::tool:[^:]+:::/g, '').trim()\n}\n\nfunction buildToolResultSummary(toolCall: ToolCallRecord): string {\n if (toolCall.status === 'error' || toolCall.status === 'cancelled') {\n return JSON.stringify({ ok: false, error: toolCall.errorMessage || '工具调用失败' })\n }\n\n if (!toolCall.result) {\n return JSON.stringify({ ok: false, error: '未获取到结果' })\n }\n\n const summary: Record<string, unknown> = { ok: toolCall.result.ok }\n if (toolCall.result.reason) summary.reason = toolCall.result.reason\n if (typeof toolCall.result.totalRows === 'number') summary.totalRows = toolCall.result.totalRows\n if (typeof toolCall.result.returnedRows === 'number') summary.returnedRows = toolCall.result.returnedRows\n if (toolCall.result.truncated) summary.truncated = true\n if (toolCall.result.error) summary.error = toolCall.result.error\n if (toolCall.result.fileName) summary.fileName = toolCall.result.fileName\n if (toolCall.result.chartConfig) summary.chartRendered = true\n return JSON.stringify(summary)\n}\n\nfunction toModelMessages(uiMessages: AskAiMessage[]) {\n const modelMessages: Array<{\n role: string\n content: string | null\n tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>\n tool_call_id?: string\n }> = []\n\n for (const message of uiMessages) {\n if (message.status === 'loading' || message.status === 'error') continue\n\n const cleanContent = stripToolMarkers(message.content)\n\n if (message.role === 'user') {\n if (cleanContent) {\n modelMessages.push({ role: 'user', content: cleanContent })\n }\n continue\n }\n\n const toolCalls = (message.toolCalls || []).filter(tc => tc.id && tc.name)\n\n if (toolCalls.length) {\n modelMessages.push({\n role: 'assistant',\n content: cleanContent || null,\n tool_calls: toolCalls.map(tc => ({\n id: tc.id,\n type: 'function' as const,\n function: {\n name: tc.name,\n arguments: JSON.stringify(tc.args || {})\n }\n }))\n })\n\n for (const tc of toolCalls) {\n modelMessages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: buildToolResultSummary(tc)\n })\n }\n\n continue\n }\n\n if (cleanContent) {\n modelMessages.push({ role: 'assistant', content: cleanContent })\n }\n }\n\n return modelMessages\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n signal?: AbortSignal\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing =\n toolCalls[idx] ||\n ({\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall)\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[]; usage?: AgentUsage }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true,\n stream_options: {\n include_usage: true\n }\n }\n\n const stream = await openai.chat.completions.create(request, { signal: callbacks.signal })\n\n let content = ''\n const toolCalls: ToolCall[] = []\n let usage: AgentUsage | undefined\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n\n if ((chunk as any).usage) {\n const chunkUsage = (chunk as any).usage\n const promptTokens = Number(chunkUsage?.prompt_tokens || 0)\n const completionTokens = Number(chunkUsage?.completion_tokens || 0)\n const totalTokens = Number(chunkUsage?.total_tokens || promptTokens + completionTokens)\n const promptCacheHitTokens = Number(\n chunkUsage?.prompt_cache_hit_tokens || chunkUsage?.prompt_tokens_details?.cached_tokens || 0\n )\n const promptCacheMissTokens = Number(\n chunkUsage?.prompt_cache_miss_tokens || Math.max(promptTokens - promptCacheHitTokens, 0)\n )\n usage = {\n promptTokens,\n completionTokens,\n totalTokens,\n promptCacheHitTokens,\n promptCacheMissTokens\n }\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined,\n usage\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\nconst SQL_QUERY_TIMEOUT_MS = 60_000\n\nfunction buildChartOption(args: Record<string, any>): Record<string, unknown> {\n if (args.type === 'pie') {\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },\n legend: { bottom: 0, type: 'scroll' },\n series: (Array.isArray(args.series) ? args.series : []).map((s: any) => ({\n name: s.name,\n type: 'pie',\n radius: ['35%', '65%'],\n center: ['50%', '48%'],\n data: s.data,\n label: { formatter: '{b}\\n{d}%' }\n }))\n }\n }\n\n const series = Array.isArray(args.series) ? args.series : []\n const hasRightAxis = series.some((s: any) => s.yAxisIndex === 1)\n const yAxis = hasRightAxis\n ? [{ type: 'value' }, { type: 'value', splitLine: { show: false } }]\n : [{ type: 'value' }]\n\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },\n legend: { bottom: 0, type: 'scroll' },\n grid: { top: 48, bottom: 60, left: 50, right: hasRightAxis ? 50 : 20, containLabel: true },\n xAxis: { type: 'category', data: Array.isArray(args.xAxisData) ? args.xAxisData : [] },\n yAxis,\n series: series.map((s: any) => ({\n name: s.name,\n type: s.type,\n data: s.data,\n yAxisIndex: s.yAxisIndex || 0,\n smooth: s.type === 'line',\n barMaxWidth: 50\n }))\n }\n}\n\nfunction csvEscape(value: unknown) {\n const str = value == null ? '' : String(value)\n if (/[\",\\r\\n]/.test(str)) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`\n }\n return str\n}\n\nfunction rowsToCsv(rows: Array<Record<string, unknown>>) {\n const columnSet = new Set<string>()\n rows.forEach(row => {\n Object.keys(row || {}).forEach(key => columnSet.add(key))\n })\n const columns = Array.from(columnSet)\n const lines = [columns.map(csvEscape).join(',')]\n\n rows.forEach(row => {\n lines.push(columns.map(column => csvEscape(row?.[column])).join(','))\n })\n\n return lines.join('\\r\\n')\n}\n\nfunction buildExportFilePath(fileName?: string) {\n const exportsDir = join(getConfigDir(), 'exports')\n if (!existsSync(exportsDir)) {\n mkdirSync(exportsDir, { recursive: true })\n }\n\n const normalized = sanitizeFileName((fileName || `AI导出_${new Date().toISOString().slice(0, 10)}`).trim())\n const finalName = /\\.csv$/i.test(normalized) ? normalized : `${normalized}.csv`\n return {\n fileName: finalName,\n filePath: join(exportsDir, `${Date.now()}_${finalName}`)\n }\n}\n\nasync function queryWithTimeout(\n executor: mysql.Pool | mysql.Connection,\n sql: string,\n values?: Array<string | number | boolean | null>\n): Promise<[unknown, unknown]> {\n return executor.query({\n sql,\n timeout: SQL_QUERY_TIMEOUT_MS,\n values: values || []\n })\n}\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>,\n context: { database: string; apiKey: string }\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [\n Array<Record<string, unknown>>,\n unknown\n ]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName\n ? [args.tableName]\n : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n if (toolCall.function.name === 'render_chart') {\n const type = String(args.type || '')\n const validTypes = ['line', 'bar', 'pie', 'line_bar']\n if (!validTypes.includes(type)) {\n throw new Error(`不支持的图表类型: ${type}`)\n }\n if (!Array.isArray(args.series) || args.series.length === 0) {\n throw new Error('series 不能为空')\n }\n if (type !== 'pie' && (!Array.isArray(args.xAxisData) || args.xAxisData.length === 0)) {\n throw new Error('折线图、柱状图、组合图必须提供 xAxisData')\n }\n\n return {\n ok: true,\n reason: args.reason || '按用户要求绘制图表',\n chartConfig: buildChartOption(args)\n }\n }\n\n if (toolCall.function.name === 'save_memory') {\n const content = String(args.content || '').trim()\n if (!content) {\n throw new Error('记忆内容不能为空')\n }\n const memory = appendMemory(context.database, context.apiKey, content)\n return {\n ok: true,\n reason: args.reason || '沉淀可复用经验',\n memoryId: memory.entry.id,\n memoryPath: memory.memoryPath,\n rows: [{ id: memory.entry.id, content }]\n }\n }\n\n if (toolCall.function.name === 'export_data') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [Array<Record<string, unknown>>, unknown]\n const exportFile = buildExportFilePath(typeof args.fileName === 'string' ? args.fileName : undefined)\n writeFileSync(exportFile.filePath, rowsToCsv(rows), 'utf-8')\n\n return {\n ok: true,\n reason: args.reason || '按用户要求导出数据',\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n fileName: exportFile.fileName,\n filePath: exportFile.filePath\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 查询余额\n ipcMain.handle('sql-expert:check-balance', async (_event, config?: { url?: string; apiKey?: string }) => {\n try {\n const savedConfig = loadConfigFromDisk()\n const url = config?.url || savedConfig?.ai?.url || 'https://api.deepseek.com/v1'\n const apiKey = config?.apiKey || savedConfig?.ai?.apiKey || ''\n const provider = normalizeProvider(savedConfig?.ai?.provider, url)\n\n if (!apiKey) {\n return { success: false, message: 'API Key 不能为空' }\n }\n\n if (provider !== 'deepseek') {\n return { success: false, message: '当前余额查询仅支持 DeepSeek' }\n }\n\n const base = url.replace(/\\/v1\\/?$/, '')\n const apiUrl = `${base}/user/balance`\n\n const res = await fetch(apiUrl, {\n method: 'GET',\n headers: {\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${apiKey}`\n }\n })\n\n if (!res.ok) {\n const errorText = await res.text()\n let errMsg = `HTTP ${res.status}`\n try {\n const errorData = JSON.parse(errorText)\n errMsg = errorData.error?.message || errorData.message || errMsg\n } catch {\n // ignore\n }\n throw new Error(errMsg)\n }\n\n const data = await res.json()\n if (data.is_available !== undefined) {\n const isAvailable = data.is_available\n const infoList = data.balance_infos || []\n let balanceInfo = ''\n if (infoList.length > 0) {\n balanceInfo = infoList.map((info: any) => `${info.total_balance} ${info.currency}`).join(', ')\n }\n return { \n success: true, \n message: `余额: ${balanceInfo || '未知'} ${isAvailable ? '(可用)' : '(欠费/受限)'}`\n }\n } else {\n return { success: true, message: '请求成功,但不包含余额信息' }\n }\n } catch (error) {\n return { success: false, message: `查询失败: ${error instanceof Error ? error.message : String(error)}` }\n }\n })\n\n // 获取模型列表(OpenAI 兼容 /models)\n ipcMain.handle(\n 'sql-expert:get-models',\n async (_event, config?: { provider?: string; url?: string; apiKey?: string }) => {\n try {\n const savedConfig = loadConfigFromDisk()\n const provider = normalizeProvider(config?.provider || savedConfig?.ai?.provider, config?.url || savedConfig?.ai?.url)\n const url = config?.url || savedConfig?.ai?.url || 'https://api.deepseek.com/v1'\n const apiKey = config?.apiKey || savedConfig?.ai?.apiKey || ''\n\n if (!apiKey) {\n return { success: false, message: 'API Key 不能为空', models: [] as string[], modelOptions: [] as ModelOptionItem[] }\n }\n\n if (provider === 'anthropic') {\n return {\n success: false,\n message: 'Anthropic 暂不支持通过当前接口自动获取模型列表,请手动填写模型名',\n models: [] as string[],\n modelOptions: [] as ModelOptionItem[]\n }\n }\n\n const base = url.replace(/\\/v1\\/?$/, '')\n const apiUrl = `${base}/models`\n\n const res = await fetch(apiUrl, {\n method: 'GET',\n headers: {\n Accept: 'application/json',\n Authorization: `Bearer ${apiKey}`\n }\n })\n\n if (!res.ok) {\n const errorText = await res.text()\n let errMsg = `HTTP ${res.status}`\n try {\n const errorData = JSON.parse(errorText)\n errMsg = errorData.error?.message || errorData.message || errMsg\n } catch {\n // ignore\n }\n throw new Error(errMsg)\n }\n\n const data = await res.json()\n const rawList = Array.isArray(data?.data) ? data.data : []\n const models = rawList\n .map((item: any) => String(item?.id || item?.name || '').trim())\n .filter(Boolean)\n .sort((a: string, b: string) => a.localeCompare(b))\n const modelOptions = buildModelOptionItems(rawList, provider)\n\n if (provider === 'ark') {\n return {\n success: true,\n message: modelOptions.length\n ? `已获取 ${modelOptions.length} 个 Model ID`\n : '未查询到可用模型',\n models: modelOptions.map(option => option.value),\n modelOptions\n }\n }\n\n return {\n success: true,\n message: models.length ? `已获取 ${models.length} 个模型` : '未查询到可用模型',\n models,\n modelOptions\n }\n } catch (error) {\n return {\n success: false,\n message: `获取模型失败: ${error instanceof Error ? error.message : String(error)}`,\n models: [] as string[],\n modelOptions: [] as ModelOptionItem[]\n }\n }\n }\n )\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const memoryState =\n config?.db?.database && config?.ai?.apiKey\n ? loadMemories(config.db.database, config.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n return {\n config,\n schema,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n })\n\n ipcMain.handle('sql-expert:load-memories', async (_event, payload?: { database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload?.database || saved?.db?.database || ''\n const apiKey = payload?.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) {\n throw new Error('缺少数据库名或 API Key,无法加载记忆')\n }\n\n const memoryState = loadMemories(database, apiKey)\n return {\n success: true,\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : '加载记忆失败',\n memories: [],\n memoryPath: '',\n memoryScope: '',\n memoryCount: 0\n }\n }\n })\n\n // 更新单条记忆\n ipcMain.handle('sql-expert:update-memory', async (_event, payload: { memoryId: string; content: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.memoryId || !payload.content?.trim()) throw new Error('记忆 ID 和内容不能为空')\n\n updateMemory(database, apiKey, payload.memoryId, payload.content.trim())\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '更新记忆失败', memories: [] }\n }\n })\n\n // 删除单条记忆\n ipcMain.handle('sql-expert:delete-memory', async (_event, payload: { memoryId: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.memoryId) throw new Error('记忆 ID 不能为空')\n\n deleteMemory(database, apiKey, payload.memoryId)\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '删除记忆失败', memories: [] }\n }\n })\n\n // 手动新增记忆\n ipcMain.handle('sql-expert:add-memory', async (_event, payload: { content: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.content?.trim()) throw new Error('内容不能为空')\n\n addMemoryManual(database, apiKey, payload.content.trim())\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '新增记忆失败', memories: [] }\n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = (await queryWithTimeout(\n conn,\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n )) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map((row) => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n const savedConfig = loadConfigFromDisk()\n const memoryState =\n config.database && savedConfig?.ai?.apiKey\n ? loadMemories(config.database, savedConfig.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n\n return {\n success: true,\n schema: schemaText,\n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map((n) => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map((n) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return {\n success: false,\n ok: false,\n error: error instanceof Error ? error.message : '查询失败'\n }\n }\n })\n\n ipcMain.handle('sql-expert:cancel-ask-ai', async (_event, payload: { requestId: string }) => {\n const requestId = String(payload?.requestId || '')\n const controller = activeAiRequests.get(requestId)\n if (!controller) {\n return { success: false, message: '当前没有可取消的请求' }\n }\n\n controller.abort()\n activeAiRequests.delete(requestId)\n return { success: true, message: '已停止生成' }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n const requestId = String(payload?.requestId || randomUUID())\n const abortController = new AbortController()\n activeAiRequests.set(requestId, abortController)\n\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const memoryState = loadMemories(savedConfig.db.database, savedConfig.ai.apiKey)\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = toModelMessages(payload.messages || [])\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, memoryState.memories) }\n const allMessages: Array<{\n role: string\n content: string | null\n tool_calls?: ToolCall[]\n tool_call_id?: string\n }> = [systemMessage, ...messages]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n let usage: AgentUsage = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n }\n const toolCallResults: Array<{\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: finalReply,\n toolCalls: toolCallResults,\n usage,\n status: 'stopped'\n }\n }\n\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n signal: abortController.signal,\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: mergeReply(finalReply, accContent)\n })\n }\n }\n })\n if (aiResponse.usage) {\n usage = aiResponse.usage\n }\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return {\n success: true,\n requestId,\n reply: finalReply || '未生成有效回复',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: finalReply\n })\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try {\n parsedArgs = JSON.parse(toolCall.function.arguments || '{}')\n } catch {\n /* ignore */\n }\n\n const callRecord: {\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs\n })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames, {\n database: savedConfig.db.database,\n apiKey: savedConfig.ai.apiKey\n })\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n requestId,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n } catch (error) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: '',\n toolCalls: [],\n usage: {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n },\n status: 'stopped'\n }\n }\n\n const rawMessage = error instanceof Error ? error.message : 'AI 请求失败'\n const savedConfig = loadConfigFromDisk()\n const provider = normalizeProvider(savedConfig?.ai?.provider, savedConfig?.ai?.url)\n const model = String(savedConfig?.ai?.model || '')\n return { success: false, requestId, error: buildAiRequestErrorMessage(rawMessage, provider, model) }\n } finally {\n activeAiRequests.delete(requestId)\n }\n })\n}\n"}, "b9219df7a58def3edc814523ba74b786b5fdebf9": {"t": 1785759182.0468066, "n": 53967, "h": "3c956195e473cffd", "c": "/**\n * 企业级分析专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'\nimport { createHash, randomUUID } from 'crypto'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n provider?: string\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface ToolCallRecord {\n id: string\n name: string\n args: Record<string, unknown>\n result?: Record<string, unknown>\n status?: string\n errorMessage?: string\n}\n\ninterface AskAiMessage {\n role: 'user' | 'assistant'\n content: string\n status?: 'done' | 'loading' | 'error' | 'stopped'\n toolCalls?: ToolCallRecord[]\n}\n\ninterface AskAiPayload {\n requestId?: string\n messages: AskAiMessage[]\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\ninterface MemoryEntry {\n id: string\n content: string\n createdAt: string\n updatedAt: string\n source: 'tool' | 'manual'\n}\n\ninterface AgentUsage {\n promptTokens: number\n completionTokens: number\n totalTokens: number\n promptCacheHitTokens: number\n promptCacheMissTokens: number\n}\n\ntype AiProvider = 'deepseek' | 'openai' | 'ark' | 'anthropic'\n\ninterface ModelOptionItem {\n label: string\n value: string\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\nconst activeAiRequests = new Map<string, AbortController>()\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction getMemoriesDir(): string {\n const dir = join(getConfigDir(), 'memories')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction sanitizeFileName(value: string) {\n return value.replace(/[\\\\/:*?\"<>|]+/g, '_').trim()\n}\n\nfunction inferProviderFromUrl(url: string): AiProvider {\n const normalized = String(url || '').toLowerCase()\n if (normalized.includes('api.openai.com')) return 'openai'\n if (normalized.includes('ark.cn-beijing.volces.com') || normalized.includes('volces.com/api/v3')) return 'ark'\n if (normalized.includes('api.anthropic.com')) return 'anthropic'\n return 'deepseek'\n}\n\nfunction normalizeProvider(rawProvider?: string, url?: string): AiProvider {\n const normalized = String(rawProvider || '').trim().toLowerCase()\n if (normalized === 'deepseek' || normalized === 'openai' || normalized === 'ark' || normalized === 'anthropic') {\n return normalized as AiProvider\n }\n return inferProviderFromUrl(String(url || ''))\n}\n\nfunction buildAiRequestErrorMessage(rawMessage: string, provider: AiProvider, model: string): string {\n const message = String(rawMessage || '').trim() || 'AI 请求失败'\n if (provider !== 'ark') return message\n\n const lower = message.toLowerCase()\n const isEndpointOrModelNotFound =\n lower.includes('invalidendpointormodel') ||\n lower.includes('model or endpoint') ||\n lower.includes('does not exist or you do not have access')\n\n if (!isEndpointOrModelNotFound) return message\n\n const hint =\n '当前配置使用 Model ID。该报错通常表示当前账号/项目对该 Model ID 无权限,或该模型未开通。'\n return `${message}\\n${hint}\\n当前 Model: ${model || '(空)'}`\n}\n\nfunction buildModelOptionItems(rawList: any[], provider: AiProvider): ModelOptionItem[] {\n const options: ModelOptionItem[] = []\n const usedValues = new Set<string>()\n const addOption = (label: string, value: string) => {\n const finalLabel = String(label || '').trim()\n const finalValue = String(value || '').trim()\n if (!finalLabel || !finalValue || usedValues.has(finalValue)) return\n usedValues.add(finalValue)\n options.push({ label: finalLabel, value: finalValue })\n }\n\n const collectStringValues = (input: unknown, maxDepth = 4, depth = 0): string[] => {\n if (depth > maxDepth || input == null) return []\n if (typeof input === 'string') return [input]\n if (Array.isArray(input)) return input.flatMap(item => collectStringValues(item, maxDepth, depth + 1))\n if (typeof input === 'object') {\n return Object.values(input as Record<string, unknown>).flatMap(value =>\n collectStringValues(value, maxDepth, depth + 1)\n )\n }\n return []\n }\n\n for (const item of rawList) {\n const directId = String(item?.id || item?.name || '').trim()\n const explicitEndpointId = String(\n item?.endpoint_id ||\n item?.endpointId ||\n item?.endpoint?.id ||\n item?.meta?.endpoint_id ||\n item?.metadata?.endpoint_id ||\n item?.resource_id ||\n ''\n ).trim()\n const modelId = String(item?.model_id || item?.modelId || directId).trim()\n const discoveredEndpointId =\n collectStringValues(item)\n .map(v => String(v || '').trim())\n .find(v => /^ep-/i.test(v)) || ''\n const endpointId = explicitEndpointId || discoveredEndpointId\n\n if (provider === 'ark') {\n // 按用户要求:Ark 下只展示/使用 Model ID,不展示 Endpoint ID。\n if (modelId && !/^ep-/i.test(modelId)) {\n addOption(modelId, modelId)\n } else if (directId && !/^ep-/i.test(directId)) {\n addOption(directId, directId)\n } else if (endpointId && !/^ep-/i.test(endpointId)) {\n addOption(endpointId, endpointId)\n }\n } else {\n addOption(directId, directId)\n }\n }\n\n return options.sort((a, b) => a.label.localeCompare(b.label))\n}\n\nfunction buildApiKeyHash(apiKey: string): string {\n return createHash('sha256').update(String(apiKey || '').trim()).digest('hex').slice(0, 24)\n}\n\nfunction buildMemoryScope(database: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n return db\n}\n\nfunction buildLegacyMemoryScope(database: string, apiKey: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n const apiKeyHash = buildApiKeyHash(apiKey)\n return `${db}__${apiKeyHash}`\n}\n\nfunction getMemoryFilePath(database: string, _apiKey?: string): string {\n const scope = buildMemoryScope(database)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction getLegacyMemoryFilePath(database: string, apiKey: string): string {\n const scope = buildLegacyMemoryScope(database, apiKey)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction normalizeMemoryList(parsed: any): MemoryEntry[] {\n const list = Array.isArray(parsed) ? parsed : parsed?.memories\n if (!Array.isArray(list)) return []\n\n return list\n .filter(item => item && typeof item.content === 'string')\n .map(item => ({\n id: item.id || randomUUID(),\n content: String(item.content),\n createdAt: item.createdAt || new Date().toISOString(),\n updatedAt: item.updatedAt || item.createdAt || new Date().toISOString(),\n source: (item.source === 'tool' ? 'tool' : 'manual') as 'tool' | 'manual'\n }))\n}\n\nfunction migrateLegacyMemoriesToDatabaseScope(database: string, apiKey: string): void {\n const filePath = getMemoryFilePath(database)\n if (existsSync(filePath)) return\n\n const memoriesDir = getMemoriesDir()\n const scope = buildMemoryScope(database)\n const legacyPrefix = `${scope}__`\n const legacyFiles = readdirSync(memoriesDir)\n .filter(name => name.startsWith(legacyPrefix) && name.endsWith('.json'))\n .map(name => join(memoriesDir, name))\n\n const preferredLegacyFile = getLegacyMemoryFilePath(database, apiKey)\n if (existsSync(preferredLegacyFile) && !legacyFiles.includes(preferredLegacyFile)) {\n legacyFiles.unshift(preferredLegacyFile)\n }\n\n if (!legacyFiles.length) return\n\n const merged: MemoryEntry[] = []\n const idSet = new Set<string>()\n\n for (const legacyFile of legacyFiles) {\n try {\n const parsed = JSON.parse(readFileSync(legacyFile, 'utf-8'))\n const list = normalizeMemoryList(parsed)\n for (const item of list) {\n if (idSet.has(item.id)) continue\n idSet.add(item.id)\n merged.push(item)\n }\n } catch {\n // ignore broken legacy file\n }\n }\n\n if (merged.length > 0) {\n writeFileSync(filePath, JSON.stringify(merged, null, 2), 'utf-8')\n }\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<SqlExpertConfig>\n if (!parsed?.db || !parsed?.ai) return null\n return {\n db: parsed.db,\n ai: parsed.ai\n }\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction loadMemories(database: string, apiKey: string) {\n migrateLegacyMemoriesToDatabaseScope(database, apiKey)\n const filePath = getMemoryFilePath(database, apiKey)\n if (!existsSync(filePath)) {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database)\n }\n }\n\n try {\n const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))\n const memories = normalizeMemoryList(parsed)\n\n // 兼容手工编辑:统一写回标准结构,保证后续可直接增量追加。\n writeFileSync(filePath, JSON.stringify(memories, null, 2), 'utf-8')\n\n return {\n memories,\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database)\n }\n } catch {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database)\n }\n }\n}\n\nfunction appendMemory(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'tool'\n }\n\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\nfunction updateMemory(database: string, apiKey: string, memoryId: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const target = memories.find(m => m.id === memoryId)\n if (!target) throw new Error(`未找到 ID 为 ${memoryId} 的记忆`)\n target.content = content\n target.updatedAt = new Date().toISOString()\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry: target, memoryPath }\n}\n\nfunction deleteMemory(database: string, apiKey: string, memoryId: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const index = memories.findIndex(m => m.id === memoryId)\n if (index < 0) throw new Error(`未找到 ID 为 ${memoryId} 的记忆`)\n memories.splice(index, 1)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { memoryPath }\n}\n\nfunction addMemoryManual(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'manual'\n }\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n continue\n }\n if (char === '[') {\n quote = ']'\n continue\n }\n if (char === '(') {\n depth += 1\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n continue\n }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n current += char\n continue\n }\n if (char === '[') {\n quote = ']'\n current += char\n continue\n }\n if (char === '(') {\n depth += 1\n current += char\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n current += char\n continue\n }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => {\n /* ignore */\n })\n connectionPool = null\n }\n}\n\nfunction buildSystemPrompt(schema: string, memories: MemoryEntry[] = []): string {\n const memoryPrompt = memories.map(memory => memory.content).join('\\n\\n')\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 当用户明确要求导出或下载时,必须调用 export_data 工具。\n8. 当用户明确要求图表展示时,必须调用 render_chart 工具。\n9. 当你确认了可复用经验时,可以调用 save_memory 工具保存为本地记忆。\n10. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 历史记忆\n${memoryPrompt || '当前暂无已沉淀的可复用记忆。'}\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\`\n`\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description:\n '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: {\n type: 'string',\n description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。'\n },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: {\n type: 'array',\n items: { type: 'string' },\n description: '要查询结构的多个表名列表。'\n },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'render_chart',\n description: '根据真实查询结果绘制图表。支持 line、bar、pie、line_bar',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n type: {\n type: 'string',\n enum: ['line', 'bar', 'pie', 'line_bar']\n },\n title: { type: 'string' },\n xAxisData: { type: 'array', items: { type: 'string' } },\n series: { type: 'array' },\n reason: { type: 'string' }\n },\n required: ['type', 'title', 'series']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'save_memory',\n description: '保存可复用经验到本地记忆文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n content: { type: 'string' },\n reason: { type: 'string' }\n },\n required: ['content']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'export_data',\n description: '执行只读 SQL 查询并导出完整结果为 CSV 文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string' },\n reason: { type: 'string' },\n fileName: { type: 'string' }\n },\n required: ['sql']\n }\n }\n }\n ]\n}\n\nfunction stripToolMarkers(content: string): string {\n return String(content || '').replace(/:::tool:[^:]+:::/g, '').trim()\n}\n\nfunction buildToolResultSummary(toolCall: ToolCallRecord): string {\n if (toolCall.status === 'error' || toolCall.status === 'cancelled') {\n return JSON.stringify({ ok: false, error: toolCall.errorMessage || '工具调用失败' })\n }\n\n if (!toolCall.result) {\n return JSON.stringify({ ok: false, error: '未获取到结果' })\n }\n\n const summary: Record<string, unknown> = { ok: toolCall.result.ok }\n if (toolCall.result.reason) summary.reason = toolCall.result.reason\n if (typeof toolCall.result.totalRows === 'number') summary.totalRows = toolCall.result.totalRows\n if (typeof toolCall.result.returnedRows === 'number') summary.returnedRows = toolCall.result.returnedRows\n if (toolCall.result.truncated) summary.truncated = true\n if (toolCall.result.error) summary.error = toolCall.result.error\n if (toolCall.result.fileName) summary.fileName = toolCall.result.fileName\n if (toolCall.result.chartConfig) summary.chartRendered = true\n return JSON.stringify(summary)\n}\n\nfunction toModelMessages(uiMessages: AskAiMessage[]) {\n const modelMessages: Array<{\n role: string\n content: string | null\n tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>\n tool_call_id?: string\n }> = []\n\n for (const message of uiMessages) {\n if (message.status === 'loading' || message.status === 'error') continue\n\n const cleanContent = stripToolMarkers(message.content)\n\n if (message.role === 'user') {\n if (cleanContent) {\n modelMessages.push({ role: 'user', content: cleanContent })\n }\n continue\n }\n\n const toolCalls = (message.toolCalls || []).filter(tc => tc.id && tc.name)\n\n if (toolCalls.length) {\n modelMessages.push({\n role: 'assistant',\n content: cleanContent || null,\n tool_calls: toolCalls.map(tc => ({\n id: tc.id,\n type: 'function' as const,\n function: {\n name: tc.name,\n arguments: JSON.stringify(tc.args || {})\n }\n }))\n })\n\n for (const tc of toolCalls) {\n modelMessages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: buildToolResultSummary(tc)\n })\n }\n\n continue\n }\n\n if (cleanContent) {\n modelMessages.push({ role: 'assistant', content: cleanContent })\n }\n }\n\n return modelMessages\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n signal?: AbortSignal\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing =\n toolCalls[idx] ||\n ({\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall)\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[]; usage?: AgentUsage }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true,\n stream_options: {\n include_usage: true\n }\n }\n\n const stream = await openai.chat.completions.create(request, { signal: callbacks.signal })\n\n let content = ''\n const toolCalls: ToolCall[] = []\n let usage: AgentUsage | undefined\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n\n if ((chunk as any).usage) {\n const chunkUsage = (chunk as any).usage\n const promptTokens = Number(chunkUsage?.prompt_tokens || 0)\n const completionTokens = Number(chunkUsage?.completion_tokens || 0)\n const totalTokens = Number(chunkUsage?.total_tokens || promptTokens + completionTokens)\n const promptCacheHitTokens = Number(\n chunkUsage?.prompt_cache_hit_tokens || chunkUsage?.prompt_tokens_details?.cached_tokens || 0\n )\n const promptCacheMissTokens = Number(\n chunkUsage?.prompt_cache_miss_tokens || Math.max(promptTokens - promptCacheHitTokens, 0)\n )\n usage = {\n promptTokens,\n completionTokens,\n totalTokens,\n promptCacheHitTokens,\n promptCacheMissTokens\n }\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined,\n usage\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\nconst SQL_QUERY_TIMEOUT_MS = 60_000\n\nfunction buildChartOption(args: Record<string, any>): Record<string, unknown> {\n if (args.type === 'pie') {\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },\n legend: { bottom: 0, type: 'scroll' },\n series: (Array.isArray(args.series) ? args.series : []).map((s: any) => ({\n name: s.name,\n type: 'pie',\n radius: ['35%', '65%'],\n center: ['50%', '48%'],\n data: s.data,\n label: { formatter: '{b}\\n{d}%' }\n }))\n }\n }\n\n const series = Array.isArray(args.series) ? args.series : []\n const hasRightAxis = series.some((s: any) => s.yAxisIndex === 1)\n const yAxis = hasRightAxis\n ? [{ type: 'value' }, { type: 'value', splitLine: { show: false } }]\n : [{ type: 'value' }]\n\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },\n legend: { bottom: 0, type: 'scroll' },\n grid: { top: 48, bottom: 60, left: 50, right: hasRightAxis ? 50 : 20, containLabel: true },\n xAxis: { type: 'category', data: Array.isArray(args.xAxisData) ? args.xAxisData : [] },\n yAxis,\n series: series.map((s: any) => ({\n name: s.name,\n type: s.type,\n data: s.data,\n yAxisIndex: s.yAxisIndex || 0,\n smooth: s.type === 'line',\n barMaxWidth: 50\n }))\n }\n}\n\nfunction csvEscape(value: unknown) {\n const str = value == null ? '' : String(value)\n if (/[\",\\r\\n]/.test(str)) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`\n }\n return str\n}\n\nfunction rowsToCsv(rows: Array<Record<string, unknown>>) {\n const columnSet = new Set<string>()\n rows.forEach(row => {\n Object.keys(row || {}).forEach(key => columnSet.add(key))\n })\n const columns = Array.from(columnSet)\n const lines = [columns.map(csvEscape).join(',')]\n\n rows.forEach(row => {\n lines.push(columns.map(column => csvEscape(row?.[column])).join(','))\n })\n\n return lines.join('\\r\\n')\n}\n\nfunction buildExportFilePath(fileName?: string) {\n const exportsDir = join(getConfigDir(), 'exports')\n if (!existsSync(exportsDir)) {\n mkdirSync(exportsDir, { recursive: true })\n }\n\n const normalized = sanitizeFileName((fileName || `AI导出_${new Date().toISOString().slice(0, 10)}`).trim())\n const finalName = /\\.csv$/i.test(normalized) ? normalized : `${normalized}.csv`\n return {\n fileName: finalName,\n filePath: join(exportsDir, `${Date.now()}_${finalName}`)\n }\n}\n\nasync function queryWithTimeout(\n executor: mysql.Pool | mysql.Connection,\n sql: string,\n values?: Array<string | number | boolean | null>\n): Promise<[unknown, unknown]> {\n return executor.query({\n sql,\n timeout: SQL_QUERY_TIMEOUT_MS,\n values: values || []\n })\n}\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>,\n context: { database: string; apiKey: string }\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [\n Array<Record<string, unknown>>,\n unknown\n ]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName\n ? [args.tableName]\n : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n if (toolCall.function.name === 'render_chart') {\n const type = String(args.type || '')\n const validTypes = ['line', 'bar', 'pie', 'line_bar']\n if (!validTypes.includes(type)) {\n throw new Error(`不支持的图表类型: ${type}`)\n }\n if (!Array.isArray(args.series) || args.series.length === 0) {\n throw new Error('series 不能为空')\n }\n if (type !== 'pie' && (!Array.isArray(args.xAxisData) || args.xAxisData.length === 0)) {\n throw new Error('折线图、柱状图、组合图必须提供 xAxisData')\n }\n\n return {\n ok: true,\n reason: args.reason || '按用户要求绘制图表',\n chartConfig: buildChartOption(args)\n }\n }\n\n if (toolCall.function.name === 'save_memory') {\n const content = String(args.content || '').trim()\n if (!content) {\n throw new Error('记忆内容不能为空')\n }\n const memory = appendMemory(context.database, context.apiKey, content)\n return {\n ok: true,\n reason: args.reason || '沉淀可复用经验',\n memoryId: memory.entry.id,\n memoryPath: memory.memoryPath,\n rows: [{ id: memory.entry.id, content }]\n }\n }\n\n if (toolCall.function.name === 'export_data') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [Array<Record<string, unknown>>, unknown]\n const exportFile = buildExportFilePath(typeof args.fileName === 'string' ? args.fileName : undefined)\n writeFileSync(exportFile.filePath, rowsToCsv(rows), 'utf-8')\n\n return {\n ok: true,\n reason: args.reason || '按用户要求导出数据',\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n fileName: exportFile.fileName,\n filePath: exportFile.filePath\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 查询余额\n ipcMain.handle('sql-expert:check-balance', async (_event, config?: { url?: string; apiKey?: string }) => {\n try {\n const savedConfig = loadConfigFromDisk()\n const url = config?.url || savedConfig?.ai?.url || 'https://api.deepseek.com/v1'\n const apiKey = config?.apiKey || savedConfig?.ai?.apiKey || ''\n const provider = normalizeProvider(savedConfig?.ai?.provider, url)\n\n if (!apiKey) {\n return { success: false, message: 'API Key 不能为空' }\n }\n\n if (provider !== 'deepseek') {\n return { success: false, message: '当前余额查询仅支持 DeepSeek' }\n }\n\n const base = url.replace(/\\/v1\\/?$/, '')\n const apiUrl = `${base}/user/balance`\n\n const res = await fetch(apiUrl, {\n method: 'GET',\n headers: {\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${apiKey}`\n }\n })\n\n if (!res.ok) {\n const errorText = await res.text()\n let errMsg = `HTTP ${res.status}`\n try {\n const errorData = JSON.parse(errorText)\n errMsg = errorData.error?.message || errorData.message || errMsg\n } catch {\n // ignore\n }\n throw new Error(errMsg)\n }\n\n const data = await res.json()\n if (data.is_available !== undefined) {\n const isAvailable = data.is_available\n const infoList = data.balance_infos || []\n let balanceInfo = ''\n if (infoList.length > 0) {\n balanceInfo = infoList.map((info: any) => `${info.total_balance} ${info.currency}`).join(', ')\n }\n return { \n success: true, \n message: `余额: ${balanceInfo || '未知'} ${isAvailable ? '(可用)' : '(欠费/受限)'}`\n }\n } else {\n return { success: true, message: '请求成功,但不包含余额信息' }\n }\n } catch (error) {\n return { success: false, message: `查询失败: ${error instanceof Error ? error.message : String(error)}` }\n }\n })\n\n // 获取模型列表(OpenAI 兼容 /models)\n ipcMain.handle(\n 'sql-expert:get-models',\n async (_event, config?: { provider?: string; url?: string; apiKey?: string }) => {\n try {\n const savedConfig = loadConfigFromDisk()\n const provider = normalizeProvider(config?.provider || savedConfig?.ai?.provider, config?.url || savedConfig?.ai?.url)\n const url = config?.url || savedConfig?.ai?.url || 'https://api.deepseek.com/v1'\n const apiKey = config?.apiKey || savedConfig?.ai?.apiKey || ''\n\n if (!apiKey) {\n return { success: false, message: 'API Key 不能为空', models: [] as string[], modelOptions: [] as ModelOptionItem[] }\n }\n\n if (provider === 'anthropic') {\n return {\n success: false,\n message: 'Anthropic 暂不支持通过当前接口自动获取模型列表,请手动填写模型名',\n models: [] as string[],\n modelOptions: [] as ModelOptionItem[]\n }\n }\n\n const base = url.replace(/\\/v1\\/?$/, '')\n const apiUrl = `${base}/models`\n\n const res = await fetch(apiUrl, {\n method: 'GET',\n headers: {\n Accept: 'application/json',\n Authorization: `Bearer ${apiKey}`\n }\n })\n\n if (!res.ok) {\n const errorText = await res.text()\n let errMsg = `HTTP ${res.status}`\n try {\n const errorData = JSON.parse(errorText)\n errMsg = errorData.error?.message || errorData.message || errMsg\n } catch {\n // ignore\n }\n throw new Error(errMsg)\n }\n\n const data = await res.json()\n const rawList = Array.isArray(data?.data) ? data.data : []\n const models = rawList\n .map((item: any) => String(item?.id || item?.name || '').trim())\n .filter(Boolean)\n .sort((a: string, b: string) => a.localeCompare(b))\n const modelOptions = buildModelOptionItems(rawList, provider)\n\n if (provider === 'ark') {\n return {\n success: true,\n message: modelOptions.length\n ? `已获取 ${modelOptions.length} 个 Model ID`\n : '未查询到可用模型',\n models: modelOptions.map(option => option.value),\n modelOptions\n }\n }\n\n return {\n success: true,\n message: models.length ? `已获取 ${models.length} 个模型` : '未查询到可用模型',\n models,\n modelOptions\n }\n } catch (error) {\n return {\n success: false,\n message: `获取模型失败: ${error instanceof Error ? error.message : String(error)}`,\n models: [] as string[],\n modelOptions: [] as ModelOptionItem[]\n }\n }\n }\n )\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const memoryState =\n config?.db?.database && config?.ai?.apiKey\n ? loadMemories(config.db.database, config.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n return {\n config,\n schema,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n })\n\n ipcMain.handle('sql-expert:load-memories', async (_event, payload?: { database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload?.database || saved?.db?.database || ''\n const apiKey = payload?.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) {\n throw new Error('缺少数据库名或 API Key,无法加载记忆')\n }\n\n const memoryState = loadMemories(database, apiKey)\n return {\n success: true,\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : '加载记忆失败',\n memories: [],\n memoryPath: '',\n memoryScope: '',\n memoryCount: 0\n }\n }\n })\n\n // 更新单条记忆\n ipcMain.handle('sql-expert:update-memory', async (_event, payload: { memoryId: string; content: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.memoryId || !payload.content?.trim()) throw new Error('记忆 ID 和内容不能为空')\n\n updateMemory(database, apiKey, payload.memoryId, payload.content.trim())\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '更新记忆失败', memories: [] }\n }\n })\n\n // 删除单条记忆\n ipcMain.handle('sql-expert:delete-memory', async (_event, payload: { memoryId: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.memoryId) throw new Error('记忆 ID 不能为空')\n\n deleteMemory(database, apiKey, payload.memoryId)\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '删除记忆失败', memories: [] }\n }\n })\n\n // 手动新增记忆\n ipcMain.handle('sql-expert:add-memory', async (_event, payload: { content: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.content?.trim()) throw new Error('内容不能为空')\n\n addMemoryManual(database, apiKey, payload.content.trim())\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '新增记忆失败', memories: [] }\n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = (await queryWithTimeout(\n conn,\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n )) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map((row) => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n const savedConfig = loadConfigFromDisk()\n const memoryState =\n config.database && savedConfig?.ai?.apiKey\n ? loadMemories(config.database, savedConfig.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n\n return {\n success: true,\n schema: schemaText,\n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map((n) => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map((n) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return {\n success: false,\n ok: false,\n error: error instanceof Error ? error.message : '查询失败'\n }\n }\n })\n\n ipcMain.handle('sql-expert:cancel-ask-ai', async (_event, payload: { requestId: string }) => {\n const requestId = String(payload?.requestId || '')\n const controller = activeAiRequests.get(requestId)\n if (!controller) {\n return { success: false, message: '当前没有可取消的请求' }\n }\n\n controller.abort()\n activeAiRequests.delete(requestId)\n return { success: true, message: '已停止生成' }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n const requestId = String(payload?.requestId || randomUUID())\n const abortController = new AbortController()\n activeAiRequests.set(requestId, abortController)\n\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const memoryState = loadMemories(savedConfig.db.database, savedConfig.ai.apiKey)\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = toModelMessages(payload.messages || [])\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, memoryState.memories) }\n const allMessages: Array<{\n role: string\n content: string | null\n tool_calls?: ToolCall[]\n tool_call_id?: string\n }> = [systemMessage, ...messages]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n let usage: AgentUsage = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n }\n const toolCallResults: Array<{\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: finalReply,\n toolCalls: toolCallResults,\n usage,\n status: 'stopped'\n }\n }\n\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n signal: abortController.signal,\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: mergeReply(finalReply, accContent)\n })\n }\n }\n })\n if (aiResponse.usage) {\n usage = aiResponse.usage\n }\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return {\n success: true,\n requestId,\n reply: finalReply || '未生成有效回复',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: finalReply\n })\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try {\n parsedArgs = JSON.parse(toolCall.function.arguments || '{}')\n } catch {\n /* ignore */\n }\n\n const callRecord: {\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs\n })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames, {\n database: savedConfig.db.database,\n apiKey: savedConfig.ai.apiKey\n })\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n requestId,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n } catch (error) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: '',\n toolCalls: [],\n usage: {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n },\n status: 'stopped'\n }\n }\n\n const rawMessage = error instanceof Error ? error.message : 'AI 请求失败'\n const savedConfig = loadConfigFromDisk()\n const provider = normalizeProvider(savedConfig?.ai?.provider, savedConfig?.ai?.url)\n const model = String(savedConfig?.ai?.model || '')\n return { success: false, requestId, error: buildAiRequestErrorMessage(rawMessage, provider, model) }\n } finally {\n activeAiRequests.delete(requestId)\n }\n })\n}\n"}, "f01c7fe1f76ed0d9290d5f72ab34c88e91b5205a": {"t": 1785759183.8799353, "n": 45514, "h": "2fa090f4ebc2956c", "c": "/**\n * 企业级分析专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'\nimport { createHash, randomUUID } from 'crypto'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface ToolCallRecord {\n id: string\n name: string\n args: Record<string, unknown>\n result?: Record<string, unknown>\n status?: string\n errorMessage?: string\n}\n\ninterface AskAiMessage {\n role: 'user' | 'assistant'\n content: string\n status?: 'done' | 'loading' | 'error' | 'stopped'\n toolCalls?: ToolCallRecord[]\n}\n\ninterface AskAiPayload {\n requestId?: string\n messages: AskAiMessage[]\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\ninterface MemoryEntry {\n id: string\n content: string\n createdAt: string\n updatedAt: string\n source: 'tool' | 'manual'\n}\n\ninterface AgentUsage {\n promptTokens: number\n completionTokens: number\n totalTokens: number\n promptCacheHitTokens: number\n promptCacheMissTokens: number\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\nconst activeAiRequests = new Map<string, AbortController>()\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction getMemoriesDir(): string {\n const dir = join(getConfigDir(), 'memories')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction sanitizeFileName(value: string) {\n return value.replace(/[\\\\/:*?\"<>|]+/g, '_').trim()\n}\n\nfunction buildApiKeyHash(apiKey: string): string {\n return createHash('sha256').update(String(apiKey || '').trim()).digest('hex').slice(0, 24)\n}\n\nfunction buildMemoryScope(database: string, apiKey: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n const apiKeyHash = buildApiKeyHash(apiKey)\n return `${db}__${apiKeyHash}`\n}\n\nfunction getMemoryFilePath(database: string, apiKey: string): string {\n const scope = buildMemoryScope(database, apiKey)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<SqlExpertConfig>\n if (!parsed?.db || !parsed?.ai) return null\n return {\n db: parsed.db,\n ai: parsed.ai\n }\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction loadMemories(database: string, apiKey: string) {\n const filePath = getMemoryFilePath(database, apiKey)\n if (!existsSync(filePath)) {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n }\n\n try {\n const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))\n const list = Array.isArray(parsed) ? parsed : parsed?.memories\n const memories = Array.isArray(list)\n ? list\n .filter(item => item && typeof item.content === 'string')\n .map(item => ({\n id: item.id || randomUUID(),\n content: String(item.content),\n createdAt: item.createdAt || new Date().toISOString(),\n updatedAt: item.updatedAt || item.createdAt || new Date().toISOString(),\n source: (item.source === 'tool' ? 'tool' : 'manual') as 'tool' | 'manual'\n }))\n : []\n\n // 兼容手工编辑:统一写回标准结构,保证后续可直接增量追加。\n writeFileSync(filePath, JSON.stringify(memories, null, 2), 'utf-8')\n\n return {\n memories,\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n } catch {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n }\n}\n\nfunction appendMemory(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'tool'\n }\n\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\nfunction updateMemory(database: string, apiKey: string, memoryId: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const target = memories.find(m => m.id === memoryId)\n if (!target) throw new Error(`未找到 ID 为 ${memoryId} 的记忆`)\n target.content = content\n target.updatedAt = new Date().toISOString()\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry: target, memoryPath }\n}\n\nfunction deleteMemory(database: string, apiKey: string, memoryId: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const index = memories.findIndex(m => m.id === memoryId)\n if (index < 0) throw new Error(`未找到 ID 为 ${memoryId} 的记忆`)\n memories.splice(index, 1)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { memoryPath }\n}\n\nfunction addMemoryManual(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'manual'\n }\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n continue\n }\n if (char === '[') {\n quote = ']'\n continue\n }\n if (char === '(') {\n depth += 1\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n continue\n }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n current += char\n continue\n }\n if (char === '[') {\n quote = ']'\n current += char\n continue\n }\n if (char === '(') {\n depth += 1\n current += char\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n current += char\n continue\n }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => {\n /* ignore */\n })\n connectionPool = null\n }\n}\n\nfunction buildSystemPrompt(schema: string, memories: MemoryEntry[] = []): string {\n const memoryPrompt = memories.map(memory => memory.content).join('\\n\\n')\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 当用户明确要求导出或下载时,必须调用 export_data 工具。\n8. 当用户明确要求图表展示时,必须调用 render_chart 工具。\n9. 当你确认了可复用经验时,可以调用 save_memory 工具保存为本地记忆。\n10. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 历史记忆\n${memoryPrompt || '当前暂无已沉淀的可复用记忆。'}\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\`\n`\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description:\n '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: {\n type: 'string',\n description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。'\n },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: {\n type: 'array',\n items: { type: 'string' },\n description: '要查询结构的多个表名列表。'\n },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'render_chart',\n description: '根据真实查询结果绘制图表。支持 line、bar、pie、line_bar',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n type: {\n type: 'string',\n enum: ['line', 'bar', 'pie', 'line_bar']\n },\n title: { type: 'string' },\n xAxisData: { type: 'array', items: { type: 'string' } },\n series: { type: 'array' },\n reason: { type: 'string' }\n },\n required: ['type', 'title', 'series']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'save_memory',\n description: '保存可复用经验到本地记忆文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n content: { type: 'string' },\n reason: { type: 'string' }\n },\n required: ['content']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'export_data',\n description: '执行只读 SQL 查询并导出完整结果为 CSV 文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string' },\n reason: { type: 'string' },\n fileName: { type: 'string' }\n },\n required: ['sql']\n }\n }\n }\n ]\n}\n\nfunction stripToolMarkers(content: string): string {\n return String(content || '').replace(/:::tool:[^:]+:::/g, '').trim()\n}\n\nfunction buildToolResultSummary(toolCall: ToolCallRecord): string {\n if (toolCall.status === 'error' || toolCall.status === 'cancelled') {\n return JSON.stringify({ ok: false, error: toolCall.errorMessage || '工具调用失败' })\n }\n\n if (!toolCall.result) {\n return JSON.stringify({ ok: false, error: '未获取到结果' })\n }\n\n const summary: Record<string, unknown> = { ok: toolCall.result.ok }\n if (toolCall.result.reason) summary.reason = toolCall.result.reason\n if (typeof toolCall.result.totalRows === 'number') summary.totalRows = toolCall.result.totalRows\n if (typeof toolCall.result.returnedRows === 'number') summary.returnedRows = toolCall.result.returnedRows\n if (toolCall.result.truncated) summary.truncated = true\n if (toolCall.result.error) summary.error = toolCall.result.error\n if (toolCall.result.fileName) summary.fileName = toolCall.result.fileName\n if (toolCall.result.chartConfig) summary.chartRendered = true\n return JSON.stringify(summary)\n}\n\nfunction toModelMessages(uiMessages: AskAiMessage[]) {\n const modelMessages: Array<{\n role: string\n content: string | null\n tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>\n tool_call_id?: string\n }> = []\n\n for (const message of uiMessages) {\n if (message.status === 'loading' || message.status === 'error') continue\n\n const cleanContent = stripToolMarkers(message.content)\n\n if (message.role === 'user') {\n if (cleanContent) {\n modelMessages.push({ role: 'user', content: cleanContent })\n }\n continue\n }\n\n const toolCalls = (message.toolCalls || []).filter(tc => tc.id && tc.name)\n\n if (toolCalls.length) {\n modelMessages.push({\n role: 'assistant',\n content: cleanContent || null,\n tool_calls: toolCalls.map(tc => ({\n id: tc.id,\n type: 'function' as const,\n function: {\n name: tc.name,\n arguments: JSON.stringify(tc.args || {})\n }\n }))\n })\n\n for (const tc of toolCalls) {\n modelMessages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: buildToolResultSummary(tc)\n })\n }\n\n continue\n }\n\n if (cleanContent) {\n modelMessages.push({ role: 'assistant', content: cleanContent })\n }\n }\n\n return modelMessages\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n signal?: AbortSignal\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing =\n toolCalls[idx] ||\n ({\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall)\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[]; usage?: AgentUsage }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true,\n stream_options: {\n include_usage: true\n }\n }\n\n const stream = await openai.chat.completions.create(request, { signal: callbacks.signal })\n\n let content = ''\n const toolCalls: ToolCall[] = []\n let usage: AgentUsage | undefined\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n\n if ((chunk as any).usage) {\n const chunkUsage = (chunk as any).usage\n const promptTokens = Number(chunkUsage?.prompt_tokens || 0)\n const completionTokens = Number(chunkUsage?.completion_tokens || 0)\n const totalTokens = Number(chunkUsage?.total_tokens || promptTokens + completionTokens)\n const promptCacheHitTokens = Number(\n chunkUsage?.prompt_cache_hit_tokens || chunkUsage?.prompt_tokens_details?.cached_tokens || 0\n )\n const promptCacheMissTokens = Number(\n chunkUsage?.prompt_cache_miss_tokens || Math.max(promptTokens - promptCacheHitTokens, 0)\n )\n usage = {\n promptTokens,\n completionTokens,\n totalTokens,\n promptCacheHitTokens,\n promptCacheMissTokens\n }\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined,\n usage\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\nconst SQL_QUERY_TIMEOUT_MS = 60_000\n\nfunction buildChartOption(args: Record<string, any>): Record<string, unknown> {\n if (args.type === 'pie') {\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },\n legend: { bottom: 0, type: 'scroll' },\n series: (Array.isArray(args.series) ? args.series : []).map((s: any) => ({\n name: s.name,\n type: 'pie',\n radius: ['35%', '65%'],\n center: ['50%', '48%'],\n data: s.data,\n label: { formatter: '{b}\\n{d}%' }\n }))\n }\n }\n\n const series = Array.isArray(args.series) ? args.series : []\n const hasRightAxis = series.some((s: any) => s.yAxisIndex === 1)\n const yAxis = hasRightAxis\n ? [{ type: 'value' }, { type: 'value', splitLine: { show: false } }]\n : [{ type: 'value' }]\n\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },\n legend: { bottom: 0, type: 'scroll' },\n grid: { top: 48, bottom: 60, left: 50, right: hasRightAxis ? 50 : 20, containLabel: true },\n xAxis: { type: 'category', data: Array.isArray(args.xAxisData) ? args.xAxisData : [] },\n yAxis,\n series: series.map((s: any) => ({\n name: s.name,\n type: s.type,\n data: s.data,\n yAxisIndex: s.yAxisIndex || 0,\n smooth: s.type === 'line',\n barMaxWidth: 50\n }))\n }\n}\n\nfunction csvEscape(value: unknown) {\n const str = value == null ? '' : String(value)\n if (/[\",\\r\\n]/.test(str)) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`\n }\n return str\n}\n\nfunction rowsToCsv(rows: Array<Record<string, unknown>>) {\n const columnSet = new Set<string>()\n rows.forEach(row => {\n Object.keys(row || {}).forEach(key => columnSet.add(key))\n })\n const columns = Array.from(columnSet)\n const lines = [columns.map(csvEscape).join(',')]\n\n rows.forEach(row => {\n lines.push(columns.map(column => csvEscape(row?.[column])).join(','))\n })\n\n return lines.join('\\r\\n')\n}\n\nfunction buildExportFilePath(fileName?: string) {\n const exportsDir = join(getConfigDir(), 'exports')\n if (!existsSync(exportsDir)) {\n mkdirSync(exportsDir, { recursive: true })\n }\n\n const normalized = sanitizeFileName((fileName || `AI导出_${new Date().toISOString().slice(0, 10)}`).trim())\n const finalName = /\\.csv$/i.test(normalized) ? normalized : `${normalized}.csv`\n return {\n fileName: finalName,\n filePath: join(exportsDir, `${Date.now()}_${finalName}`)\n }\n}\n\nasync function queryWithTimeout(\n executor: mysql.Pool | mysql.Connection,\n sql: string,\n values?: Array<string | number | boolean | null>\n): Promise<[unknown, unknown]> {\n return executor.query({\n sql,\n timeout: SQL_QUERY_TIMEOUT_MS,\n values: values || []\n })\n}\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>,\n context: { database: string; apiKey: string }\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [\n Array<Record<string, unknown>>,\n unknown\n ]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName\n ? [args.tableName]\n : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n if (toolCall.function.name === 'render_chart') {\n const type = String(args.type || '')\n const validTypes = ['line', 'bar', 'pie', 'line_bar']\n if (!validTypes.includes(type)) {\n throw new Error(`不支持的图表类型: ${type}`)\n }\n if (!Array.isArray(args.series) || args.series.length === 0) {\n throw new Error('series 不能为空')\n }\n if (type !== 'pie' && (!Array.isArray(args.xAxisData) || args.xAxisData.length === 0)) {\n throw new Error('折线图、柱状图、组合图必须提供 xAxisData')\n }\n\n return {\n ok: true,\n reason: args.reason || '按用户要求绘制图表',\n chartConfig: buildChartOption(args)\n }\n }\n\n if (toolCall.function.name === 'save_memory') {\n const content = String(args.content || '').trim()\n if (!content) {\n throw new Error('记忆内容不能为空')\n }\n const memory = appendMemory(context.database, context.apiKey, content)\n return {\n ok: true,\n reason: args.reason || '沉淀可复用经验',\n memoryId: memory.entry.id,\n memoryPath: memory.memoryPath,\n rows: [{ id: memory.entry.id, content }]\n }\n }\n\n if (toolCall.function.name === 'export_data') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [Array<Record<string, unknown>>, unknown]\n const exportFile = buildExportFilePath(typeof args.fileName === 'string' ? args.fileName : undefined)\n writeFileSync(exportFile.filePath, rowsToCsv(rows), 'utf-8')\n\n return {\n ok: true,\n reason: args.reason || '按用户要求导出数据',\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n fileName: exportFile.fileName,\n filePath: exportFile.filePath\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 查询余额\n ipcMain.handle('sql-expert:check-balance', async (_event, config?: { url?: string; apiKey?: string }) => {\n try {\n const savedConfig = loadConfigFromDisk()\n const url = config?.url || savedConfig?.ai?.url || 'https://api.deepseek.com/v1'\n const apiKey = config?.apiKey || savedConfig?.ai?.apiKey || ''\n\n if (!apiKey) {\n return { success: false, message: 'API Key 不能为空' }\n }\n\n const base = url.replace(/\\/v1\\/?$/, '')\n const apiUrl = `${base}/user/balance`\n\n const res = await fetch(apiUrl, {\n method: 'GET',\n headers: {\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${apiKey}`\n }\n })\n\n if (!res.ok) {\n const errorText = await res.text()\n let errMsg = `HTTP ${res.status}`\n try {\n const errorData = JSON.parse(errorText)\n errMsg = errorData.error?.message || errorData.message || errMsg\n } catch {\n // ignore\n }\n throw new Error(errMsg)\n }\n\n const data = await res.json()\n if (data.is_available !== undefined) {\n const isAvailable = data.is_available\n const infoList = data.balance_infos || []\n let balanceInfo = ''\n if (infoList.length > 0) {\n balanceInfo = infoList.map((info: any) => `${info.total_balance} ${info.currency}`).join(', ')\n }\n return { \n success: true, \n message: `余额: ${balanceInfo || '未知'} ${isAvailable ? '(可用)' : '(欠费/受限)'}`\n }\n } else {\n return { success: true, message: '请求成功,但不包含余额信息' }\n }\n } catch (error) {\n return { success: false, message: `查询失败: ${error instanceof Error ? error.message : String(error)}` }\n }\n })\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const memoryState =\n config?.db?.database && config?.ai?.apiKey\n ? loadMemories(config.db.database, config.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n return {\n config,\n schema,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n })\n\n ipcMain.handle('sql-expert:load-memories', async (_event, payload?: { database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload?.database || saved?.db?.database || ''\n const apiKey = payload?.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) {\n throw new Error('缺少数据库名或 API Key,无法加载记忆')\n }\n\n const memoryState = loadMemories(database, apiKey)\n return {\n success: true,\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : '加载记忆失败',\n memories: [],\n memoryPath: '',\n memoryScope: '',\n memoryCount: 0\n }\n }\n })\n\n // 更新单条记忆\n ipcMain.handle('sql-expert:update-memory', async (_event, payload: { memoryId: string; content: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.memoryId || !payload.content?.trim()) throw new Error('记忆 ID 和内容不能为空')\n\n updateMemory(database, apiKey, payload.memoryId, payload.content.trim())\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '更新记忆失败', memories: [] }\n }\n })\n\n // 删除单条记忆\n ipcMain.handle('sql-expert:delete-memory', async (_event, payload: { memoryId: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.memoryId) throw new Error('记忆 ID 不能为空')\n\n deleteMemory(database, apiKey, payload.memoryId)\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '删除记忆失败', memories: [] }\n }\n })\n\n // 手动新增记忆\n ipcMain.handle('sql-expert:add-memory', async (_event, payload: { content: string; database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload.database || saved?.db?.database || ''\n const apiKey = payload.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) throw new Error('缺少数据库名或 API Key')\n if (!payload.content?.trim()) throw new Error('内容不能为空')\n\n addMemoryManual(database, apiKey, payload.content.trim())\n const memoryState = loadMemories(database, apiKey)\n return { success: true, memories: memoryState.memories, memoryPath: memoryState.memoryPath, memoryScope: memoryState.memoryScope, memoryCount: memoryState.memories.length }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '新增记忆失败', memories: [] }\n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = (await queryWithTimeout(\n conn,\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n )) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map((row) => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n const savedConfig = loadConfigFromDisk()\n const memoryState =\n config.database && savedConfig?.ai?.apiKey\n ? loadMemories(config.database, savedConfig.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n\n return {\n success: true,\n schema: schemaText,\n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map((n) => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map((n) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return {\n success: false,\n ok: false,\n error: error instanceof Error ? error.message : '查询失败'\n }\n }\n })\n\n ipcMain.handle('sql-expert:cancel-ask-ai', async (_event, payload: { requestId: string }) => {\n const requestId = String(payload?.requestId || '')\n const controller = activeAiRequests.get(requestId)\n if (!controller) {\n return { success: false, message: '当前没有可取消的请求' }\n }\n\n controller.abort()\n activeAiRequests.delete(requestId)\n return { success: true, message: '已停止生成' }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n const requestId = String(payload?.requestId || randomUUID())\n const abortController = new AbortController()\n activeAiRequests.set(requestId, abortController)\n\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const memoryState = loadMemories(savedConfig.db.database, savedConfig.ai.apiKey)\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = toModelMessages(payload.messages || [])\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, memoryState.memories) }\n const allMessages: Array<{\n role: string\n content: string | null\n tool_calls?: ToolCall[]\n tool_call_id?: string\n }> = [systemMessage, ...messages]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n let usage: AgentUsage = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n }\n const toolCallResults: Array<{\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: finalReply,\n toolCalls: toolCallResults,\n usage,\n status: 'stopped'\n }\n }\n\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n signal: abortController.signal,\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: mergeReply(finalReply, accContent)\n })\n }\n }\n })\n if (aiResponse.usage) {\n usage = aiResponse.usage\n }\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return {\n success: true,\n requestId,\n reply: finalReply || '未生成有效回复',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: finalReply\n })\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try {\n parsedArgs = JSON.parse(toolCall.function.arguments || '{}')\n } catch {\n /* ignore */\n }\n\n const callRecord: {\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs\n })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames, {\n database: savedConfig.db.database,\n apiKey: savedConfig.ai.apiKey\n })\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n requestId,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n } catch (error) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: '',\n toolCalls: [],\n usage: {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n },\n status: 'stopped'\n }\n }\n\n return { success: false, requestId, error: error instanceof Error ? error.message : 'AI 请求失败' }\n } finally {\n activeAiRequests.delete(requestId)\n }\n })\n}\n"}, "688127782dbe240bb9d50dbcccc23b4dc3b92858": {"t": 1785759185.9233084, "n": 41493, "h": "ccf1accc913d394e", "c": "/**\n * 企业级分析专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'\nimport { createHash, randomUUID } from 'crypto'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface ToolCallRecord {\n id: string\n name: string\n args: Record<string, unknown>\n result?: Record<string, unknown>\n status?: string\n errorMessage?: string\n}\n\ninterface AskAiMessage {\n role: 'user' | 'assistant'\n content: string\n status?: 'done' | 'loading' | 'error' | 'stopped'\n toolCalls?: ToolCallRecord[]\n}\n\ninterface AskAiPayload {\n requestId?: string\n messages: AskAiMessage[]\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\ninterface MemoryEntry {\n id: string\n content: string\n createdAt: string\n updatedAt: string\n source: 'tool' | 'manual'\n}\n\ninterface AgentUsage {\n promptTokens: number\n completionTokens: number\n totalTokens: number\n promptCacheHitTokens: number\n promptCacheMissTokens: number\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\nconst activeAiRequests = new Map<string, AbortController>()\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction getMemoriesDir(): string {\n const dir = join(getConfigDir(), 'memories')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction sanitizeFileName(value: string) {\n return value.replace(/[\\\\/:*?\"<>|]+/g, '_').trim()\n}\n\nfunction buildApiKeyHash(apiKey: string): string {\n return createHash('sha256').update(String(apiKey || '').trim()).digest('hex').slice(0, 24)\n}\n\nfunction buildMemoryScope(database: string, apiKey: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n const apiKeyHash = buildApiKeyHash(apiKey)\n return `${db}__${apiKeyHash}`\n}\n\nfunction getMemoryFilePath(database: string, apiKey: string): string {\n const scope = buildMemoryScope(database, apiKey)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<SqlExpertConfig>\n if (!parsed?.db || !parsed?.ai) return null\n return {\n db: parsed.db,\n ai: parsed.ai\n }\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction loadMemories(database: string, apiKey: string) {\n const filePath = getMemoryFilePath(database, apiKey)\n if (!existsSync(filePath)) {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n }\n\n try {\n const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))\n const list = Array.isArray(parsed) ? parsed : parsed?.memories\n const memories = Array.isArray(list)\n ? list\n .filter(item => item && typeof item.content === 'string')\n .map(item => ({\n id: item.id || randomUUID(),\n content: String(item.content),\n createdAt: item.createdAt || new Date().toISOString(),\n updatedAt: item.updatedAt || item.createdAt || new Date().toISOString(),\n source: (item.source === 'tool' ? 'tool' : 'manual') as 'tool' | 'manual'\n }))\n : []\n\n // 兼容手工编辑:统一写回标准结构,保证后续可直接增量追加。\n writeFileSync(filePath, JSON.stringify(memories, null, 2), 'utf-8')\n\n return {\n memories,\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n } catch {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n }\n}\n\nfunction appendMemory(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'tool'\n }\n\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n continue\n }\n if (char === '[') {\n quote = ']'\n continue\n }\n if (char === '(') {\n depth += 1\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n continue\n }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n current += char\n continue\n }\n if (char === '[') {\n quote = ']'\n current += char\n continue\n }\n if (char === '(') {\n depth += 1\n current += char\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n current += char\n continue\n }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => {\n /* ignore */\n })\n connectionPool = null\n }\n}\n\nfunction buildSystemPrompt(schema: string, memories: MemoryEntry[] = []): string {\n const memoryPrompt = memories.map(memory => memory.content).join('\\n\\n')\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 当用户明确要求导出或下载时,必须调用 export_data 工具。\n8. 当用户明确要求图表展示时,必须调用 render_chart 工具。\n9. 当你确认了可复用经验时,可以调用 save_memory 工具保存为本地记忆。\n10. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 历史记忆\n${memoryPrompt || '当前暂无已沉淀的可复用记忆。'}\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\`\n`\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description:\n '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: {\n type: 'string',\n description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。'\n },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: {\n type: 'array',\n items: { type: 'string' },\n description: '要查询结构的多个表名列表。'\n },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'render_chart',\n description: '根据真实查询结果绘制图表。支持 line、bar、pie、line_bar',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n type: {\n type: 'string',\n enum: ['line', 'bar', 'pie', 'line_bar']\n },\n title: { type: 'string' },\n xAxisData: { type: 'array', items: { type: 'string' } },\n series: { type: 'array' },\n reason: { type: 'string' }\n },\n required: ['type', 'title', 'series']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'save_memory',\n description: '保存可复用经验到本地记忆文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n content: { type: 'string' },\n reason: { type: 'string' }\n },\n required: ['content']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'export_data',\n description: '执行只读 SQL 查询并导出完整结果为 CSV 文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string' },\n reason: { type: 'string' },\n fileName: { type: 'string' }\n },\n required: ['sql']\n }\n }\n }\n ]\n}\n\nfunction stripToolMarkers(content: string): string {\n return String(content || '').replace(/:::tool:[^:]+:::/g, '').trim()\n}\n\nfunction buildToolResultSummary(toolCall: ToolCallRecord): string {\n if (toolCall.status === 'error' || toolCall.status === 'cancelled') {\n return JSON.stringify({ ok: false, error: toolCall.errorMessage || '工具调用失败' })\n }\n\n if (!toolCall.result) {\n return JSON.stringify({ ok: false, error: '未获取到结果' })\n }\n\n const summary: Record<string, unknown> = { ok: toolCall.result.ok }\n if (toolCall.result.reason) summary.reason = toolCall.result.reason\n if (typeof toolCall.result.totalRows === 'number') summary.totalRows = toolCall.result.totalRows\n if (typeof toolCall.result.returnedRows === 'number') summary.returnedRows = toolCall.result.returnedRows\n if (toolCall.result.truncated) summary.truncated = true\n if (toolCall.result.error) summary.error = toolCall.result.error\n if (toolCall.result.fileName) summary.fileName = toolCall.result.fileName\n if (toolCall.result.chartConfig) summary.chartRendered = true\n return JSON.stringify(summary)\n}\n\nfunction toModelMessages(uiMessages: AskAiMessage[]) {\n const modelMessages: Array<{\n role: string\n content: string | null\n tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>\n tool_call_id?: string\n }> = []\n\n for (const message of uiMessages) {\n if (message.status === 'loading' || message.status === 'error') continue\n\n const cleanContent = stripToolMarkers(message.content)\n\n if (message.role === 'user') {\n if (cleanContent) {\n modelMessages.push({ role: 'user', content: cleanContent })\n }\n continue\n }\n\n const toolCalls = (message.toolCalls || []).filter(tc => tc.id && tc.name)\n\n if (toolCalls.length) {\n modelMessages.push({\n role: 'assistant',\n content: cleanContent || null,\n tool_calls: toolCalls.map(tc => ({\n id: tc.id,\n type: 'function' as const,\n function: {\n name: tc.name,\n arguments: JSON.stringify(tc.args || {})\n }\n }))\n })\n\n for (const tc of toolCalls) {\n modelMessages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: buildToolResultSummary(tc)\n })\n }\n\n continue\n }\n\n if (cleanContent) {\n modelMessages.push({ role: 'assistant', content: cleanContent })\n }\n }\n\n return modelMessages\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n signal?: AbortSignal\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing =\n toolCalls[idx] ||\n ({\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall)\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[]; usage?: AgentUsage }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true,\n stream_options: {\n include_usage: true\n }\n }\n\n const stream = await openai.chat.completions.create(request, { signal: callbacks.signal })\n\n let content = ''\n const toolCalls: ToolCall[] = []\n let usage: AgentUsage | undefined\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n\n if ((chunk as any).usage) {\n const chunkUsage = (chunk as any).usage\n const promptTokens = Number(chunkUsage?.prompt_tokens || 0)\n const completionTokens = Number(chunkUsage?.completion_tokens || 0)\n const totalTokens = Number(chunkUsage?.total_tokens || promptTokens + completionTokens)\n const promptCacheHitTokens = Number(\n chunkUsage?.prompt_cache_hit_tokens || chunkUsage?.prompt_tokens_details?.cached_tokens || 0\n )\n const promptCacheMissTokens = Number(\n chunkUsage?.prompt_cache_miss_tokens || Math.max(promptTokens - promptCacheHitTokens, 0)\n )\n usage = {\n promptTokens,\n completionTokens,\n totalTokens,\n promptCacheHitTokens,\n promptCacheMissTokens\n }\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined,\n usage\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\nconst SQL_QUERY_TIMEOUT_MS = 60_000\n\nfunction buildChartOption(args: Record<string, any>): Record<string, unknown> {\n if (args.type === 'pie') {\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },\n legend: { bottom: 0, type: 'scroll' },\n series: (Array.isArray(args.series) ? args.series : []).map((s: any) => ({\n name: s.name,\n type: 'pie',\n radius: ['35%', '65%'],\n center: ['50%', '48%'],\n data: s.data,\n label: { formatter: '{b}\\n{d}%' }\n }))\n }\n }\n\n const series = Array.isArray(args.series) ? args.series : []\n const hasRightAxis = series.some((s: any) => s.yAxisIndex === 1)\n const yAxis = hasRightAxis\n ? [{ type: 'value' }, { type: 'value', splitLine: { show: false } }]\n : [{ type: 'value' }]\n\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },\n legend: { bottom: 0, type: 'scroll' },\n grid: { top: 48, bottom: 60, left: 50, right: hasRightAxis ? 50 : 20, containLabel: true },\n xAxis: { type: 'category', data: Array.isArray(args.xAxisData) ? args.xAxisData : [] },\n yAxis,\n series: series.map((s: any) => ({\n name: s.name,\n type: s.type,\n data: s.data,\n yAxisIndex: s.yAxisIndex || 0,\n smooth: s.type === 'line',\n barMaxWidth: 50\n }))\n }\n}\n\nfunction csvEscape(value: unknown) {\n const str = value == null ? '' : String(value)\n if (/[\",\\r\\n]/.test(str)) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`\n }\n return str\n}\n\nfunction rowsToCsv(rows: Array<Record<string, unknown>>) {\n const columnSet = new Set<string>()\n rows.forEach(row => {\n Object.keys(row || {}).forEach(key => columnSet.add(key))\n })\n const columns = Array.from(columnSet)\n const lines = [columns.map(csvEscape).join(',')]\n\n rows.forEach(row => {\n lines.push(columns.map(column => csvEscape(row?.[column])).join(','))\n })\n\n return lines.join('\\r\\n')\n}\n\nfunction buildExportFilePath(fileName?: string) {\n const exportsDir = join(getConfigDir(), 'exports')\n if (!existsSync(exportsDir)) {\n mkdirSync(exportsDir, { recursive: true })\n }\n\n const normalized = sanitizeFileName((fileName || `AI导出_${new Date().toISOString().slice(0, 10)}`).trim())\n const finalName = /\\.csv$/i.test(normalized) ? normalized : `${normalized}.csv`\n return {\n fileName: finalName,\n filePath: join(exportsDir, `${Date.now()}_${finalName}`)\n }\n}\n\nasync function queryWithTimeout(\n executor: mysql.Pool | mysql.Connection,\n sql: string,\n values?: Array<string | number | boolean | null>\n): Promise<[unknown, unknown]> {\n return executor.query({\n sql,\n timeout: SQL_QUERY_TIMEOUT_MS,\n values: values || []\n })\n}\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>,\n context: { database: string; apiKey: string }\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [\n Array<Record<string, unknown>>,\n unknown\n ]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName\n ? [args.tableName]\n : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n if (toolCall.function.name === 'render_chart') {\n const type = String(args.type || '')\n const validTypes = ['line', 'bar', 'pie', 'line_bar']\n if (!validTypes.includes(type)) {\n throw new Error(`不支持的图表类型: ${type}`)\n }\n if (!Array.isArray(args.series) || args.series.length === 0) {\n throw new Error('series 不能为空')\n }\n if (type !== 'pie' && (!Array.isArray(args.xAxisData) || args.xAxisData.length === 0)) {\n throw new Error('折线图、柱状图、组合图必须提供 xAxisData')\n }\n\n return {\n ok: true,\n reason: args.reason || '按用户要求绘制图表',\n chartConfig: buildChartOption(args)\n }\n }\n\n if (toolCall.function.name === 'save_memory') {\n const content = String(args.content || '').trim()\n if (!content) {\n throw new Error('记忆内容不能为空')\n }\n const memory = appendMemory(context.database, context.apiKey, content)\n return {\n ok: true,\n reason: args.reason || '沉淀可复用经验',\n memoryId: memory.entry.id,\n memoryPath: memory.memoryPath,\n rows: [{ id: memory.entry.id, content }]\n }\n }\n\n if (toolCall.function.name === 'export_data') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [Array<Record<string, unknown>>, unknown]\n const exportFile = buildExportFilePath(typeof args.fileName === 'string' ? args.fileName : undefined)\n writeFileSync(exportFile.filePath, rowsToCsv(rows), 'utf-8')\n\n return {\n ok: true,\n reason: args.reason || '按用户要求导出数据',\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n fileName: exportFile.fileName,\n filePath: exportFile.filePath\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 查询余额\n ipcMain.handle('sql-expert:check-balance', async (_event, config?: { url?: string; apiKey?: string }) => {\n try {\n const savedConfig = loadConfigFromDisk()\n const url = config?.url || savedConfig?.ai?.url || 'https://api.deepseek.com/v1'\n const apiKey = config?.apiKey || savedConfig?.ai?.apiKey || ''\n\n if (!apiKey) {\n return { success: false, message: 'API Key 不能为空' }\n }\n\n const base = url.replace(/\\/v1\\/?$/, '')\n const apiUrl = `${base}/user/balance`\n\n const res = await fetch(apiUrl, {\n method: 'GET',\n headers: {\n 'Accept': 'application/json',\n 'Authorization': `Bearer ${apiKey}`\n }\n })\n\n if (!res.ok) {\n const errorText = await res.text()\n let errMsg = `HTTP ${res.status}`\n try {\n const errorData = JSON.parse(errorText)\n errMsg = errorData.error?.message || errorData.message || errMsg\n } catch {\n // ignore\n }\n throw new Error(errMsg)\n }\n\n const data = await res.json()\n if (data.is_available !== undefined) {\n const isAvailable = data.is_available\n const infoList = data.balance_infos || []\n let balanceInfo = ''\n if (infoList.length > 0) {\n balanceInfo = infoList.map((info: any) => `${info.total_balance} ${info.currency}`).join(', ')\n }\n return { \n success: true, \n message: `余额: ${balanceInfo || '未知'} ${isAvailable ? '(可用)' : '(欠费/受限)'}`\n }\n } else {\n return { success: true, message: '请求成功,但不包含余额信息' }\n }\n } catch (error) {\n return { success: false, message: `查询失败: ${error instanceof Error ? error.message : String(error)}` }\n }\n })\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const memoryState =\n config?.db?.database && config?.ai?.apiKey\n ? loadMemories(config.db.database, config.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n return {\n config,\n schema,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n })\n\n ipcMain.handle('sql-expert:load-memories', async (_event, payload?: { database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload?.database || saved?.db?.database || ''\n const apiKey = payload?.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) {\n throw new Error('缺少数据库名或 API Key,无法加载记忆')\n }\n\n const memoryState = loadMemories(database, apiKey)\n return {\n success: true,\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : '加载记忆失败',\n memories: [],\n memoryPath: '',\n memoryScope: '',\n memoryCount: 0\n }\n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = (await queryWithTimeout(\n conn,\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n )) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map((row) => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n const savedConfig = loadConfigFromDisk()\n const memoryState =\n config.database && savedConfig?.ai?.apiKey\n ? loadMemories(config.database, savedConfig.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n\n return {\n success: true,\n schema: schemaText,\n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map((n) => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map((n) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return {\n success: false,\n ok: false,\n error: error instanceof Error ? error.message : '查询失败'\n }\n }\n })\n\n ipcMain.handle('sql-expert:cancel-ask-ai', async (_event, payload: { requestId: string }) => {\n const requestId = String(payload?.requestId || '')\n const controller = activeAiRequests.get(requestId)\n if (!controller) {\n return { success: false, message: '当前没有可取消的请求' }\n }\n\n controller.abort()\n activeAiRequests.delete(requestId)\n return { success: true, message: '已停止生成' }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n const requestId = String(payload?.requestId || randomUUID())\n const abortController = new AbortController()\n activeAiRequests.set(requestId, abortController)\n\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const memoryState = loadMemories(savedConfig.db.database, savedConfig.ai.apiKey)\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = toModelMessages(payload.messages || [])\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, memoryState.memories) }\n const allMessages: Array<{\n role: string\n content: string | null\n tool_calls?: ToolCall[]\n tool_call_id?: string\n }> = [systemMessage, ...messages]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n let usage: AgentUsage = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n }\n const toolCallResults: Array<{\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: finalReply,\n toolCalls: toolCallResults,\n usage,\n status: 'stopped'\n }\n }\n\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n signal: abortController.signal,\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: mergeReply(finalReply, accContent)\n })\n }\n }\n })\n if (aiResponse.usage) {\n usage = aiResponse.usage\n }\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return {\n success: true,\n requestId,\n reply: finalReply || '未生成有效回复',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: finalReply\n })\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try {\n parsedArgs = JSON.parse(toolCall.function.arguments || '{}')\n } catch {\n /* ignore */\n }\n\n const callRecord: {\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs\n })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames, {\n database: savedConfig.db.database,\n apiKey: savedConfig.ai.apiKey\n })\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n requestId,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n } catch (error) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: '',\n toolCalls: [],\n usage: {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n },\n status: 'stopped'\n }\n }\n\n return { success: false, requestId, error: error instanceof Error ? error.message : 'AI 请求失败' }\n } finally {\n activeAiRequests.delete(requestId)\n }\n })\n}\n"}, "ea40e18eda987fe3868870ab0663d19b64b63ab4": {"t": 1785759188.858098, "n": 39753, "h": "fe3bbbd05b679a1a", "c": "/**\n * SQL 专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'\nimport { createHash, randomUUID } from 'crypto'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface ToolCallRecord {\n id: string\n name: string\n args: Record<string, unknown>\n result?: Record<string, unknown>\n status?: string\n errorMessage?: string\n}\n\ninterface AskAiMessage {\n role: 'user' | 'assistant'\n content: string\n status?: 'done' | 'loading' | 'error' | 'stopped'\n toolCalls?: ToolCallRecord[]\n}\n\ninterface AskAiPayload {\n requestId?: string\n messages: AskAiMessage[]\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\ninterface MemoryEntry {\n id: string\n content: string\n createdAt: string\n updatedAt: string\n source: 'tool' | 'manual'\n}\n\ninterface AgentUsage {\n promptTokens: number\n completionTokens: number\n totalTokens: number\n promptCacheHitTokens: number\n promptCacheMissTokens: number\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\nconst activeAiRequests = new Map<string, AbortController>()\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction getMemoriesDir(): string {\n const dir = join(getConfigDir(), 'memories')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction sanitizeFileName(value: string) {\n return value.replace(/[\\\\/:*?\"<>|]+/g, '_').trim()\n}\n\nfunction buildApiKeyHash(apiKey: string): string {\n return createHash('sha256').update(String(apiKey || '').trim()).digest('hex').slice(0, 24)\n}\n\nfunction buildMemoryScope(database: string, apiKey: string): string {\n const db = sanitizeFileName(String(database || '').trim() || 'unknown_db')\n const apiKeyHash = buildApiKeyHash(apiKey)\n return `${db}__${apiKeyHash}`\n}\n\nfunction getMemoryFilePath(database: string, apiKey: string): string {\n const scope = buildMemoryScope(database, apiKey)\n return join(getMemoriesDir(), `${scope}.json`)\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<SqlExpertConfig>\n if (!parsed?.db || !parsed?.ai) return null\n return {\n db: parsed.db,\n ai: parsed.ai\n }\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction loadMemories(database: string, apiKey: string) {\n const filePath = getMemoryFilePath(database, apiKey)\n if (!existsSync(filePath)) {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n }\n\n try {\n const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))\n const list = Array.isArray(parsed) ? parsed : parsed?.memories\n const memories = Array.isArray(list)\n ? list\n .filter(item => item && typeof item.content === 'string')\n .map(item => ({\n id: item.id || randomUUID(),\n content: String(item.content),\n createdAt: item.createdAt || new Date().toISOString(),\n updatedAt: item.updatedAt || item.createdAt || new Date().toISOString(),\n source: (item.source === 'tool' ? 'tool' : 'manual') as 'tool' | 'manual'\n }))\n : []\n\n // 兼容手工编辑:统一写回标准结构,保证后续可直接增量追加。\n writeFileSync(filePath, JSON.stringify(memories, null, 2), 'utf-8')\n\n return {\n memories,\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n } catch {\n writeFileSync(filePath, JSON.stringify([], null, 2), 'utf-8')\n return {\n memories: [] as MemoryEntry[],\n memoryPath: filePath,\n memoryScope: buildMemoryScope(database, apiKey)\n }\n }\n}\n\nfunction appendMemory(database: string, apiKey: string, content: string) {\n const { memories, memoryPath } = loadMemories(database, apiKey)\n const now = new Date().toISOString()\n const entry: MemoryEntry = {\n id: randomUUID(),\n content,\n createdAt: now,\n updatedAt: now,\n source: 'tool'\n }\n\n memories.push(entry)\n writeFileSync(memoryPath, JSON.stringify(memories, null, 2), 'utf-8')\n return { entry, memoryPath }\n}\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n continue\n }\n if (char === '[') {\n quote = ']'\n continue\n }\n if (char === '(') {\n depth += 1\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n continue\n }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n current += char\n continue\n }\n if (char === '[') {\n quote = ']'\n current += char\n continue\n }\n if (char === '(') {\n depth += 1\n current += char\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n current += char\n continue\n }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => {\n /* ignore */\n })\n connectionPool = null\n }\n}\n\nfunction buildSystemPrompt(schema: string, memories: MemoryEntry[] = []): string {\n const memoryPrompt = memories.map(memory => memory.content).join('\\n\\n')\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 当用户明确要求导出或下载时,必须调用 export_data 工具。\n8. 当用户明确要求图表展示时,必须调用 render_chart 工具。\n9. 当你确认了可复用经验时,可以调用 save_memory 工具保存为本地记忆。\n10. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 历史记忆\n${memoryPrompt || '当前暂无已沉淀的可复用记忆。'}\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\`\n`\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description:\n '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: {\n type: 'string',\n description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。'\n },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: {\n type: 'array',\n items: { type: 'string' },\n description: '要查询结构的多个表名列表。'\n },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'render_chart',\n description: '根据真实查询结果绘制图表。支持 line、bar、pie、line_bar',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n type: {\n type: 'string',\n enum: ['line', 'bar', 'pie', 'line_bar']\n },\n title: { type: 'string' },\n xAxisData: { type: 'array', items: { type: 'string' } },\n series: { type: 'array' },\n reason: { type: 'string' }\n },\n required: ['type', 'title', 'series']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'save_memory',\n description: '保存可复用经验到本地记忆文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n content: { type: 'string' },\n reason: { type: 'string' }\n },\n required: ['content']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'export_data',\n description: '执行只读 SQL 查询并导出完整结果为 CSV 文件',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string' },\n reason: { type: 'string' },\n fileName: { type: 'string' }\n },\n required: ['sql']\n }\n }\n }\n ]\n}\n\nfunction stripToolMarkers(content: string): string {\n return String(content || '').replace(/:::tool:[^:]+:::/g, '').trim()\n}\n\nfunction buildToolResultSummary(toolCall: ToolCallRecord): string {\n if (toolCall.status === 'error' || toolCall.status === 'cancelled') {\n return JSON.stringify({ ok: false, error: toolCall.errorMessage || '工具调用失败' })\n }\n\n if (!toolCall.result) {\n return JSON.stringify({ ok: false, error: '未获取到结果' })\n }\n\n const summary: Record<string, unknown> = { ok: toolCall.result.ok }\n if (toolCall.result.reason) summary.reason = toolCall.result.reason\n if (typeof toolCall.result.totalRows === 'number') summary.totalRows = toolCall.result.totalRows\n if (typeof toolCall.result.returnedRows === 'number') summary.returnedRows = toolCall.result.returnedRows\n if (toolCall.result.truncated) summary.truncated = true\n if (toolCall.result.error) summary.error = toolCall.result.error\n if (toolCall.result.fileName) summary.fileName = toolCall.result.fileName\n if (toolCall.result.chartConfig) summary.chartRendered = true\n return JSON.stringify(summary)\n}\n\nfunction toModelMessages(uiMessages: AskAiMessage[]) {\n const modelMessages: Array<{\n role: string\n content: string | null\n tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>\n tool_call_id?: string\n }> = []\n\n for (const message of uiMessages) {\n if (message.status === 'loading' || message.status === 'error') continue\n\n const cleanContent = stripToolMarkers(message.content)\n\n if (message.role === 'user') {\n if (cleanContent) {\n modelMessages.push({ role: 'user', content: cleanContent })\n }\n continue\n }\n\n const toolCalls = (message.toolCalls || []).filter(tc => tc.id && tc.name)\n\n if (toolCalls.length) {\n modelMessages.push({\n role: 'assistant',\n content: cleanContent || null,\n tool_calls: toolCalls.map(tc => ({\n id: tc.id,\n type: 'function' as const,\n function: {\n name: tc.name,\n arguments: JSON.stringify(tc.args || {})\n }\n }))\n })\n\n for (const tc of toolCalls) {\n modelMessages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: buildToolResultSummary(tc)\n })\n }\n\n continue\n }\n\n if (cleanContent) {\n modelMessages.push({ role: 'assistant', content: cleanContent })\n }\n }\n\n return modelMessages\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n signal?: AbortSignal\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing =\n toolCalls[idx] ||\n ({\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall)\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[]; usage?: AgentUsage }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true,\n stream_options: {\n include_usage: true\n }\n }\n\n const stream = await openai.chat.completions.create(request, { signal: callbacks.signal })\n\n let content = ''\n const toolCalls: ToolCall[] = []\n let usage: AgentUsage | undefined\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n\n if ((chunk as any).usage) {\n const chunkUsage = (chunk as any).usage\n const promptTokens = Number(chunkUsage?.prompt_tokens || 0)\n const completionTokens = Number(chunkUsage?.completion_tokens || 0)\n const totalTokens = Number(chunkUsage?.total_tokens || promptTokens + completionTokens)\n const promptCacheHitTokens = Number(\n chunkUsage?.prompt_cache_hit_tokens || chunkUsage?.prompt_tokens_details?.cached_tokens || 0\n )\n const promptCacheMissTokens = Number(\n chunkUsage?.prompt_cache_miss_tokens || Math.max(promptTokens - promptCacheHitTokens, 0)\n )\n usage = {\n promptTokens,\n completionTokens,\n totalTokens,\n promptCacheHitTokens,\n promptCacheMissTokens\n }\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined,\n usage\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\nconst SQL_QUERY_TIMEOUT_MS = 60_000\n\nfunction buildChartOption(args: Record<string, any>): Record<string, unknown> {\n if (args.type === 'pie') {\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },\n legend: { bottom: 0, type: 'scroll' },\n series: (Array.isArray(args.series) ? args.series : []).map((s: any) => ({\n name: s.name,\n type: 'pie',\n radius: ['35%', '65%'],\n center: ['50%', '48%'],\n data: s.data,\n label: { formatter: '{b}\\n{d}%' }\n }))\n }\n }\n\n const series = Array.isArray(args.series) ? args.series : []\n const hasRightAxis = series.some((s: any) => s.yAxisIndex === 1)\n const yAxis = hasRightAxis\n ? [{ type: 'value' }, { type: 'value', splitLine: { show: false } }]\n : [{ type: 'value' }]\n\n return {\n title: { text: args.title, left: 'center', top: 8 },\n tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },\n legend: { bottom: 0, type: 'scroll' },\n grid: { top: 48, bottom: 60, left: 50, right: hasRightAxis ? 50 : 20, containLabel: true },\n xAxis: { type: 'category', data: Array.isArray(args.xAxisData) ? args.xAxisData : [] },\n yAxis,\n series: series.map((s: any) => ({\n name: s.name,\n type: s.type,\n data: s.data,\n yAxisIndex: s.yAxisIndex || 0,\n smooth: s.type === 'line',\n barMaxWidth: 50\n }))\n }\n}\n\nfunction csvEscape(value: unknown) {\n const str = value == null ? '' : String(value)\n if (/[\",\\r\\n]/.test(str)) {\n return `\"${str.replace(/\"/g, '\"\"')}\"`\n }\n return str\n}\n\nfunction rowsToCsv(rows: Array<Record<string, unknown>>) {\n const columnSet = new Set<string>()\n rows.forEach(row => {\n Object.keys(row || {}).forEach(key => columnSet.add(key))\n })\n const columns = Array.from(columnSet)\n const lines = [columns.map(csvEscape).join(',')]\n\n rows.forEach(row => {\n lines.push(columns.map(column => csvEscape(row?.[column])).join(','))\n })\n\n return lines.join('\\r\\n')\n}\n\nfunction buildExportFilePath(fileName?: string) {\n const exportsDir = join(getConfigDir(), 'exports')\n if (!existsSync(exportsDir)) {\n mkdirSync(exportsDir, { recursive: true })\n }\n\n const normalized = sanitizeFileName((fileName || `AI导出_${new Date().toISOString().slice(0, 10)}`).trim())\n const finalName = /\\.csv$/i.test(normalized) ? normalized : `${normalized}.csv`\n return {\n fileName: finalName,\n filePath: join(exportsDir, `${Date.now()}_${finalName}`)\n }\n}\n\nasync function queryWithTimeout(\n executor: mysql.Pool | mysql.Connection,\n sql: string,\n values?: Array<string | number | boolean | null>\n): Promise<[unknown, unknown]> {\n return executor.query({\n sql,\n timeout: SQL_QUERY_TIMEOUT_MS,\n values: values || []\n })\n}\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>,\n context: { database: string; apiKey: string }\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [\n Array<Record<string, unknown>>,\n unknown\n ]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName\n ? [args.tableName]\n : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n if (toolCall.function.name === 'render_chart') {\n const type = String(args.type || '')\n const validTypes = ['line', 'bar', 'pie', 'line_bar']\n if (!validTypes.includes(type)) {\n throw new Error(`不支持的图表类型: ${type}`)\n }\n if (!Array.isArray(args.series) || args.series.length === 0) {\n throw new Error('series 不能为空')\n }\n if (type !== 'pie' && (!Array.isArray(args.xAxisData) || args.xAxisData.length === 0)) {\n throw new Error('折线图、柱状图、组合图必须提供 xAxisData')\n }\n\n return {\n ok: true,\n reason: args.reason || '按用户要求绘制图表',\n chartConfig: buildChartOption(args)\n }\n }\n\n if (toolCall.function.name === 'save_memory') {\n const content = String(args.content || '').trim()\n if (!content) {\n throw new Error('记忆内容不能为空')\n }\n const memory = appendMemory(context.database, context.apiKey, content)\n return {\n ok: true,\n reason: args.reason || '沉淀可复用经验',\n memoryId: memory.entry.id,\n memoryPath: memory.memoryPath,\n rows: [{ id: memory.entry.id, content }]\n }\n }\n\n if (toolCall.function.name === 'export_data') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [Array<Record<string, unknown>>, unknown]\n const exportFile = buildExportFilePath(typeof args.fileName === 'string' ? args.fileName : undefined)\n writeFileSync(exportFile.filePath, rowsToCsv(rows), 'utf-8')\n\n return {\n ok: true,\n reason: args.reason || '按用户要求导出数据',\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n fileName: exportFile.fileName,\n filePath: exportFile.filePath\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const memoryState =\n config?.db?.database && config?.ai?.apiKey\n ? loadMemories(config.db.database, config.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n return {\n config,\n schema,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n })\n\n ipcMain.handle('sql-expert:load-memories', async (_event, payload?: { database?: string; apiKey?: string }) => {\n try {\n const saved = loadConfigFromDisk()\n const database = payload?.database || saved?.db?.database || ''\n const apiKey = payload?.apiKey || saved?.ai?.apiKey || ''\n if (!database || !apiKey) {\n throw new Error('缺少数据库名或 API Key,无法加载记忆')\n }\n\n const memoryState = loadMemories(database, apiKey)\n return {\n success: true,\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : '加载记忆失败',\n memories: [],\n memoryPath: '',\n memoryScope: '',\n memoryCount: 0\n }\n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = (await queryWithTimeout(\n conn,\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n )) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map((row) => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n const savedConfig = loadConfigFromDisk()\n const memoryState =\n config.database && savedConfig?.ai?.apiKey\n ? loadMemories(config.database, savedConfig.ai.apiKey)\n : { memories: [] as MemoryEntry[], memoryPath: '', memoryScope: '' }\n\n return {\n success: true,\n schema: schemaText,\n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n memories: memoryState.memories,\n memoryPath: memoryState.memoryPath,\n memoryScope: memoryState.memoryScope,\n memoryCount: memoryState.memories.length\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map((n) => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map((n) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return {\n success: false,\n ok: false,\n error: error instanceof Error ? error.message : '查询失败'\n }\n }\n })\n\n ipcMain.handle('sql-expert:cancel-ask-ai', async (_event, payload: { requestId: string }) => {\n const requestId = String(payload?.requestId || '')\n const controller = activeAiRequests.get(requestId)\n if (!controller) {\n return { success: false, message: '当前没有可取消的请求' }\n }\n\n controller.abort()\n activeAiRequests.delete(requestId)\n return { success: true, message: '已停止生成' }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n const requestId = String(payload?.requestId || randomUUID())\n const abortController = new AbortController()\n activeAiRequests.set(requestId, abortController)\n\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const memoryState = loadMemories(savedConfig.db.database, savedConfig.ai.apiKey)\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = toModelMessages(payload.messages || [])\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, memoryState.memories) }\n const allMessages: Array<{\n role: string\n content: string | null\n tool_calls?: ToolCall[]\n tool_call_id?: string\n }> = [systemMessage, ...messages]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n let usage: AgentUsage = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n }\n const toolCallResults: Array<{\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: finalReply,\n toolCalls: toolCallResults,\n usage,\n status: 'stopped'\n }\n }\n\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n signal: abortController.signal,\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: mergeReply(finalReply, accContent)\n })\n }\n }\n })\n if (aiResponse.usage) {\n usage = aiResponse.usage\n }\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return {\n success: true,\n requestId,\n reply: finalReply || '未生成有效回复',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', {\n requestId,\n content: finalReply\n })\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try {\n parsedArgs = JSON.parse(toolCall.function.arguments || '{}')\n } catch {\n /* ignore */\n }\n\n const callRecord: {\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs\n })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames, {\n database: savedConfig.db.database,\n apiKey: savedConfig.ai.apiKey\n })\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n requestId,\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n requestId,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults,\n usage,\n status: 'done'\n }\n } catch (error) {\n if (abortController.signal.aborted) {\n return {\n success: true,\n requestId,\n reply: '',\n toolCalls: [],\n usage: {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n promptCacheHitTokens: 0,\n promptCacheMissTokens: 0\n },\n status: 'stopped'\n }\n }\n\n return { success: false, requestId, error: error instanceof Error ? error.message : 'AI 请求失败' }\n } finally {\n activeAiRequests.delete(requestId)\n }\n })\n}\n"}, "8fbe814e55a83246a023d3d8c2cf3b8876a7bc42": {"t": 1785759190.4668796, "n": 31645, "h": "13950f109ecd83eb", "c": "/**\n * SQL 专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app, dialog } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join, extname, relative } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs'\nimport { readdir, stat, readFile } from 'fs/promises'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n backendProjectRoot: string\n}\n\ninterface AskAiPayload {\n messages: Array<{ role: string; content: string }>\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<SqlExpertConfig>\n if (!parsed?.db || !parsed?.ai) return null\n return {\n db: parsed.db,\n ai: parsed.ai,\n backendProjectRoot: typeof parsed.backendProjectRoot === 'string' ? parsed.backendProjectRoot : ''\n }\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction getPromptPath(): string {\n return join(getConfigDir(), 'sql-prompt.md')\n}\n\nconst PROMPT_SCAN_SKIP_DIRS = new Set([\n 'node_modules',\n '.git',\n 'dist',\n 'build',\n 'target',\n '.idea',\n '.vscode',\n 'logs',\n 'log',\n 'tmp',\n 'temp',\n 'coverage',\n '.next',\n '.nuxt'\n])\n\nconst PROMPT_SCAN_TEXT_EXTENSIONS = new Set([\n '.ts',\n '.js',\n '.tsx',\n '.jsx',\n '.mjs',\n '.cjs',\n '.java',\n '.kt',\n '.kts',\n '.go',\n '.py',\n '.cs',\n '.php',\n '.rb',\n '.xml',\n '.sql',\n '.yml',\n '.yaml',\n '.json',\n '.properties',\n '.md'\n])\n\nconst PROMPT_SCAN_FILE_MAX_BYTES = 256 * 1024\nconst PROMPT_SCAN_SUMMARY_MAX_LENGTH = 48_000\nconst PROMPT_SCAN_MAX_FILES = 3_000\n\nconst PROMPT_IMPORTANT_PATH_KEYWORDS = [\n 'repository',\n 'repo',\n 'service',\n 'mapper',\n 'controller',\n 'dao',\n 'query',\n 'sql',\n 'order',\n 'product',\n 'activity',\n 'store',\n 'goods',\n 'inventory'\n]\n\nconst PROMPT_IMPORTANT_CONTENT_REGEX =\n /(select\\s+.+from|left\\s+join|inner\\s+join|where\\s+|group\\s+by|order\\s+by|mapper|repository|service|controller|query|商品|订单|活动|门店|库存)/i\n\nconst SQL_PROMPT_GENERATION_INSTRUCTION = `现在这个项目接入了 ai 查询,ai 可以直接访问数据库查询数据(ai 直接编写sql),这个项目是后端,请你扫描一下这个项目的逻辑,重点看主要的查询逻辑;然后编写一份文档,用于交给 ai 并附加在提示词中,重点提示 ai 某些特殊的查询逻辑,或是数据之间的关联关系、含义等,或者是你认为 ai 必须知道的一些事情。另外,ai 已经知道了数据库的全部表结构和数据,你不必额外写数据结构。你不需要关心 ai 如何实现的,只需要编写一个文档。你只需要关心主要的逻辑,如商品、订单、活动、门店等(不局限于我提到的这些),一些你认为较少使用的功能或本身就冷门的数据不需要查看。`\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n continue\n }\n if (char === '[') {\n quote = ']'\n continue\n }\n if (char === '(') {\n depth += 1\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n continue\n }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') {\n quote = char\n current += char\n continue\n }\n if (char === '[') {\n quote = ']'\n current += char\n continue\n }\n if (char === '(') {\n depth += 1\n current += char\n continue\n }\n if (char === ')') {\n depth = Math.max(0, depth - 1)\n current += char\n continue\n }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => {\n /* ignore */\n })\n connectionPool = null\n }\n}\n\nfunction loadPromptFromDisk(): string {\n const promptPath = getPromptPath()\n if (!existsSync(promptPath)) return ''\n try {\n return readFileSync(promptPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction isSkippableDirectory(dirname: string): boolean {\n const normalized = dirname.trim().toLowerCase()\n return PROMPT_SCAN_SKIP_DIRS.has(normalized)\n}\n\nfunction isTextCodeFile(filePath: string): boolean {\n return PROMPT_SCAN_TEXT_EXTENSIONS.has(extname(filePath).toLowerCase())\n}\n\nfunction scorePath(relativePath: string): number {\n const lower = relativePath.toLowerCase()\n return PROMPT_IMPORTANT_PATH_KEYWORDS.reduce((score, keyword) => {\n return lower.includes(keyword) ? score + 2 : score\n }, 0)\n}\n\nfunction collectSnippetLines(content: string, maxLines = 10): string[] {\n const lines = content.split(/\\r?\\n/)\n const snippets: string[] = []\n for (const line of lines) {\n const trimmed = line.trim()\n if (!trimmed) continue\n if (!PROMPT_IMPORTANT_CONTENT_REGEX.test(trimmed)) continue\n snippets.push(trimmed.length > 180 ? `${trimmed.slice(0, 180)}...` : trimmed)\n if (snippets.length >= maxLines) break\n }\n return snippets\n}\n\nasync function buildBackendProjectSummary(rootPath: string): Promise<string> {\n const queue = [rootPath]\n let walkedFiles = 0\n const candidates: Array<{ relativePath: string; score: number; snippets: string[] }> = []\n\n while (queue.length && walkedFiles < PROMPT_SCAN_MAX_FILES) {\n const current = queue.shift()\n if (!current) break\n\n let entries\n try {\n entries = await readdir(current, { withFileTypes: true })\n } catch {\n continue\n }\n\n for (const entry of entries) {\n const fullPath = join(current, entry.name)\n if (entry.isDirectory()) {\n if (isSkippableDirectory(entry.name)) continue\n queue.push(fullPath)\n continue\n }\n if (!entry.isFile()) continue\n if (!isTextCodeFile(fullPath)) continue\n walkedFiles += 1\n\n let fileStat\n try {\n fileStat = await stat(fullPath)\n } catch {\n continue\n }\n if (fileStat.size > PROMPT_SCAN_FILE_MAX_BYTES || fileStat.size <= 0) continue\n\n const relPath = relative(rootPath, fullPath).replace(/\\\\/g, '/')\n const pathScore = scorePath(relPath)\n\n let content = ''\n try {\n content = await readFile(fullPath, 'utf-8')\n } catch {\n continue\n }\n\n const snippets = collectSnippetLines(content)\n const contentScore = snippets.length * 2\n const score = pathScore + contentScore\n if (score <= 0) continue\n\n candidates.push({ relativePath: relPath, score, snippets })\n }\n }\n\n const picked = candidates\n .sort((a, b) => b.score - a.score)\n .slice(0, 80)\n .map((item, index) => {\n const snippets = item.snippets.length ? item.snippets.map((line) => `- ${line}`).join('\\n') : '- (无明显 SQL 相关片段)'\n return `### ${index + 1}. ${item.relativePath}\\n重要性分值: ${item.score}\\n${snippets}`\n })\n .join('\\n\\n')\n\n const summary = [\n `项目根目录: ${rootPath}`,\n `扫描文件总数(上限 ${PROMPT_SCAN_MAX_FILES}): ${walkedFiles}`,\n `命中候选文件数: ${candidates.length}`,\n '',\n '以下是与查询逻辑可能最相关的代码摘要:',\n picked || '- 未检出明显与查询逻辑相关的文件'\n ].join('\\n')\n\n return summary.length > PROMPT_SCAN_SUMMARY_MAX_LENGTH\n ? `${summary.slice(0, PROMPT_SCAN_SUMMARY_MAX_LENGTH)}\\n\\n(摘要已截断)`\n : summary\n}\n\nasync function generateSqlPromptByAi(aiConfig: AiConfig, backendProjectRoot: string): Promise<string> {\n const projectSummary = await buildBackendProjectSummary(backendProjectRoot)\n const payload = {\n model: aiConfig.model,\n temperature: 0.1,\n messages: [\n {\n role: 'system',\n content:\n '你是一位资深后端架构与数据查询顾问。请基于给定项目摘要输出高质量 markdown 文档,内容简洁、准确、可直接用于 AI 查询提示词。'\n },\n {\n role: 'user',\n content: `${SQL_PROMPT_GENERATION_INSTRUCTION}\\n\\n---\\n\\n项目扫描摘要:\\n${projectSummary}`\n }\n ]\n } as any\n\n const response = await callAiApi(aiConfig, payload)\n const content = response.content.trim()\n if (!content) throw new Error('AI 未生成有效的 sql-prompt 内容')\n return content\n}\n\nfunction buildSystemPrompt(schema: string, prompt: string): string {\n const promptSection = prompt.trim()\n ? `\\n### 查询建议\\n${prompt.trim()}\\n`\n : ''\n\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\`\n${promptSection}\n`\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description:\n '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: {\n type: 'string',\n description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。'\n },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: {\n type: 'array',\n items: { type: 'string' },\n description: '要查询结构的多个表名列表。'\n },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n }\n ]\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing =\n toolCalls[idx] ||\n ({\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall)\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[] }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true\n }\n\n const stream = await openai.chat.completions.create(request)\n\n let content = ''\n const toolCalls: ToolCall[] = []\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\nconst SQL_QUERY_TIMEOUT_MS = 60_000\n\nasync function queryWithTimeout(\n executor: mysql.Pool | mysql.Connection,\n sql: string,\n values?: Array<string | number | boolean | null>\n): Promise<[unknown, unknown]> {\n return executor.query({\n sql,\n timeout: SQL_QUERY_TIMEOUT_MS,\n values: values || []\n })\n}\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = (await queryWithTimeout(pool, args.sql)) as [\n Array<Record<string, unknown>>,\n unknown\n ]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName\n ? [args.tableName]\n : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n const normalized: SqlExpertConfig = {\n ...config,\n backendProjectRoot: typeof config.backendProjectRoot === 'string' ? config.backendProjectRoot : ''\n }\n saveConfigToDisk(normalized)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const prompt = loadPromptFromDisk()\n return {\n config,\n schema,\n schemaPath: getSchemaPath(),\n prompt,\n promptPath: getPromptPath(),\n backendProjectRoot: config?.backendProjectRoot || ''\n }\n })\n\n // 选择后端项目根目录\n ipcMain.handle('sql-expert:select-backend-root', async () => {\n const result = await dialog.showOpenDialog({\n properties: ['openDirectory'],\n title: '选择后端项目根目录'\n })\n if (result.canceled || result.filePaths.length === 0) {\n return { success: false }\n }\n const path = result.filePaths[0]\n return { success: true, path }\n })\n\n // 清空后端项目根目录\n ipcMain.handle('sql-expert:clear-backend-root', async () => {\n try {\n const config = loadConfigFromDisk()\n if (!config) return { success: false, error: '请先保存 SQL 专家配置' }\n saveConfigToDisk({ ...config, backendProjectRoot: '' })\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '清空失败' }\n }\n })\n\n // 生成 sql-prompt.md\n ipcMain.handle(\n 'sql-expert:generate-prompt',\n async (_event, payload?: { forceRegenerate?: boolean; backendProjectRoot?: string }) => {\n try {\n const config = loadConfigFromDisk()\n if (!config || !config?.ai?.url || !config?.ai?.apiKey || !config?.ai?.model) {\n throw new Error('请先在设置中完整配置 AI 参数')\n }\n const backendProjectRoot = (payload?.backendProjectRoot || config.backendProjectRoot || '').trim()\n if (!backendProjectRoot) {\n throw new Error('请先选择后端项目根目录')\n }\n if (!existsSync(backendProjectRoot)) {\n throw new Error('后端项目根目录不存在,请重新选择')\n }\n if (backendProjectRoot !== config.backendProjectRoot) {\n saveConfigToDisk({ ...config, backendProjectRoot })\n }\n\n const promptPath = getPromptPath()\n if (payload?.forceRegenerate && existsSync(promptPath)) {\n unlinkSync(promptPath)\n }\n\n const prompt = await generateSqlPromptByAi(config.ai, backendProjectRoot)\n writeFileSync(promptPath, prompt, 'utf-8')\n\n return {\n success: true,\n message: 'sql-prompt.md 生成成功',\n prompt,\n promptPath\n }\n } catch (error) {\n return {\n success: false,\n error: error instanceof Error ? error.message : '生成 sql-prompt.md 失败'\n }\n }\n }\n )\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = (await queryWithTimeout(\n conn,\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n )) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map((row) => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n\n const prompt = loadPromptFromDisk()\n\n return {\n success: true,\n schema: schemaText,\n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n prompt,\n promptPath: getPromptPath()\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn)\n await conn.end().catch(() => {\n /* ignore */\n })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map((n) => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map((n) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = (await queryWithTimeout(pool, sql)) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return {\n success: false,\n ok: false,\n error: error instanceof Error ? error.message : '查询失败'\n }\n }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = [...payload.messages]\n const prompt = loadPromptFromDisk()\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, prompt) }\n const allMessages: Array<{\n role: string\n content: string\n tool_calls?: ToolCall[]\n tool_call_id?: string\n }> = [systemMessage, ...messages]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n const toolCallResults: Array<{\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', mergeReply(finalReply, accContent))\n }\n }\n })\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return {\n success: true,\n reply: finalReply || '未生成有效回复',\n toolCalls: toolCallResults\n }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', finalReply)\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try {\n parsedArgs = JSON.parse(toolCall.function.arguments || '{}')\n } catch {\n /* ignore */\n }\n\n const callRecord: {\n id: string\n name: string\n args: Record<string, unknown>\n result: Record<string, unknown>\n status: string\n errorMessage?: string\n } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs\n })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames)\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : 'AI 请求失败' }\n }\n })\n}\n"}, "4f4120945df2f9ccbe1f1887f2abd19364818d10": {"t": 1785759192.9631708, "n": 24271, "h": "11bf0a124d7b6503", "c": "/**\n * SQL 专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface AskAiPayload {\n messages: Array<{ role: string; content: string }>\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n return JSON.parse(readFileSync(configPath, 'utf-8'))\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction getPromptPath(): string {\n return join(getConfigDir(), 'sql-prompt.md')\n}\n\n// 这里的 SQL_PROMPT_TEMPLATE 由后续定义,这里使用 loadPromptFromDisk 需要放在后面或者先声明\n// 为避免作用域问题,稍后通过重构将其下移,或将 SQL_PROMPT_TEMPLATE 上移\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') { quote = char; continue }\n if (char === '[') { quote = ']'; continue }\n if (char === '(') { depth += 1; continue }\n if (char === ')') { depth = Math.max(0, depth - 1); continue }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') { quote = char; current += char; continue }\n if (char === '[') { quote = ']'; current += char; continue }\n if (char === '(') { depth += 1; current += char; continue }\n if (char === ')') { depth = Math.max(0, depth - 1); current += char; continue }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => { /* ignore */ })\n connectionPool = null\n }\n}\n\n// ============ 内置 Prompt 模板(基于 sql-prompt.md) ============\n\nconst SQL_PROMPT_TEMPLATE = `#### 1. 全局查询原则(必须优先套用)\n\n1. 先限定业务域:绝大多数查询都应先带 \\`center_code\\`,再按 \\`store_code/user_code\\` 收敛。\n2. 逻辑删除:项目框架默认会自动过滤 \\`mark=true\\`。AI 直写 SQL 时,建议显式加 \\`mark=1\\`(若表有该字段)。\n3. 商品可售不是看 \\`product_sku\\`:应以 \\`center_product.enable=1\\` 作为是否在当前配送中心可售的准入条件。\n4. 库存口径不是单表:常用可售库存来自 \\`location_batch_inventory.available_count\\`,并且常带 \\`sale_flag=1\\`、\\`shipper_code\\` 对齐。\n5. 价格口径优先级:门店成交价优先 \\`center_product.multiple_price[priceLevel]\\`,否则 \\`center_product.market_price\\`;不要直接拿 \\`product_sku.price/market_price\\` 当最终价。\n\n#### 2. 核心业务关系(查询时按这个思路 join)\n\n1. 商品主链路:\\`product_spu -> product_sku -> center_product\\`。\n2. 库存主链路:\\`center_product -> location_batch_inventory\\`(批次场景再接 \\`product_batch_info\\`)。\n3. 订单主链路:\\`daily_master_orders(主单) -> daily_orders(子单) -> daily_orders_detail(明细)\\`。\n4. 活动主链路:\\`product_activity_config -> activity_sku -> (product_activity_store/time_limit_activity_*)\\`。\n5. 门店与活动范围:\\`store_info.store_code\\` 是门店,\\`store_info.store_type\\` 常被当\"渠道门店编码\"。\n\n#### 3. 订单查询口径(最容易查错)\n\n1. 订单有两套状态,不可混用:\n - \\`daily_master_orders.order_status\\`:支付态(NOT_PAY/SUCCESS/CLOSED/REFUND...)\n - \\`daily_master_orders.status\\`:履约流转态(PAY_FINISH/STOCK_SERVICING/WAIT_DISTRIBUTION/.../RECEIVED/FINISH/CANCELED)\n2. 订单维度字段要分清:主单号 \\`master_order_code\\`,子单号 \\`order_code\\`。\n3. 预售订单与普通订单分表:预售主单在 \\`pre_daily_master_orders\\`。\n\n#### 4. SQL 生成约束建议\n\n1. 涉及\"商品可售/价格/库存\"的查询,必须从 \\`center_product\\` 起表,并携带 \\`center_code\\`。\n2. 涉及\"活动有效性\"的查询,必须同时判断活动状态、时间窗口、门店范围。\n3. 涉及\"订单列表或统计\"的查询,必须明确使用 \\`order_status\\` 还是 \\`status\\`,不得混淆。`\n\nfunction loadPromptFromDisk(): string {\n const promptPath = getPromptPath()\n if (!existsSync(promptPath)) {\n writeFileSync(promptPath, SQL_PROMPT_TEMPLATE, 'utf-8')\n return SQL_PROMPT_TEMPLATE\n }\n try {\n return readFileSync(promptPath, 'utf-8')\n } catch {\n return SQL_PROMPT_TEMPLATE\n }\n}\n\nfunction buildSystemPrompt(schema: string, prompt: string): string {\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n### 查询建议\n${prompt}\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\``\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description: '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string', description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。' },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: { type: 'array', items: { type: 'string' }, description: '要查询结构的多个表名列表。' },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n }\n ]\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing = toolCalls[idx] || {\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[] }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const request: OpenAI.Chat.ChatCompletionCreateParamsStreaming = {\n ...(payload as Omit<OpenAI.Chat.ChatCompletionCreateParamsStreaming, 'stream'>),\n stream: true\n }\n\n const stream = await openai.chat.completions.create(request)\n\n let content = ''\n const toolCalls: ToolCall[] = []\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = await pool.query(args.sql) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName ? [args.tableName] : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = await pool.query(sql) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema.split(/\\r?\\n/)\n .map(line => line.trim())\n .filter(Boolean)\n .map(line => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn) await conn.end().catch(() => { /* ignore */ })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const prompt = loadPromptFromDisk()\n return { \n config, \n schema, \n schemaPath: getSchemaPath(), \n prompt, \n promptPath: getPromptPath() \n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = await conn.query(\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n ) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map(row => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n \n const prompt = loadPromptFromDisk()\n\n return { \n success: true, \n schema: schemaText, \n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n prompt,\n promptPath: getPromptPath()\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn) await conn.end().catch(() => { /* ignore */ })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map(n => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map(n => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = await pool.query(sql) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = await pool.query(sql) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return { success: false, ok: false, error: error instanceof Error ? error.message : '查询失败' }\n }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = [...payload.messages]\n const prompt = loadPromptFromDisk()\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, prompt) }\n const allMessages: Array<{ role: string; content: string; tool_calls?: ToolCall[]; tool_call_id?: string }> = [\n systemMessage,\n ...messages\n ]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n const toolCallResults: Array<{ id: string; name: string; args: Record<string, unknown>; result: Record<string, unknown>; status: string; errorMessage?: string }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', mergeReply(finalReply, accContent))\n }\n }\n })\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return { success: true, reply: finalReply || '未生成有效回复', toolCalls: toolCallResults }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', finalReply)\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try { parsedArgs = JSON.parse(toolCall.function.arguments || '{}') } catch { /* ignore */ }\n\n const callRecord: { id: string; name: string; args: Record<string, unknown>; result: Record<string, unknown>; status: string; errorMessage?: string } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', { id: toolCall.id, name: toolCall.function.name, args: parsedArgs })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames)\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : 'AI 请求失败' }\n }\n })\n}\n"}, "ab374ff7a57282ae0adc97f88942fefecab9c3ab": {"t": 1785759194.439713, "n": 24116, "h": "0b4721b5725dc2c7", "c": "/**\n * SQL 专家服务\n * 主进程侧:数据库连接管理、AI 请求、SQL 校验、Schema 动态生成\n */\nimport { ipcMain, app } from 'electron'\nimport * as mysql from 'mysql2/promise'\nimport { join } from 'path'\nimport { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'\nimport OpenAI from 'openai'\n\n// ============ 类型定义 ============\n\ninterface DbConfig {\n host: string\n port: number\n user: string\n password: string\n database: string\n}\n\ninterface AiConfig {\n url: string\n apiKey: string\n model: string\n}\n\ninterface SqlExpertConfig {\n db: DbConfig\n ai: AiConfig\n}\n\ninterface AskAiPayload {\n messages: Array<{ role: string; content: string }>\n schema: string\n tools?: AiToolDefinition[]\n toolChoice?: string\n}\n\ninterface AiToolDefinition {\n type: string\n function: {\n name: string\n description: string\n parameters: Record<string, unknown>\n }\n}\n\ninterface ToolCall {\n id: string\n type: string\n function: { name: string; arguments: string }\n}\n\n// ============ 全局状态 ============\n\nlet connectionPool: mysql.Pool | null = null\nlet cachedSchema = ''\n\n// ============ 配置持久化 ============\n\nfunction getConfigDir(): string {\n const dir = join(app.getPath('userData'), 'sql-expert')\n if (!existsSync(dir)) {\n mkdirSync(dir, { recursive: true })\n }\n return dir\n}\n\nfunction getConfigPath(): string {\n return join(getConfigDir(), 'config.json')\n}\n\nfunction getSchemaPath(): string {\n return join(getConfigDir(), 'schema.txt')\n}\n\nfunction loadConfigFromDisk(): SqlExpertConfig | null {\n const configPath = getConfigPath()\n if (!existsSync(configPath)) return null\n try {\n return JSON.parse(readFileSync(configPath, 'utf-8'))\n } catch {\n return null\n }\n}\n\nfunction saveConfigToDisk(config: SqlExpertConfig): void {\n writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), 'utf-8')\n}\n\nfunction saveSchemaToDisk(schema: string): void {\n writeFileSync(getSchemaPath(), schema, 'utf-8')\n}\n\nfunction loadSchemaFromDisk(): string {\n const schemaPath = getSchemaPath()\n if (!existsSync(schemaPath)) return ''\n try {\n return readFileSync(schemaPath, 'utf-8')\n } catch {\n return ''\n }\n}\n\nfunction getPromptPath(): string {\n return join(getConfigDir(), 'sql-prompt.md')\n}\n\n// 这里的 SQL_PROMPT_TEMPLATE 由后续定义,这里使用 loadPromptFromDisk 需要放在后面或者先声明\n// 为避免作用域问题,稍后通过重构将其下移,或将 SQL_PROMPT_TEMPLATE 上移\n\n// ============ SQL 校验(复用 agentRuntime.ts 核心逻辑) ============\n\nfunction extractSelectClause(sql: string): string {\n const lowerSql = sql.toLowerCase()\n let selectIndex = -1\n let fromIndex = -1\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < sql.length; index += 1) {\n const char = sql[index]\n const previousChar = index > 0 ? sql[index - 1] : ''\n\n if (quote) {\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') { quote = char; continue }\n if (char === '[') { quote = ']'; continue }\n if (char === '(') { depth += 1; continue }\n if (char === ')') { depth = Math.max(0, depth - 1); continue }\n\n if (depth === 0 && selectIndex === -1 && lowerSql.startsWith('select ', index)) {\n selectIndex = index + 6\n index += 5\n continue\n }\n\n if (depth === 0 && selectIndex !== -1 && lowerSql.startsWith(' from ', index)) {\n fromIndex = index\n break\n }\n }\n\n if (selectIndex === -1 || fromIndex === -1 || fromIndex <= selectIndex) return ''\n return sql.slice(selectIndex, fromIndex).trim()\n}\n\nfunction splitTopLevelCsv(value: string): string[] {\n const items: string[] = []\n let current = ''\n let depth = 0\n let quote: string | null = null\n\n for (let index = 0; index < value.length; index += 1) {\n const char = value[index]\n const previousChar = index > 0 ? value[index - 1] : ''\n\n if (quote) {\n current += char\n if (char === quote && previousChar !== '\\\\') quote = null\n continue\n }\n\n if (char === \"'\" || char === '\"' || char === '`') { quote = char; current += char; continue }\n if (char === '[') { quote = ']'; current += char; continue }\n if (char === '(') { depth += 1; current += char; continue }\n if (char === ')') { depth = Math.max(0, depth - 1); current += char; continue }\n if (char === ',' && depth === 0) {\n if (current.trim()) items.push(current.trim())\n current = ''\n continue\n }\n current += char\n }\n\n if (current.trim()) items.push(current.trim())\n return items\n}\n\nfunction validateSql(sql: string, options: { allowSystemSchema?: boolean } = {}): void {\n if (typeof sql !== 'string' || !sql.trim()) throw new Error('SQL 不能为空')\n\n const compactSql = sql.trim().replace(/\\s+/g, ' ')\n if (compactSql.includes(';')) throw new Error('只允许单条 SQL')\n\n const lowerSql = compactSql.toLowerCase()\n if (!/^(select|with)\\b/.test(lowerSql)) throw new Error('只允许 SELECT 查询')\n\n const forbiddenKeywords =\n /\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|call|execute|exec|upsert|outfile|dumpfile|load\\s+data|into\\s+outfile|into\\s+dumpfile|lock\\s+tables|for\\s+update)\\b/i\n if (forbiddenKeywords.test(compactSql)) throw new Error('仅允许只读 SQL')\n\n if (!options.allowSystemSchema && /\\binformation_schema\\b|\\bmysql\\b\\./i.test(compactSql)) {\n throw new Error('不允许访问系统库')\n }\n\n if (/\\bselect\\s+\\*/i.test(compactSql)) {\n throw new Error('不允许 SELECT *,请为每个输出列显式使用 AS')\n }\n\n const selectClause = extractSelectClause(compactSql)\n if (!selectClause) throw new Error('无法解析 SELECT 子句')\n\n const selectExpressions = splitTopLevelCsv(selectClause)\n if (!selectExpressions.length) throw new Error('SELECT 子句不能为空')\n\n for (const expression of selectExpressions) {\n if (/^\\s*\\*\\s*$/i.test(expression) || /\\.\\s*\\*\\s*$/i.test(expression)) {\n throw new Error('不允许使用通配列,请为每个输出列显式使用 AS')\n }\n if (!/\\bas\\s+[\"'`[\\]A-Za-z0-9_\\u4e00-\\u9fa5]+\\s*$/i.test(expression.trim())) {\n throw new Error('每个输出列都必须以 AS 别名结尾')\n }\n }\n}\n\n// ============ 数据库连接管理 ============\n\nasync function createPool(config: DbConfig): Promise<mysql.Pool> {\n return mysql.createPool({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n waitForConnections: true,\n connectionLimit: 5,\n queueLimit: 0,\n connectTimeout: 10000\n })\n}\n\nasync function ensurePool(config?: DbConfig): Promise<mysql.Pool> {\n if (connectionPool) return connectionPool\n\n const savedConfig = config || loadConfigFromDisk()?.db\n if (!savedConfig) throw new Error('未配置数据库连接')\n\n connectionPool = await createPool(savedConfig)\n return connectionPool\n}\n\nasync function destroyPool(): Promise<void> {\n if (connectionPool) {\n await connectionPool.end().catch(() => { /* ignore */ })\n connectionPool = null\n }\n}\n\n// ============ 内置 Prompt 模板(基于 sql-prompt.md) ============\n\nconst SQL_PROMPT_TEMPLATE = `#### 1. 全局查询原则(必须优先套用)\n\n1. 先限定业务域:绝大多数查询都应先带 \\`center_code\\`,再按 \\`store_code/user_code\\` 收敛。\n2. 逻辑删除:项目框架默认会自动过滤 \\`mark=true\\`。AI 直写 SQL 时,建议显式加 \\`mark=1\\`(若表有该字段)。\n3. 商品可售不是看 \\`product_sku\\`:应以 \\`center_product.enable=1\\` 作为是否在当前配送中心可售的准入条件。\n4. 库存口径不是单表:常用可售库存来自 \\`location_batch_inventory.available_count\\`,并且常带 \\`sale_flag=1\\`、\\`shipper_code\\` 对齐。\n5. 价格口径优先级:门店成交价优先 \\`center_product.multiple_price[priceLevel]\\`,否则 \\`center_product.market_price\\`;不要直接拿 \\`product_sku.price/market_price\\` 当最终价。\n\n#### 2. 核心业务关系(查询时按这个思路 join)\n\n1. 商品主链路:\\`product_spu -> product_sku -> center_product\\`。\n2. 库存主链路:\\`center_product -> location_batch_inventory\\`(批次场景再接 \\`product_batch_info\\`)。\n3. 订单主链路:\\`daily_master_orders(主单) -> daily_orders(子单) -> daily_orders_detail(明细)\\`。\n4. 活动主链路:\\`product_activity_config -> activity_sku -> (product_activity_store/time_limit_activity_*)\\`。\n5. 门店与活动范围:\\`store_info.store_code\\` 是门店,\\`store_info.store_type\\` 常被当\"渠道门店编码\"。\n\n#### 3. 订单查询口径(最容易查错)\n\n1. 订单有两套状态,不可混用:\n - \\`daily_master_orders.order_status\\`:支付态(NOT_PAY/SUCCESS/CLOSED/REFUND...)\n - \\`daily_master_orders.status\\`:履约流转态(PAY_FINISH/STOCK_SERVICING/WAIT_DISTRIBUTION/.../RECEIVED/FINISH/CANCELED)\n2. 订单维度字段要分清:主单号 \\`master_order_code\\`,子单号 \\`order_code\\`。\n3. 预售订单与普通订单分表:预售主单在 \\`pre_daily_master_orders\\`。\n\n#### 4. SQL 生成约束建议\n\n1. 涉及\"商品可售/价格/库存\"的查询,必须从 \\`center_product\\` 起表,并携带 \\`center_code\\`。\n2. 涉及\"活动有效性\"的查询,必须同时判断活动状态、时间窗口、门店范围。\n3. 涉及\"订单列表或统计\"的查询,必须明确使用 \\`order_status\\` 还是 \\`status\\`,不得混淆。`\n\nfunction loadPromptFromDisk(): string {\n const promptPath = getPromptPath()\n if (!existsSync(promptPath)) {\n writeFileSync(promptPath, SQL_PROMPT_TEMPLATE, 'utf-8')\n return SQL_PROMPT_TEMPLATE\n }\n try {\n return readFileSync(promptPath, 'utf-8')\n } catch {\n return SQL_PROMPT_TEMPLATE\n }\n}\n\nfunction buildSystemPrompt(schema: string, prompt: string): string {\n return `你是一个专业、严谨的企业数据查询助手,服务于本系统的真实业务用户。\n你只能通过工具访问数据库。query_database 工具最多返回 10 行样例;describe_table_schema 工具会返回完整字段清单。\n你的目标是在合法、合规、最小必要权限、以及结果可解释的前提下,高效帮助用户查询、聚合和分析数据。\n\n### 当前环境上下文\n- 当前系统时间:${new Date().toLocaleString()},请严格基于此时间理解\"今天\"\"最近一周\"等动态时间概念。\n\n### 核心工作流与规范\n1. 当用户需求模糊、缺少必要筛选条件、或字段含义不明确时,先追问澄清,不要直接查询数据库。\n2. 如需数据库数据,优先调用 query_database 工具,不要直接臆造结果。\n3. 当你只知道表名含义、不确定字段名或字段口径时,先调用 describe_table_schema 工具查询表结构,再决定后续 SQL。该工具支持一次查询多个表。\n4. SQL 只能使用只读查询语句:SELECT 或 WITH ... SELECT。\n5. 输出列必须使用 AS 指定清晰的别名,不允许 SELECT *。\n6. 若工具结果被截断,请在最终答复里明确说明,并判断是否建议用户缩小范围。\n7. 最终答复必须包含:结论、统计口径/筛选条件、是否截断。\n\n### 绝对禁区\n1. 严禁生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE、CREATE、GRANT 等修改性语句。\n2. 严禁自行编写查询 information_schema 或 mysql 等系统库的 SQL;如需字段信息,只能调用 describe_table_schema 工具。\n\n### 查询建议\n${prompt}\n\n### 数据库表清单(仅含表名和中文语义)\n\\`\\`\\`text\n${schema}\n\\`\\`\\``\n}\n\nfunction getTools(): AiToolDefinition[] {\n return [\n {\n type: 'function',\n function: {\n name: 'query_database',\n description: '执行只读 SQL 查询。仅允许 SELECT 或 WITH...SELECT,且输出列必须使用 AS 指定表头。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n sql: { type: 'string', description: '待读取数据库的 SQL。必须是只读查询,并为输出列使用 AS 指定表头。' },\n reason: { type: 'string', description: '本次查询目的和预期用途。' }\n },\n required: ['sql', 'reason']\n }\n }\n },\n {\n type: 'function',\n function: {\n name: 'describe_table_schema',\n description: '查询一个或多个表的字段结构。传表名或表名数组,工具会返回完整字段清单。',\n parameters: {\n type: 'object',\n additionalProperties: false,\n properties: {\n tableName: { type: 'string', description: '要查询结构的单个表名。' },\n tableNames: { type: 'array', items: { type: 'string' }, description: '要查询结构的多个表名列表。' },\n reason: { type: 'string', description: '为什么需要查看这张表的结构。' }\n },\n anyOf: [{ required: ['tableName'] }, { required: ['tableNames'] }]\n }\n }\n }\n ]\n}\n\n// ============ AI 请求(流式) ============\n\ninterface StreamCallbacks {\n onContent?: (accumulatedContent: string) => void\n}\n\nfunction upsertToolCall(toolCalls: ToolCall[], delta: any): void {\n const idx = typeof delta?.index === 'number' ? delta.index : toolCalls.length\n const existing = toolCalls[idx] || {\n id: delta?.id || `tool_call_${idx}`,\n type: 'function',\n function: { name: '', arguments: '' }\n } as ToolCall\n\n if (delta?.id) existing.id = delta.id\n if (delta?.function?.name) existing.function.name += delta.function.name\n if (delta?.function?.arguments) existing.function.arguments += delta.function.arguments\n toolCalls[idx] = existing\n}\n\nasync function callAiApi(\n aiConfig: AiConfig,\n payload: any,\n callbacks: StreamCallbacks = {}\n): Promise<{ content: string; toolCalls?: ToolCall[] }> {\n const openai = new OpenAI({\n apiKey: aiConfig.apiKey,\n baseURL: aiConfig.url\n })\n\n const stream = await openai.chat.completions.create({ ...payload, stream: true })\n\n let content = ''\n const toolCalls: ToolCall[] = []\n\n for await (const chunk of stream) {\n const delta = (chunk as any).choices?.[0]?.delta\n if (!delta) continue\n\n if (typeof delta.content === 'string' && delta.content) {\n content += delta.content\n callbacks.onContent?.(content)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n delta.tool_calls.forEach((tc: any) => upsertToolCall(toolCalls, tc))\n }\n }\n\n return {\n content,\n toolCalls: toolCalls.length ? toolCalls : undefined\n }\n}\n\n// ============ 工具调度 ============\n\nconst TOOL_RESULT_ROW_LIMIT = 10\n\nasync function dispatchToolCall(\n pool: mysql.Pool,\n toolCall: ToolCall,\n schemaTableNames: Set<string>\n): Promise<Record<string, unknown>> {\n const args = JSON.parse(toolCall.function.arguments || '{}')\n\n if (toolCall.function.name === 'query_database') {\n validateSql(args.sql)\n const [rows] = await pool.query(args.sql) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n ok: true,\n reason: args.reason,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n }\n\n if (toolCall.function.name === 'describe_table_schema') {\n const tableNames: string[] = Array.isArray(args.tableNames)\n ? args.tableNames\n : args.tableName ? [args.tableName] : []\n\n const validNames = tableNames\n .map((n: string) => String(n).trim())\n .filter((n: string) => n && schemaTableNames.has(n))\n\n if (!validNames.length) throw new Error('至少需要提供一个合法表名')\n\n const quoted = validNames.map((n: string) => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = await pool.query(sql) as [Array<Record<string, unknown>>, unknown]\n return {\n ok: true,\n reason: args.reason || `查询表 ${validNames.join('、')} 的结构`,\n truncated: false,\n totalRows: rows.length,\n returnedRows: rows.length,\n rows\n }\n }\n\n return { ok: false, error: `未知工具: ${toolCall.function.name}` }\n}\n\n// ============ 解析 schema 表名集合 ============\n\nfunction getSchemaTableNames(schema: string): Set<string> {\n return new Set(\n schema.split(/\\r?\\n/)\n .map(line => line.trim())\n .filter(Boolean)\n .map(line => line.match(/^(\\S+)/)?.[1] || '')\n .filter(Boolean)\n )\n}\n\n// ============ IPC 注册 ============\n\nexport function setupSqlExpert(): void {\n // 测试数据库连接\n ipcMain.handle('sql-expert:test-db', async (_event, config: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n await conn.ping()\n return { success: true, message: '连接成功' }\n } catch (error) {\n return { success: false, message: error instanceof Error ? error.message : '连接失败' }\n } finally {\n if (conn) await conn.end().catch(() => { /* ignore */ })\n }\n })\n\n // 保存配置\n ipcMain.handle('sql-expert:save-config', async (_event, config: SqlExpertConfig) => {\n try {\n saveConfigToDisk(config)\n // 配置变更后重建连接池\n await destroyPool()\n return { success: true }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '保存失败' }\n }\n })\n\n // 加载配置\n ipcMain.handle('sql-expert:load-config', async () => {\n const config = loadConfigFromDisk()\n const schema = loadSchemaFromDisk()\n const prompt = loadPromptFromDisk()\n return { \n config, \n schema, \n schemaPath: getSchemaPath(), \n prompt, \n promptPath: getPromptPath() \n }\n })\n\n // 动态加载 Schema(从数据库 information_schema 查询)\n ipcMain.handle('sql-expert:load-schema', async (_event, dbConfig?: DbConfig) => {\n let conn: mysql.Connection | null = null\n try {\n const config = dbConfig || loadConfigFromDisk()?.db\n if (!config) throw new Error('未配置数据库连接')\n if (!config.database) throw new Error('请先填写具体的要查询的数据库名称')\n\n conn = await mysql.createConnection({\n host: config.host,\n port: config.port,\n user: config.user,\n password: config.password,\n database: config.database,\n connectTimeout: 10000\n })\n\n const [rows] = await conn.query(\n 'SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',\n [config.database]\n ) as [Array<{ TABLE_NAME: string; TABLE_COMMENT: string }>, unknown]\n\n // 生成与 daily_orange_schema.txt 同格式的文本\n const schemaText = rows\n .map(row => `${row.TABLE_NAME} ${row.TABLE_COMMENT || ''}`.trim())\n .join('\\n\\n')\n\n cachedSchema = schemaText\n saveSchemaToDisk(schemaText)\n \n const prompt = loadPromptFromDisk()\n\n return { \n success: true, \n schema: schemaText, \n tableCount: rows.length,\n schemaPath: getSchemaPath(),\n prompt,\n promptPath: getPromptPath()\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '加载表结构失败' }\n } finally {\n if (conn) await conn.end().catch(() => { /* ignore */ })\n }\n })\n\n // 查询指定表的字段结构\n ipcMain.handle('sql-expert:describe-table', async (_event, tableNames: string[]) => {\n try {\n const pool = await ensurePool()\n const names = (Array.isArray(tableNames) ? tableNames : [tableNames])\n .map(n => String(n).trim())\n .filter(Boolean)\n\n if (!names.length) throw new Error('请传入至少一个表名')\n\n const quoted = names.map(n => `'${n.replace(/'/g, \"''\")}'`).join(', ')\n const sql = [\n 'SELECT TABLE_NAME AS 表名, ORDINAL_POSITION AS 字段顺序,',\n ' COLUMN_NAME AS 字段名, COLUMN_TYPE AS 字段类型,',\n ' IS_NULLABLE AS 是否可空, COLUMN_DEFAULT AS 默认值,',\n ' COLUMN_KEY AS 索引类型, EXTRA AS 额外信息, COLUMN_COMMENT AS 字段注释',\n 'FROM information_schema.COLUMNS',\n 'WHERE TABLE_SCHEMA = DATABASE()',\n ` AND TABLE_NAME IN (${quoted})`,\n 'ORDER BY TABLE_NAME, ORDINAL_POSITION'\n ].join('\\n')\n\n const [rows] = await pool.query(sql) as [Array<Record<string, unknown>>, unknown]\n return { success: true, rows }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : '查询表结构失败' }\n }\n })\n\n // 执行只读 SQL\n ipcMain.handle('sql-expert:execute-sql', async (_event, sql: string) => {\n try {\n validateSql(sql)\n const pool = await ensurePool()\n const [rows] = await pool.query(sql) as [Array<Record<string, unknown>>, unknown]\n const totalRows = rows.length\n const truncated = totalRows > TOOL_RESULT_ROW_LIMIT\n return {\n success: true,\n ok: true,\n truncated,\n totalRows,\n returnedRows: truncated ? TOOL_RESULT_ROW_LIMIT : totalRows,\n rows: truncated ? rows.slice(0, TOOL_RESULT_ROW_LIMIT) : rows\n }\n } catch (error) {\n return { success: false, ok: false, error: error instanceof Error ? error.message : '查询失败' }\n }\n })\n\n // AI 对话(支持多轮工具调用,流式推送进度)\n ipcMain.handle('sql-expert:ask-ai', async (event, payload: AskAiPayload) => {\n const sender = event.sender\n try {\n const savedConfig = loadConfigFromDisk()\n if (!savedConfig?.ai?.url || !savedConfig?.ai?.apiKey) {\n throw new Error('请先配置 AI 模型参数')\n }\n\n const schema = payload.schema || cachedSchema || loadSchemaFromDisk()\n if (!schema) throw new Error('请先加载数据库表结构')\n\n const schemaTableNames = getSchemaTableNames(schema)\n const pool = await ensurePool()\n const messages = [...payload.messages]\n const prompt = loadPromptFromDisk()\n const systemMessage = { role: 'system', content: buildSystemPrompt(schema, prompt) }\n const allMessages: Array<{ role: string; content: string; tool_calls?: ToolCall[]; tool_call_id?: string }> = [\n systemMessage,\n ...messages\n ]\n\n const MAX_ROUNDS = 15\n let finalReply = ''\n const toolCallResults: Array<{ id: string; name: string; args: Record<string, unknown>; result: Record<string, unknown>; status: string; errorMessage?: string }> = []\n\n const mergeReply = (prev: string, next: string) => {\n const p = prev.trimEnd()\n const n = next.trim()\n if (!n) return p\n if (!p) return n\n return `${p}\\n\\n${n}`\n }\n\n for (let round = 0; round < MAX_ROUNDS; round++) {\n const finalPayload = {\n model: savedConfig.ai.model,\n temperature: 0.1,\n messages: allMessages,\n tools: getTools(),\n tool_choice: 'auto'\n } as any\n\n const aiResponse = await callAiApi(savedConfig.ai, finalPayload, {\n onContent: (accContent) => {\n // 实时推送 AI 回复内容给渲染进程\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', mergeReply(finalReply, accContent))\n }\n }\n })\n\n if (aiResponse.content) {\n finalReply = mergeReply(finalReply, aiResponse.content)\n }\n\n if (!aiResponse.toolCalls?.length) {\n return { success: true, reply: finalReply || '未生成有效回复', toolCalls: toolCallResults }\n }\n\n // 将 assistant 消息(含 tool_calls)加入历史\n allMessages.push({\n role: 'assistant',\n content: aiResponse.content || '',\n tool_calls: aiResponse.toolCalls\n })\n\n // 执行工具调用\n for (const toolCall of aiResponse.toolCalls) {\n finalReply = mergeReply(finalReply, `:::tool:${toolCall.id}:::`)\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-content', finalReply)\n }\n\n let toolResult: Record<string, unknown>\n let parsedArgs: Record<string, unknown> = {}\n try { parsedArgs = JSON.parse(toolCall.function.arguments || '{}') } catch { /* ignore */ }\n\n const callRecord: { id: string; name: string; args: Record<string, unknown>; result: Record<string, unknown>; status: string; errorMessage?: string } = {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n result: {},\n status: 'running'\n }\n toolCallResults.push(callRecord)\n\n // 通知渲染进程:工具调用开始\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-start', { id: toolCall.id, name: toolCall.function.name, args: parsedArgs })\n }\n\n try {\n toolResult = await dispatchToolCall(pool, toolCall, schemaTableNames)\n callRecord.status = (toolResult.ok as boolean) ? 'success' : 'error'\n callRecord.result = { ...toolResult, rows: undefined }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n toolResult = { ok: false, error: message }\n callRecord.status = 'error'\n callRecord.errorMessage = message\n }\n\n // 通知渲染进程:工具调用完成\n if (!sender.isDestroyed()) {\n sender.send('sql-expert:ai-tool-done', {\n id: toolCall.id,\n name: toolCall.function.name,\n args: parsedArgs,\n status: callRecord.status,\n result: callRecord.result,\n errorMessage: callRecord.errorMessage\n })\n }\n\n allMessages.push({\n role: 'tool' as string,\n content: JSON.stringify(toolResult, null, 2),\n tool_call_id: toolCall.id\n })\n }\n }\n\n return {\n success: true,\n reply: '本轮查询已达到工具调用上限。请进一步明确筛选条件、缩小时间范围,或分步骤提问。',\n toolCalls: toolCallResults\n }\n } catch (error) {\n return { success: false, error: error instanceof Error ? error.message : 'AI 请求失败' }\n }\n })\n}\n"}}}}