feat(history): add AI analysis action
This commit is contained in:
parent
048f629d21
commit
f031fe85ae
35
src/App.vue
35
src/App.vue
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: "错误",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue