1 line
343 KiB
JSON
1 line
343 KiB
JSON
{"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"}}}} |