feat(ai): support editing sent user messages
* feat(ai): support editing sent user messages Adds inline edit support for user messages in the AI chat panel. Hover over a user message to reveal the edit button; confirming the edit truncates all subsequent history and re-sends with the new content. * fix(ai): fix IME composition and ref-focus regression in message edit - Handle IME composition events in onEditKeydown to prevent accidental submission on Enter during CJK candidate selection - Replace per-render :ref focus callback with nextTick + data attribute to avoid cursor jumping on every keystroke - Extract visibleToActualIndex into aiMessageEdit.ts for testability - Add 8 unit tests covering index mapping with contextSummary messages * fix(ai): prevent data loss and mention pollution in message edit - Guard connection/config before truncating messages to prevent silent data loss when send() bails early - Clear selectedMentions before resend to prevent stale chips from polluting edited message content
This commit is contained in:
parent
f1f4518b67
commit
fe4908321e
|
|
@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, type Component } from
|
|||
import { uuid } from "@/lib/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, GitBranch, HelpCircle, History, Loader2, MessageSquarePlus, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
|
||||
import { ArrowUp, ArrowRightLeft, AlertTriangle, Bot, Check, ChevronRight, CircleSlash, Copy, Database, GitBranch, HelpCircle, History, Loader2, MessageSquarePlus, Pencil, Replace, Server, ShieldCheck, Table2, Play, Square, Trash2, Terminal, Wand2, Wrench, X, Zap, TestTube } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -38,6 +38,7 @@ import { copyToClipboard } from "@/lib/clipboard";
|
|||
import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filterAiTableMentionCandidates, formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/aiTableMentions";
|
||||
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/aiPromptKeyboard";
|
||||
import { looksLikeActionProposal, containsChinese } from "@/lib/aiProposalDetect";
|
||||
import { visibleToActualIndex } from "@/lib/aiMessageEdit";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
|
|
@ -87,6 +88,58 @@ const promptHistory = ref<string[]>([]);
|
|||
const historyIndex = ref(-1);
|
||||
const draftBeforeHistory = ref("");
|
||||
|
||||
const editingMessageIndex = ref<number | null>(null);
|
||||
const editingContent = ref("");
|
||||
const editCompositionActive = ref(false);
|
||||
|
||||
function startEditMessage(visibleIndex: number) {
|
||||
if (isGenerating.value) return;
|
||||
editingMessageIndex.value = visibleIndex;
|
||||
editingContent.value = visibleMessages.value[visibleIndex].content;
|
||||
nextTick(() => {
|
||||
const el = document.querySelector<HTMLTextAreaElement>("[data-edit-textarea]");
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingMessageIndex.value = null;
|
||||
editingContent.value = "";
|
||||
}
|
||||
|
||||
function submitEdit(visibleIndex: number) {
|
||||
const content = editingContent.value.trim();
|
||||
if (!content) return;
|
||||
const actualIndex = visibleToActualIndex(messages.value, visibleIndex);
|
||||
if (actualIndex < 0) return;
|
||||
if (!props.connection || !props.tab) return;
|
||||
if (!settings.isConfigured()) {
|
||||
toast(t("ai.noConfig"));
|
||||
return;
|
||||
}
|
||||
messages.value = messages.value.slice(0, actualIndex);
|
||||
editingMessageIndex.value = null;
|
||||
editingContent.value = "";
|
||||
selectedMentions.value = [];
|
||||
prompt.value = content;
|
||||
send();
|
||||
}
|
||||
|
||||
function onEditKeydown(event: KeyboardEvent, visibleIndex: number) {
|
||||
if (isAiPromptImeCompositionEvent(event, editCompositionActive.value)) return;
|
||||
if (event.key === "Escape") {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submitEdit(visibleIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline model selector
|
||||
const modelOptions = ref<AiModelInfo[]>([]);
|
||||
const modelLoading = ref(false);
|
||||
|
|
@ -1155,9 +1208,33 @@ async function openExternalUrl(url: string) {
|
|||
<ScrollArea v-else ref="scrollRef" class="min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<template v-for="(msg, i) in visibleMessages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="flex justify-end">
|
||||
<div class="max-w-[85%] whitespace-pre-wrap rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
|
||||
{{ msg.content }}
|
||||
<div v-if="msg.role === 'user'" class="group flex justify-end">
|
||||
<div class="max-w-[85%]">
|
||||
<template v-if="editingMessageIndex === i">
|
||||
<textarea
|
||||
data-edit-textarea
|
||||
v-model="editingContent"
|
||||
rows="3"
|
||||
class="w-full resize-none rounded-lg border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
|
||||
@keydown="onEditKeydown($event, i)"
|
||||
@compositionstart="editCompositionActive = true"
|
||||
@compositionend="editCompositionActive = false"
|
||||
/>
|
||||
<div class="mt-1.5 flex justify-end gap-1.5">
|
||||
<Button size="sm" variant="ghost" class="h-6 px-2 text-[11px]" @click="cancelEdit">{{ t("ai.editCancel") }}</Button>
|
||||
<Button size="sm" class="h-6 px-2 text-[11px]" @click="submitEdit(i)">{{ t("ai.editResend") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-1">
|
||||
<button v-if="!isGenerating" class="mt-1 hidden h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground group-hover:flex" :title="t('ai.editMessage')" @click="startEditMessage(i)">
|
||||
<Pencil class="h-3 w-3" />
|
||||
</button>
|
||||
<div class="whitespace-pre-wrap rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
|
||||
{{ msg.content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1218,6 +1218,9 @@ export default {
|
|||
convert: "e.g. convert to PostgreSQL / MySQL / SQL Server",
|
||||
sampleData: "Describe the sample data or test statements to generate",
|
||||
},
|
||||
editMessage: "Edit message",
|
||||
editResend: "Resend",
|
||||
editCancel: "Cancel",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Open Connection",
|
||||
|
|
|
|||
|
|
@ -1221,6 +1221,9 @@ export default withEnglishFallback({
|
|||
convert: "P. ej. convertir a PostgreSQL / MySQL / SQL Server",
|
||||
sampleData: "Describe los datos de ejemplo o sentencias de prueba a generar",
|
||||
},
|
||||
editMessage: "Editar mensaje",
|
||||
editResend: "Reenviar",
|
||||
editCancel: "Cancelar",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Abrir conexión",
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,9 @@ export default withEnglishFallback({
|
|||
convert: "es. converti in PostgreSQL / MySQL / SQL Server",
|
||||
sampleData: "Descrivi i dati di esempio o le istruzioni di test da generare",
|
||||
},
|
||||
editMessage: "Modifica messaggio",
|
||||
editResend: "Invia di nuovo",
|
||||
editCancel: "Annulla",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Apri Connessione",
|
||||
|
|
|
|||
|
|
@ -1219,6 +1219,9 @@ export default withEnglishFallback({
|
|||
convert: "例: PostgreSQL / MySQL / SQL Serverに変換",
|
||||
sampleData: "生成するサンプルデータやテスト文を説明してください",
|
||||
},
|
||||
editMessage: "メッセージを編集",
|
||||
editResend: "再送信",
|
||||
editCancel: "キャンセル",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "接続を開く",
|
||||
|
|
|
|||
|
|
@ -1220,6 +1220,9 @@ export default withEnglishFallback({
|
|||
convert: "por exemplo, converter para PostgreSQL / MySQL / SQL Server",
|
||||
sampleData: "Descreva os dados de exemplo ou instruções de teste a gerar",
|
||||
},
|
||||
editMessage: "Editar mensagem",
|
||||
editResend: "Reenviar",
|
||||
editCancel: "Cancelar",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "Abrir Conexão",
|
||||
|
|
|
|||
|
|
@ -1220,6 +1220,9 @@ export default withEnglishFallback({
|
|||
convert: "例如:转换成 PostgreSQL / MySQL / SQL Server",
|
||||
sampleData: "描述要生成的样例数据或测试语句",
|
||||
},
|
||||
editMessage: "编辑消息",
|
||||
editResend: "重新发送",
|
||||
editCancel: "取消",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "打开连接",
|
||||
|
|
|
|||
|
|
@ -1220,6 +1220,9 @@ export default withEnglishFallback({
|
|||
convert: "例如:轉換成 PostgreSQL / MySQL / SQL Server",
|
||||
sampleData: "描述要產生的範例資料或測試語句",
|
||||
},
|
||||
editMessage: "編輯訊息",
|
||||
editResend: "重新傳送",
|
||||
editCancel: "取消",
|
||||
},
|
||||
contextMenu: {
|
||||
openConnection: "開啟連線",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { visibleToActualIndex } from "@/lib/aiMessageEdit";
|
||||
|
||||
describe("visibleToActualIndex", () => {
|
||||
it("maps visible index 0 to first non-summary message", () => {
|
||||
const messages = [{ kind: undefined }, { kind: undefined }, { kind: undefined }];
|
||||
expect(visibleToActualIndex(messages, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("maps visible index 1 correctly with no summaries", () => {
|
||||
const messages = [{ kind: undefined }, { kind: undefined }, { kind: undefined }];
|
||||
expect(visibleToActualIndex(messages, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it("skips contextSummary messages when computing visible index", () => {
|
||||
const messages = [{ kind: undefined }, { kind: "contextSummary" }, { kind: undefined }, { kind: undefined }];
|
||||
// visible[0] → actual[0], visible[1] → actual[2], visible[2] → actual[3]
|
||||
expect(visibleToActualIndex(messages, 0)).toBe(0);
|
||||
expect(visibleToActualIndex(messages, 1)).toBe(2);
|
||||
expect(visibleToActualIndex(messages, 2)).toBe(3);
|
||||
});
|
||||
|
||||
it("skips multiple consecutive contextSummary messages", () => {
|
||||
const messages = [{ kind: "contextSummary" }, { kind: "contextSummary" }, { kind: undefined }, { kind: undefined }];
|
||||
expect(visibleToActualIndex(messages, 0)).toBe(2);
|
||||
expect(visibleToActualIndex(messages, 1)).toBe(3);
|
||||
});
|
||||
|
||||
it("returns -1 when visibleIndex is out of range", () => {
|
||||
const messages = [{ kind: undefined }, { kind: undefined }];
|
||||
expect(visibleToActualIndex(messages, 5)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -1 for empty messages array", () => {
|
||||
expect(visibleToActualIndex([], 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns -1 when all messages are contextSummary", () => {
|
||||
const messages = [{ kind: "contextSummary" }, { kind: "contextSummary" }];
|
||||
expect(visibleToActualIndex(messages, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it("handles last visible message correctly", () => {
|
||||
const messages = [{ kind: undefined }, { kind: "contextSummary" }, { kind: undefined }];
|
||||
expect(visibleToActualIndex(messages, 1)).toBe(2);
|
||||
expect(visibleToActualIndex(messages, 2)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
export interface MessageWithKind {
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a visible message index (contextSummary messages excluded) to the
|
||||
* actual index in the full messages array.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
export function visibleToActualIndex(messages: MessageWithKind[], visibleIndex: number): number {
|
||||
let vi = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].kind !== "contextSummary") {
|
||||
if (vi === visibleIndex) return i;
|
||||
vi++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
Loading…
Reference in New Issue