import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store"; import { decodeSecret } from "./secrets"; /** C2:桌面 sidecar 注入的运行时配置(缺省时行为与现状一致) */ declare global { interface Window { __OPSXDB_API__?: string; __OPSXDB_TOKEN__?: string; __OPSXDB_BOOT_ERROR__?: string; } } function injectedApi(): string | undefined { if (typeof window === "undefined") return undefined; const v = window.__OPSXDB_API__; return v ? v.replace(/\/$/, "") : undefined; } function injectedToken(): string { if (typeof window === "undefined") return ""; return window.__OPSXDB_TOKEN__ ?? ""; } /** 开发态走 Vite 代理(空串);桌面生产包优先 sidecar 注入地址,回落默认 8788。 */ function resolveApiBase(): string { const injected = injectedApi(); if (injected) return injected; const fromEnv = (import.meta as { env?: Record }).env?.VITE_OPSXDB_API; if (fromEnv) return fromEnv.replace(/\/$/, ""); if (typeof window === "undefined") return ""; const { protocol, hostname, port } = window.location; if (protocol === "tauri:" || hostname === "tauri.localhost" || hostname === "localhost" && port === "") { return "http://127.0.0.1:8788"; } return ""; } const API = resolveApiBase(); /** C2:token 存在时统一带 Authorization 头;token 为空(本机 dev)行为不变 */ function authHeaders(): Record { const token = injectedToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } async function post(path: string, body: unknown, signal?: AbortSignal): Promise { const res = await fetch(`${API}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body), signal, }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error((data as { error?: string }).error || res.statusText || "request failed"); } return data as T; } async function get(path: string, signal?: AbortSignal): Promise { const res = await fetch(`${API}${path}`, { headers: { ...authHeaders() }, signal }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error((data as { error?: string }).error || res.statusText || "request failed"); } return data as T; } export type ConnBody = { driver?: string; host?: string; port?: number; user?: string; password?: string; database?: string; service?: string; ssl?: boolean; sslMode?: string; sslCA?: string; sslCert?: string; sslKey?: string; sessionId?: string; sql?: string; table?: string; limit?: number; /** E1:执行请求标识,配合 POST /api/db/cancel 取消在途语句 */ requestId?: string; /** E2:单语句执行超时(毫秒),0/缺省 = 服务端默认不额外限制 */ timeoutMs?: number; /** E6:对象搜索关键字 */ keyword?: string; /** E7:服务端导出格式 */ format?: string; /** C5:结果分页,跳过前 offset 行 */ offset?: number; /** C1:前端已完成危险语句确认后重发时置 true */ confirmDanger?: boolean; key?: string; pattern?: string; keyCursor?: string; cursor?: number; redisMode?: string; redisNodes?: string; masterName?: string; sentinelPassword?: string; sshEnabled?: boolean; sshHost?: string; sshPort?: number; sshUser?: string; sshAuth?: string; sshPassword?: string; sshPrivateKey?: string; sshPassphrase?: string; }; export function fullConnPayload(c: SavedConnection): ConnBody { const sslOn = !!(c.ssl || (c.sslMode && c.sslMode !== "disable")); return { driver: c.driver, host: c.host, port: c.port, user: c.user, password: decodeSecret(c.password), database: c.database, service: c.service, ssl: sslOn, sslMode: sslOn ? (c.sslMode && c.sslMode !== "disable" ? c.sslMode : "require") : "disable", sslCA: c.sslCA || "", sslCert: c.sslCert || "", sslKey: c.sslKey || "", redisMode: c.redisMode || "standalone", redisNodes: c.redisNodes || "", masterName: c.masterName || "", sentinelPassword: c.sentinelPassword ? decodeSecret(c.sentinelPassword) : "", sshEnabled: !!c.sshEnabled, sshHost: c.sshHost, sshPort: c.sshPort, sshUser: c.sshUser, sshAuth: c.sshAuth, sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "", sshPrivateKey: c.sshPrivateKey, sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "", }; } export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Partial): ConnBody { return { sessionId, database: extra?.database ?? c.database, sql: extra?.sql, table: extra?.table, limit: extra?.limit, offset: extra?.offset, confirmDanger: extra?.confirmDanger, key: extra?.key, pattern: extra?.pattern, keyCursor: extra?.keyCursor, service: extra?.service ?? c.service, requestId: extra?.requestId, timeoutMs: extra?.timeoutMs, keyword: extra?.keyword, format: extra?.format, }; } export async function testConnection(c: SavedConnection) { return post<{ ok: boolean; message?: string }>("/api/db/test-connection", fullConnPayload(c)); } export async function openSession(c: SavedConnection) { return post<{ ok: boolean; sessionId: string; message?: string }>("/api/db/open", fullConnPayload(c)); } export async function closeSession(sessionId: string) { return post<{ ok: boolean }>("/api/db/close", { sessionId }); } function body(c: SavedConnection, sessionId: string | undefined, extra?: Partial): ConnBody { if (sessionId) return sessionPayload(c, sessionId, extra); return { ...fullConnPayload(c), ...extra }; } export async function listDatabases(c: SavedConnection, sessionId?: string) { return post<{ databases: string[] }>("/api/db/databases", body(c, sessionId)); } /** * C8:schema 缓存 —— connId → database → table → columns。 * listTables/listColumns 成功时写入,供 SqlEditor 补全与 AiPanel 上下文注入共用。 */ const schemaCache = new Map>>(); function cacheTables(connId: string, db: string, tables: string[]) { let dbMap = schemaCache.get(connId); if (!dbMap) { dbMap = new Map(); schemaCache.set(connId, dbMap); } let tblMap = dbMap.get(db); if (!tblMap) { tblMap = new Map(); dbMap.set(db, tblMap); } for (const t of tables) { if (!tblMap.has(t)) tblMap.set(t, []); } } function cacheColumns(connId: string, db: string, table: string, columns: ColumnInfo[]) { let dbMap = schemaCache.get(connId); if (!dbMap) { dbMap = new Map(); schemaCache.set(connId, dbMap); } let tblMap = dbMap.get(db); if (!tblMap) { tblMap = new Map(); dbMap.set(db, tblMap); } tblMap.set(table, columns); } /** 连接关闭/删除后丢弃其缓存,避免跨连接串数据 */ export function dropSchemaCache(connId: string) { schemaCache.delete(connId); } /** SqlEditor 补全数据源:已加载的表与列 */ export function getCachedSchema( connId: string | null | undefined, ): Map> | null { if (!connId) return null; return schemaCache.get(connId) ?? null; } /** * AiPanel 上下文注入:把已加载的库表列拼成文本,按 connId 隔离,截断 4000 字符。 * 优先输出当前库,其余库只列库.表名。 */ export function schemaContextFor(connId: string | null | undefined, currentDb?: string): string { const dbMap = connId ? schemaCache.get(connId) : undefined; if (!dbMap || dbMap.size === 0) return ""; const MAX = 4000; const lines: string[] = []; const dbs = [...dbMap.keys()].sort((a, b) => { if (a === currentDb) return -1; if (b === currentDb) return 1; return a.localeCompare(b); }); for (const db of dbs) { const tblMap = dbMap.get(db)!; for (const [table, cols] of tblMap) { const colText = cols.length ? `(${cols.map((c) => `${c.name} ${c.type}`.trim()).join(", ")})` : ""; lines.push(`${db}.${table}${colText}`); if (lines.join("\n").length > MAX) { return lines.join("\n").slice(0, MAX); } } } return lines.join("\n").slice(0, MAX); } export async function listTables(c: SavedConnection, database: string, sessionId?: string) { const res = await post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database })); if (Array.isArray(res.tables)) cacheTables(c.id, database, res.tables); return res; } export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) { const res = await post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table })); if (Array.isArray(res.columns)) cacheColumns(c.id, database, table, res.columns); return res; } /** E1:最近一次 execSql 的 requestId,供 cancelExec() 取消在途语句 */ let lastExecRequestId: string | undefined; export async function execSql( c: SavedConnection, sql: string, opts?: { database?: string; sessionId?: string; limit?: number; offset?: number; confirmDanger?: boolean; /** E2:单语句执行超时(毫秒),0/缺省 = 不额外限制 */ timeoutMs?: number; signal?: AbortSignal; }, ) { // E1:每次执行生成独立 requestId(模块级记录最后一次),取消端点按 id 精准取消 const requestId = crypto.randomUUID(); lastExecRequestId = requestId; return post( "/api/db/exec", body(c, opts?.sessionId, { sql, database: opts?.database || c.database, limit: opts?.limit, offset: opts?.offset, confirmDanger: opts?.confirmDanger, requestId, timeoutMs: opts?.timeoutMs, }), opts?.signal, ); } /** * E1:取消最近一次执行 —— fire-and-forget 调 POST /api/db/cancel。 * 与前端 abort 互补:服务端收到取消后释放连接/杀查询,错误一律吞掉。 */ export function cancelExec(): void { const rid = lastExecRequestId; if (!rid) return; void fetch(`${API}/api/db/cancel`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ requestId: rid }), }).catch(() => { /* 取消失败不影响本地中止流程 */ }); } /** E5:查看表/视图 DDL(方言差异由服务端处理) */ export async function getDdl( c: SavedConnection, table: string, opts?: { database?: string; sessionId?: string }, ) { return post<{ ddl?: string; error?: string }>( "/api/db/ddl", body(c, opts?.sessionId, { table, database: opts?.database || c.database }), ); } export type SearchObjectHit = { schema: string; name: string; type: "table" | "view" }; /** E6:跨库对象搜索(服务端 LIKE,大小写策略由驱动决定) */ export async function searchObjects( c: SavedConnection, keyword: string, opts?: { database?: string; sessionId?: string }, ) { return post<{ results: SearchObjectHit[]; error?: string }>( "/api/db/search", body(c, opts?.sessionId, { keyword, database: opts?.database || c.database }), ); } export type ExportRequest = { format: "csv" | "json" | "insert"; /** 自定义查询导出(单条 SELECT);命中危险/非 SELECT 时服务端返回 needConfirm */ sql?: string; /** 整表导出 */ table?: string; limit?: number; database?: string; sessionId?: string; /** C1:危险确认二段流重发时置 true */ confirmDanger?: boolean; }; /** E7 服务端导出失败:Error 上挂 needConfirm,由调用侧弹二段确认后重发 */ export type ExportError = Error & { needConfirm?: boolean }; /** * E7:服务端导出 —— 文件流下载(Content-Disposition attachment)。 * 返回 blob 与解析出的文件名;失败时抛 ExportError(needConfirm=true 表示需二段确认)。 */ export async function exportData(c: SavedConnection, opts: ExportRequest): Promise<{ blob: Blob; filename?: string }> { const res = await fetch(`${API}/api/db/export`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify( body(c, opts.sessionId, { format: opts.format, sql: opts.sql, table: opts.table, limit: opts.limit, database: opts.database || c.database, confirmDanger: opts.confirmDanger, }), ), }); if (!res.ok) { const data = await res.json().catch(() => ({})); const err = new Error((data as { error?: string }).error || res.statusText || "导出失败") as ExportError; if ((data as { needConfirm?: boolean }).needConfirm) err.needConfirm = true; throw err; } const cd = res.headers.get("Content-Disposition") || ""; const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd); const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined; const blob = await res.blob(); return { blob, filename }; } /** C3:服务端专用执行计划端点(方言包装在服务端完成,前端不再拼 EXPLAIN) */ export type ExplainResult = { columns?: string[]; rows?: Record[]; /** MSSQL SHOWPLAN_XML / Oracle 文本计划 */ text?: string; timeMs: number; error?: string; }; export async function explainSql( c: SavedConnection, sql: string, opts?: { database?: string; sessionId?: string; signal?: AbortSignal }, ) { return post( "/api/db/explain", body(c, opts?.sessionId, { sql, database: opts?.database || c.database }), opts?.signal, ); } export type RedisKeyInfo = { name: string; type: string; ttl: number; exact?: boolean }; export type RedisKeysResult = { keys: RedisKeyInfo[]; nextCursor?: string; exactHit?: boolean; truncated?: boolean; timeMs?: number; error?: string; }; export type RedisKeyValue = { name: string; type: string; ttl: number; columns: string[]; rows: Record[]; rowCount: number; timeMs: number; truncated?: boolean; limit?: number; /** >0 表示还有更多;续载时作为 cursor 回传 */ nextCursor?: number; error?: string; }; export async function listRedisKeys( c: SavedConnection, database: string, opts?: { sessionId?: string; pattern?: string; limit?: number; keyCursor?: string }, ) { return post( "/api/db/redis/keys", body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, keyCursor: opts?.keyCursor, driver: c.driver }), ); } export async function getRedisKey( c: SavedConnection, database: string, key: string, opts?: { sessionId?: string; limit?: number; cursor?: number }, ) { return post( "/api/db/redis/key", body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, cursor: opts?.cursor, driver: c.driver }), ); } export async function deleteRedisKey( c: SavedConnection, database: string, key: string, opts?: { sessionId?: string }, ) { return post( "/api/db/redis/key/delete", body(c, opts?.sessionId, { database, key, table: key, driver: c.driver }), ); } /** 目录(前缀)批量删除:pattern 如 `agent:*`;后端 SCAN+UNLINK,拒全库与超上限 */ export async function deleteRedisPattern( c: SavedConnection, database: string, pattern: string, opts?: { sessionId?: string }, ) { return post<{ deleted: number; scanned: number; truncated: boolean; error?: string; timeMs?: number }>( "/api/db/redis/del-pattern", body(c, opts?.sessionId, { database, pattern, driver: c.driver }), ); } export async function loadTree(c: SavedConnection, sessionId?: string): Promise { const { databases } = await listDatabases(c, sessionId); return databases.map((name) => ({ name, kind: "database" as const })); } export type AiAction = | "ask" | "generate" | "explain" | "optimize" | "fix" | "dialect" | "sample"; export async function callAi( payload: { action: AiAction; prompt?: string; sql?: string; driver?: string; database?: string; error?: string; /** C4/C8:前端注入的库表列上下文(截断 4000 字符) */ schema?: string; model?: string; }, signal?: AbortSignal, ) { return post<{ content: string; sql?: string }>("/api/ai/chat", payload, signal); } /** 阶段 2:Agent 模式 — 服务端多轮调同一 aitools Runtime(Ask 仍用 callAi) */ export type AiAgentStep = { tool: string; args?: string; ok: boolean; summary: string; error?: string; ms: number; }; export type AiAgentRes = { content: string; sql?: string; steps: AiAgentStep[]; rounds: number; runId?: string; sessionId?: string; runOutcome?: string; incompleteReason?: string; resumable?: boolean; stepsBudget?: number; }; export async function callAiAgent(payload: AiAgentStreamPayload) { return post("/api/ai/agent", payload); } export type AiSessionSummary = { id: string; title?: string; status: string; createdAt: string; updatedAt: string; msgCount: number; lastRunId?: string; }; export async function listAiSessions() { return get<{ sessions: AiSessionSummary[] }>("/api/ai/sessions"); } export async function getAiSession(id: string) { return get<{ id: string; title?: string; status: string; messages: unknown[]; runs?: unknown[]; }>(`/api/ai/sessions/${encodeURIComponent(id)}`); } export async function continueAiSession( id: string, payload: { prompt?: string; connectionId?: string; driver?: string; database?: string; model?: string; maxRounds?: number; stream?: boolean; }, ) { return post(`/api/ai/sessions/${encodeURIComponent(id)}/continue`, payload); } export type AiAgentQueryResult = { columns: string[]; rows: Record[]; rowCount: number; sql?: string; tool?: string; }; export type AiAgentStepExt = AiAgentStep & { pending?: boolean; needConfirm?: boolean; confirmSql?: string; confirmBind?: string; result?: AiAgentQueryResult; }; export type AiAgentStreamHandlers = { onStepStart?: (step: AiAgentStepExt) => void; onStep?: (step: AiAgentStepExt) => void; onDelta?: (delta: string) => void; onDone?: (res: AiAgentRes) => void; onIncomplete?: (res: AiAgentRes) => void; onSession?: (meta: { sessionId?: string; runId?: string; runOutcome?: string; resumable?: boolean }) => void; onError?: (error: string, partial?: Partial) => void; }; /** Agent SSE(stream=true);不可用时回落非流式。signal 供「停止」。 */ export type AiAgentStreamPayload = { prompt: string; connectionId?: string; driver?: string; database?: string; model?: string; sessionId?: string; continue?: boolean; maxRounds?: number; prior?: unknown[]; }; export async function callAiAgentStream( payload: AiAgentStreamPayload, handlers: AiAgentStreamHandlers, signal?: AbortSignal, ): Promise { try { const res = await fetch(`${API}/api/ai/agent`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...authHeaders(), }, body: JSON.stringify({ ...payload, stream: true }), signal, }); if (!res.ok || !res.body || !(res.headers.get("content-type") || "").includes("text/event-stream")) { const plain = await callAiAgent(payload); handlers.onDone?.(plain); return plain; } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; let content = ""; const steps: AiAgentStep[] = []; let rounds = 0; let finalRes: AiAgentRes | null = null; const handleEvent = (event: string, data: string) => { let ev: Record; try { ev = JSON.parse(data) as Record; } catch { return; } const typ = event || String(ev.type || ""); if (typ === "step_start" && ev.step) { handlers.onStepStart?.(ev.step as AiAgentStepExt); } else if (typ === "step" && ev.step) { const st = ev.step as AiAgentStepExt; steps.push(st); handlers.onStep?.(st); } else if (typ === "delta") { const d = String(ev.delta || ""); content += d; handlers.onDelta?.(d); } else if (typ === "done" || typ === "incomplete") { finalRes = { content: String(ev.content ?? content), sql: ev.sql ? String(ev.sql) : undefined, steps: (ev.steps as AiAgentStep[]) || steps, rounds: Number(ev.rounds ?? rounds) || 0, runId: ev.runId ? String(ev.runId) : undefined, sessionId: ev.sessionId ? String(ev.sessionId) : undefined, runOutcome: ev.runOutcome ? String(ev.runOutcome) : typ === "incomplete" ? "incomplete" : undefined, incompleteReason: ev.incompleteReason ? String(ev.incompleteReason) : undefined, resumable: !!ev.resumable, stepsBudget: ev.stepsBudget != null ? Number(ev.stepsBudget) : undefined, }; rounds = finalRes.rounds; if (typ === "incomplete") handlers.onIncomplete?.(finalRes); else handlers.onDone?.(finalRes); } else if (typ === "session") { handlers.onSession?.({ sessionId: ev.content ? String(ev.content) : ev.sessionId ? String(ev.sessionId) : undefined, runId: ev.runId ? String(ev.runId) : undefined, runOutcome: ev.runOutcome ? String(ev.runOutcome) : undefined, resumable: !!ev.resumable, }); if (finalRes) { finalRes = { ...finalRes, sessionId: (ev.content ? String(ev.content) : finalRes.sessionId) || finalRes.sessionId, runId: ev.runId ? String(ev.runId) : finalRes.runId, runOutcome: ev.runOutcome ? String(ev.runOutcome) : finalRes.runOutcome, resumable: ev.resumable != null ? !!ev.resumable : finalRes.resumable, }; } } else if (typ === "error") { handlers.onError?.(String(ev.error || "agent error"), { content, steps, rounds: Number(ev.rounds) || 0 }); } }; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx: number; while ((idx = buf.indexOf("\n\n")) >= 0) { const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2); let event = ""; let data = ""; for (const line of chunk.split("\n")) { if (line.startsWith("event:")) event = line.slice(6).trim(); else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim(); } if (data) handleEvent(event, data); } } if (finalRes) return finalRes; const fallback: AiAgentRes = { content, steps, rounds }; handlers.onDone?.(fallback); return fallback; } catch (e) { if (signal?.aborted) throw e; const plain = await callAiAgent(payload); handlers.onDone?.(plain); return plain; } } export type McpStatus = { configured: boolean; mode: string; whitelistCount: number; writesEnabled: boolean; allowDangerous?: boolean; active: boolean; recent?: { time: string; tool: string; ok: boolean; summary?: string; error?: string; source?: string }[]; policyPath?: string; }; export type McpPolicy = { allowed_connection_ids: string[]; mode: "read_only" | "read_write" | string; allow_dangerous?: boolean; default_limit?: number; max_limit?: number; }; export type McpInstallInfo = { status: "ready" | "partial" | "not_installed" | string; installed: boolean; currentVersion?: string; latestVersion?: string; nodeVersion?: string; npmAvailable: boolean; npmVersion?: string; npmPackageInstalled: boolean; npmPackageVersion?: string; goBinaryFound: boolean; goBinaryPath?: string; installCommand: string; npxCommand?: string; policyPath?: string; hint?: string; }; export async function getMcpStatus(signal?: AbortSignal) { return get("/api/ai/mcp-status", signal); } export async function getMcpPolicy(signal?: AbortSignal) { return get<{ policy: McpPolicy; path?: string }>("/api/ai/mcp-policy", signal); } export async function saveMcpPolicy(policy: McpPolicy, signal?: AbortSignal) { const res = await fetch(`${API}/api/ai/mcp-policy`, { method: "PUT", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(policy), signal, }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error((data as { error?: string }).error || res.statusText || "request failed"); } return data as { ok: boolean; policy: McpPolicy; path?: string }; } export async function getMcpInstall(signal?: AbortSignal) { return get("/api/ai/mcp-install", signal); } export async function confirmAiAgentWrite(payload: { connectionId: string; sql?: string; command?: string; database?: string; kind?: "query" | "redis"; confirmBind?: string; runId?: string; }) { return post>("/api/ai/agent/confirm", payload); } /** C4:AI 配置(key 脱敏,只回掩码与是否已配置);protocol/authStyle/allowInsecureHttp 见 aiPresets.ts */ export type AiConfig = { provider: string; baseURL: string; model: string; hasKey: boolean; apiKey?: string; protocol?: string; authStyle?: string; allowInsecureHttp?: boolean; }; export async function getAiConfig(signal?: AbortSignal) { return get("/api/ai/config", signal); } let connectionSyncQueue: Promise = Promise.resolve(); /** 按顺序同步桌面连接;Agent 必须等待成功,避免旧快照覆盖最新连接。 */ export function syncConnectionsToDisk(connections: SavedConnection[]): Promise { const body = JSON.stringify({ connections }); const sync = connectionSyncQueue.catch(() => {}).then(async () => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(`${API}/api/db/connections-store`, { method: "PUT", headers: { "Content-Type": "application/json", ...authHeaders() }, body, signal: controller.signal, }); if (!res.ok) throw new Error(`本机服务返回 ${res.status}`); const result = await res.json(); if (result?.ok !== true) throw new Error("本机服务未确认连接已保存"); } catch (e) { throw new Error(`连接同步失败,Agent 尚未执行任务:${controller.signal.aborted ? "本机服务响应超时" : e instanceof Error ? e.message : String(e)}`); } finally { clearTimeout(timer); } }); connectionSyncQueue = sync; return sync; } export async function loadConnectionsFromDisk(): Promise { try { const res = await fetch(`${API}/api/db/connections-store`, { headers: { ...authHeaders() } }); if (!res.ok) return null; const data = (await res.json()) as { connections?: SavedConnection[] }; return Array.isArray(data.connections) ? data.connections : null; } catch { return null; } } export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string; protocol?: string; authStyle?: string; allowInsecureHttp?: boolean; }) { return post<{ ok: boolean }>("/api/ai/config", cfg); } export async function health() { const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } }); if (!res.ok) throw new Error(`health ${res.status}`); const data = await res.json() as { service?: string; version?: string }; if (data.service !== "opsxdb") throw new Error("当前地址不是 OpsxDB 后端"); return data; } /** fetch AbortController / DOMException AbortError */ export function isAbortError(e: unknown): boolean { if (!e || typeof e !== "object") return false; const name = (e as { name?: string }).name; if (name === "AbortError") return true; const msg = e instanceof Error ? e.message : String(e); return /aborted|abort/i.test(msg) && !/session/i.test(msg); } // ---------- 驱动管理(agent 驱动) ---------- export type DriverJarStatus = { file: string; size: number; installed: boolean }; export type DriverStatusItem = { id: string; label: string; runtime: string; license: string; autoFetch: boolean; jars: DriverJarStatus[]; allInstalled: boolean; jdkRequired: boolean; jdkOk: boolean; javaVersion: string; agentInstalled: boolean; offlinePack: boolean; }; export async function driverStatus() { const res = await fetch(`${API}/api/drivers/status`); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText); const drivers = (data as { drivers?: DriverStatusItem[] }).drivers; if (!Array.isArray(drivers)) throw new Error("本机服务未提供驱动状态,请重启桌面应用后重试"); return drivers; } export async function driverInstall(driver: string) { return post<{ ok: boolean; error?: string; drivers?: DriverStatusItem[] }>("/api/drivers/install", { driver }); } export async function driverImportOffline(file: File) { const form = new FormData(); form.append("file", file); const res = await fetch(`${API}/api/drivers/import`, { method: "POST", body: form }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText); return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] }; } /** 表转储范围:仅结构 / 仅数据 / 结构+数据(服务端 DumpTable 对应值) */ export type DumpMode = "structure" | "data" | "structure_data"; /** 表右键「转储 SQL 文件」:POST /api/db/download → blob 下载(.sql) */ export async function dumpTable( c: SavedConnection, opts: { table: string; database?: string; mode: DumpMode; sessionId?: string }, ): Promise<{ blob: Blob; filename?: string }> { const res = await fetch(`${API}/api/db/dump`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify( body(c, opts.sessionId, { format: opts.mode, table: opts.table, database: opts.database || c.database }), ), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error((data as { error?: string }).error || res.statusText || "转储失败"); } const cd = res.headers.get("Content-Disposition") || ""; const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd); const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined; const blob = await res.blob(); return { blob, filename }; } /** 触发浏览器下载 blob(导出/转储共用) */ export function downloadBlob(blob: Blob, filename: string) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); }