feat(history): add AI analysis action

This commit is contained in:
t8y2 2026-05-15 19:53:32 +08:00
parent 048f629d21
commit f031fe85ae
7 changed files with 188 additions and 9 deletions

View File

@ -46,6 +46,7 @@ import {
import { isPreviewTab } from "@/lib/tabPresentation";
import { supportsSqlFileExecution } from "@/lib/databaseCapabilities";
import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy";
import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -191,6 +192,34 @@ function fixWithAi(errorMessage: string) {
nextTick(() => aiAssistantRef.value?.triggerAction("fix", errorMessage));
}
function openAiPanel() {
if (!showAiPanel.value) {
showAiPanel.value = true;
localStorage.setItem("dbx-ai-panel-open", "true");
}
}
function analyzeHistoryWithAi(entry: HistoryEntry) {
const connectionId = entry.connection_id || activeTab.value?.connectionId;
if (!connectionId) {
toast(t("history.aiAnalyzeNoConnection"), 5000);
return;
}
const config = connectionStore.getConfig(connectionId);
if (!config) {
toast(t("history.aiAnalyzeNoConnection"), 5000);
return;
}
openAiPanel();
const database = entry.database || activeTab.value?.database || resolveDefaultDatabase(config, []);
const title = t("history.aiAnalysisTab");
const tabId = queryStore.createTab(connectionId, database || "", title, "query");
queryStore.updateSql(tabId, entry.sql);
nextTick(() => aiAssistantRef.value?.triggerAction("explain", buildHistoryAiAnalysisPrompt(entry)));
}
function formatActiveSql() {
const tab = activeTab.value;
if (!tab || tab.mode !== "query" || !tab.sql.trim()) return;
@ -780,7 +809,11 @@ onUnmounted(() => {
:style="{ width: historyWidth + 'px' }"
>
<div class="panel-resize-handle panel-resize-handle--left" @mousedown="startHistoryResize" />
<QueryHistory @restore="restoreHistorySql" @close="showHistory = false" />
<QueryHistory
@restore="restoreHistorySql"
@analyze-ai="analyzeHistoryWithAi"
@close="showHistory = false"
/>
</div>
</div>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { Clock, Copy, Database, RotateCcw, Search, Trash2, X } from "lucide-vue-next";
import { Clock, Copy, Database, RotateCcw, Search, Sparkles, Trash2, X } from "lucide-vue-next";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@ -10,6 +10,7 @@ import { useHistoryStore } from "@/stores/historyStore";
import { useToast } from "@/composables/useToast";
import { shouldClearHistory, shouldDeleteHistoryEntry } from "@/lib/historyActions";
import { resolveHistoryActivityKind } from "@/lib/historyActivityKind";
import { canRollbackHistoryEntry } from "@/lib/historyAiAnalysis";
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/historyVirtualList";
import type { HistoryEntry } from "@/lib/api";
import * as api from "@/lib/api";
@ -20,6 +21,7 @@ const store = useHistoryStore();
const emit = defineEmits<{
restore: [sql: string, entry: HistoryEntry];
analyzeAi: [entry: HistoryEntry];
close: [];
}>();
@ -119,6 +121,10 @@ function detailsRows(entry: HistoryEntry) {
[t("history.detail.time"), formatFullTime(entry.executed_at)],
[t("history.detail.duration"), `${entry.execution_time_ms}ms`],
[t("history.detail.affectedRows"), entry.affected_rows ?? "-"],
[
t("history.detail.rollback"),
canRollbackHistoryEntry(entry) ? t("history.rollbackAvailable") : t("history.rollbackUnavailable"),
],
[t("history.detail.status"), entry.success ? t("history.success") : t("history.failed")],
];
if (entry.error) rows.push([t("history.detail.error"), entry.error]);
@ -126,18 +132,20 @@ function detailsRows(entry: HistoryEntry) {
}
async function rollback(entry: HistoryEntry) {
if (!entry.connection_id || !entry.database || !entry.rollback_sql || isRollingBack.value) return;
if (!canRollbackHistoryEntry(entry) || isRollingBack.value) return;
if (!window.confirm(t("history.rollbackConfirm"))) return;
const connectionId = entry.connection_id!;
const rollbackSql = entry.rollback_sql!;
isRollingBack.value = true;
const start = Date.now();
try {
const result = await api.executeScript(entry.connection_id, entry.database, entry.rollback_sql);
const result = await api.executeScript(connectionId, entry.database, rollbackSql);
await store.add({
connection_id: entry.connection_id,
connection_id: connectionId,
connection_name: entry.connection_name,
database: entry.database,
sql: entry.rollback_sql,
sql: rollbackSql,
execution_time_ms: Date.now() - start,
success: true,
activity_kind: "data_change",
@ -241,8 +249,12 @@ onMounted(() => store.load());
<ContextMenuContent class="w-44">
<ContextMenuItem @click="selectedEntry = entry">{{ t("history.viewDetails") }}</ContextMenuItem>
<ContextMenuItem @click="restore(entry)">{{ t("history.restore") }}</ContextMenuItem>
<ContextMenuItem @click="emit('analyzeAi', entry)">
<Sparkles class="h-3.5 w-3.5" />
{{ t("history.analyzeWithAi") }}
</ContextMenuItem>
<ContextMenuItem @click="copyText(entry.sql)">{{ t("history.copy") }}</ContextMenuItem>
<ContextMenuItem v-if="entry.rollback_sql" @click="rollback(entry)">{{
<ContextMenuItem v-if="canRollbackHistoryEntry(entry)" @click="rollback(entry)">{{
t("history.rollback")
}}</ContextMenuItem>
<ContextMenuItem class="text-destructive" @click="confirmDeleteEntry(entry.id)">{{
@ -296,10 +308,14 @@ onMounted(() => store.load());
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="selectedEntry && emit('analyzeAi', selectedEntry)">
<Sparkles class="h-4 w-4" />
{{ t("history.analyzeWithAi") }}
</Button>
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
<Button
v-if="selectedEntry?.rollback_sql"
:disabled="isRollingBack || !selectedEntry.connection_id"
v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)"
:disabled="isRollingBack"
@click="rollback(selectedEntry)"
>
<RotateCcw class="h-4 w-4" />

View File

@ -809,8 +809,13 @@ export default {
copy: "Copy SQL",
delete: "Delete",
viewDetails: "View details",
analyzeWithAi: "Analyze with AI",
aiAnalyzeNoConnection: "Cannot analyze this history entry because its connection no longer exists.",
aiAnalysisTab: "History Analysis",
rollback: "Rollback",
rollbackSql: "Rollback SQL",
rollbackAvailable: "Available",
rollbackUnavailable: "No rollback SQL",
rollbackConfirm: "Execute the rollback SQL generated for this history entry?",
rollbackSuccess: "Rollback executed",
rollbackFailed: "Rollback failed: {message}",
@ -846,6 +851,7 @@ export default {
time: "Time",
duration: "Duration",
affectedRows: "Affected rows",
rollback: "Rollback",
status: "Status",
error: "Error",
},

View File

@ -785,8 +785,13 @@ export default {
copy: "Copiar SQL",
delete: "Eliminar",
viewDetails: "Ver detalles",
analyzeWithAi: "Analizar con IA",
aiAnalyzeNoConnection: "No se puede analizar esta entrada porque su conexión ya no existe.",
aiAnalysisTab: "Análisis del historial",
rollback: "Revertir",
rollbackSql: "SQL de reversión",
rollbackAvailable: "Disponible",
rollbackUnavailable: "Sin SQL de reversión",
rollbackConfirm: "¿Ejecutar el SQL de reversión generado para esta entrada del historial?",
rollbackSuccess: "Reversión ejecutada",
rollbackFailed: "Error al revertir: {message}",
@ -822,6 +827,7 @@ export default {
time: "Hora",
duration: "Duración",
affectedRows: "Filas afectadas",
rollback: "Reversión",
status: "Estado",
error: "Error",
},

View File

@ -793,8 +793,13 @@ export default {
copy: "复制 SQL",
delete: "删除",
viewDetails: "查看详情",
analyzeWithAi: "AI 分析",
aiAnalyzeNoConnection: "无法分析这条历史记录:对应连接已不存在。",
aiAnalysisTab: "历史分析",
rollback: "回滚",
rollbackSql: "回滚 SQL",
rollbackAvailable: "可回滚",
rollbackUnavailable: "无回滚 SQL",
rollbackConfirm: "将执行这条历史记录生成的回滚 SQL确认继续吗",
rollbackSuccess: "回滚已执行",
rollbackFailed: "回滚失败:{message}",
@ -830,6 +835,7 @@ export default {
time: "时间",
duration: "耗时",
affectedRows: "影响行数",
rollback: "回滚",
status: "状态",
error: "错误",
},

View File

@ -0,0 +1,58 @@
export type HistoryAiAnalysisEntry = {
id: string;
connection_id?: string;
connection_name: string;
database: string;
sql: string;
executed_at: string;
execution_time_ms: number;
success: boolean;
error?: string | null;
activity_kind?: "query" | "data_change" | "schema_change" | "import" | "transfer";
operation?: string;
target?: string;
affected_rows?: number | null;
rollback_sql?: string | null;
details_json?: string | null;
};
export function canRollbackHistoryEntry(
entry: Pick<HistoryAiAnalysisEntry, "connection_id" | "database" | "rollback_sql">,
) {
return !!entry.connection_id?.trim() && !!entry.database?.trim() && !!entry.rollback_sql?.trim();
}
export function buildHistoryAiAnalysisPrompt(entry: HistoryAiAnalysisEntry): string {
const details = [
"请分析这条 DBX 历史记录,重点说明:",
"1. 这次操作做了什么,以及可能影响哪些数据或结构。",
"2. 是否有风险,例如无 WHERE 更新、删除、DDL、锁表、性能或权限问题。",
"3. 如果有 Rollback SQL请评估它是否足够安全执行前还应该确认什么。",
"4. 如果没有 Rollback SQL请明确说明无法直接回滚并给出可行的人工恢复建议。",
"",
"History metadata:",
`Connection: ${entry.connection_name || "(unknown)"}`,
`Database: ${entry.database || "(unknown)"}`,
`Activity kind: ${entry.activity_kind || "query"}`,
`Operation: ${entry.operation || "(unknown)"}`,
`Target: ${entry.target || "(unknown)"}`,
`Status: ${entry.success ? "success" : "failed"}`,
`Executed at: ${entry.executed_at || "(unknown)"}`,
`Duration: ${entry.execution_time_ms}ms`,
`Affected rows: ${entry.affected_rows ?? "(unknown)"}`,
entry.error ? `Error: ${entry.error}` : "",
entry.details_json ? `Details JSON: ${entry.details_json}` : "",
"",
"SQL:",
"```sql",
entry.sql.trim() || "-- empty",
"```",
"",
"Rollback SQL:",
entry.rollback_sql?.trim() ? "```sql" : "(not available)",
entry.rollback_sql?.trim() ? entry.rollback_sql.trim() : "",
entry.rollback_sql?.trim() ? "```" : "",
];
return details.filter((line) => line !== "").join("\n");
}

View File

@ -0,0 +1,54 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
buildHistoryAiAnalysisPrompt,
canRollbackHistoryEntry,
type HistoryAiAnalysisEntry,
} from "../src/lib/historyAiAnalysis.ts";
const baseEntry: HistoryAiAnalysisEntry = {
id: "h1",
connection_name: "Local MySQL",
database: "app",
sql: "UPDATE users SET status = 'inactive' WHERE id = 42;",
executed_at: "2026-05-15T07:30:00.000Z",
execution_time_ms: 125,
success: true,
activity_kind: "data_change",
operation: "UPDATE",
target: "public.users",
affected_rows: 1,
rollback_sql: "UPDATE users SET status = 'active' WHERE id = 42;",
};
test("buildHistoryAiAnalysisPrompt includes operation details and rollback SQL", () => {
const prompt = buildHistoryAiAnalysisPrompt(baseEntry);
assert.match(prompt, /分析这条 DBX 历史记录/);
assert.match(prompt, /Connection: Local MySQL/);
assert.match(prompt, /Operation: UPDATE/);
assert.match(prompt, /Affected rows: 1/);
assert.match(prompt, /UPDATE users SET status = 'inactive'/);
assert.match(prompt, /Rollback SQL/);
assert.match(prompt, /UPDATE users SET status = 'active'/);
});
test("buildHistoryAiAnalysisPrompt records when rollback SQL is unavailable", () => {
const prompt = buildHistoryAiAnalysisPrompt({
...baseEntry,
rollback_sql: null,
error: "permission denied",
success: false,
});
assert.match(prompt, /Status: failed/);
assert.match(prompt, /Error: permission denied/);
assert.match(prompt, /Rollback SQL:\n\(not available\)/);
});
test("canRollbackHistoryEntry requires connection, database, and rollback SQL", () => {
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "conn-1" }), true);
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "" }), false);
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "conn-1", database: "" }), false);
assert.equal(canRollbackHistoryEntry({ ...baseEntry, connection_id: "conn-1", rollback_sql: "" }), false);
});