- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
984 lines
31 KiB
TypeScript
984 lines
31 KiB
TypeScript
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<string, string> }).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<string, string> {
|
||
const token = injectedToken();
|
||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||
}
|
||
|
||
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||
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<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||
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>): 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>): 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<string, Map<string, Map<string, ColumnInfo[]>>>();
|
||
|
||
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<string, Map<string, ColumnInfo[]>> | 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<QueryResult>(
|
||
"/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<string, unknown>[];
|
||
/** 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<ExplainResult>(
|
||
"/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<string, unknown>[];
|
||
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<RedisKeysResult>(
|
||
"/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<RedisKeyValue>(
|
||
"/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<QueryResult>(
|
||
"/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<TreeNode[]> {
|
||
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<AiAgentRes>("/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<AiAgentRes>(`/api/ai/sessions/${encodeURIComponent(id)}/continue`, payload);
|
||
}
|
||
|
||
export type AiAgentQueryResult = {
|
||
columns: string[];
|
||
rows: Record<string, unknown>[];
|
||
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<AiAgentRes>) => 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<AiAgentRes> {
|
||
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<string, unknown>;
|
||
try {
|
||
ev = JSON.parse(data) as Record<string, unknown>;
|
||
} 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<McpStatus>("/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<McpInstallInfo>("/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<Record<string, unknown>>("/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<AiConfig>("/api/ai/config", signal);
|
||
}
|
||
|
||
|
||
let connectionSyncQueue: Promise<void> = Promise.resolve();
|
||
|
||
/** 按顺序同步桌面连接;Agent 必须等待成功,避免旧快照覆盖最新连接。 */
|
||
export function syncConnectionsToDisk(connections: SavedConnection[]): Promise<void> {
|
||
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<SavedConnection[] | null> {
|
||
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);
|
||
}
|