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:
Abeautifulsnow 2026-07-03 15:18:56 +08:00 committed by GitHub
parent f1f4518b67
commit fe4908321e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 169 additions and 4 deletions

View File

@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, type Component } from
import { uuid } from "@/lib/utils"; import { uuid } from "@/lib/utils";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors"; 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 { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 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 { 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 { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/aiPromptKeyboard";
import { looksLikeActionProposal, containsChinese } from "@/lib/aiProposalDetect"; import { looksLikeActionProposal, containsChinese } from "@/lib/aiProposalDetect";
import { visibleToActualIndex } from "@/lib/aiMessageEdit";
const { t } = useI18n(); const { t } = useI18n();
const settings = useSettingsStore(); const settings = useSettingsStore();
@ -87,6 +88,58 @@ const promptHistory = ref<string[]>([]);
const historyIndex = ref(-1); const historyIndex = ref(-1);
const draftBeforeHistory = ref(""); 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 // Inline model selector
const modelOptions = ref<AiModelInfo[]>([]); const modelOptions = ref<AiModelInfo[]>([]);
const modelLoading = ref(false); 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"> <ScrollArea v-else ref="scrollRef" class="min-h-0 flex-1 overflow-hidden">
<div class="flex flex-col gap-3 p-3"> <div class="flex flex-col gap-3 p-3">
<template v-for="(msg, i) in visibleMessages" :key="i"> <template v-for="(msg, i) in visibleMessages" :key="i">
<div v-if="msg.role === 'user'" class="flex justify-end"> <div v-if="msg.role === 'user'" class="group flex justify-end">
<div class="max-w-[85%] whitespace-pre-wrap rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground"> <div class="max-w-[85%]">
{{ msg.content }} <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>
</div> </div>

View File

@ -1218,6 +1218,9 @@ export default {
convert: "e.g. convert to PostgreSQL / MySQL / SQL Server", convert: "e.g. convert to PostgreSQL / MySQL / SQL Server",
sampleData: "Describe the sample data or test statements to generate", sampleData: "Describe the sample data or test statements to generate",
}, },
editMessage: "Edit message",
editResend: "Resend",
editCancel: "Cancel",
}, },
contextMenu: { contextMenu: {
openConnection: "Open Connection", openConnection: "Open Connection",

View File

@ -1221,6 +1221,9 @@ export default withEnglishFallback({
convert: "P. ej. convertir a PostgreSQL / MySQL / SQL Server", convert: "P. ej. convertir a PostgreSQL / MySQL / SQL Server",
sampleData: "Describe los datos de ejemplo o sentencias de prueba a generar", sampleData: "Describe los datos de ejemplo o sentencias de prueba a generar",
}, },
editMessage: "Editar mensaje",
editResend: "Reenviar",
editCancel: "Cancelar",
}, },
contextMenu: { contextMenu: {
openConnection: "Abrir conexión", openConnection: "Abrir conexión",

View File

@ -1219,6 +1219,9 @@ export default withEnglishFallback({
convert: "es. converti in PostgreSQL / MySQL / SQL Server", convert: "es. converti in PostgreSQL / MySQL / SQL Server",
sampleData: "Descrivi i dati di esempio o le istruzioni di test da generare", sampleData: "Descrivi i dati di esempio o le istruzioni di test da generare",
}, },
editMessage: "Modifica messaggio",
editResend: "Invia di nuovo",
editCancel: "Annulla",
}, },
contextMenu: { contextMenu: {
openConnection: "Apri Connessione", openConnection: "Apri Connessione",

View File

@ -1219,6 +1219,9 @@ export default withEnglishFallback({
convert: "例: PostgreSQL / MySQL / SQL Serverに変換", convert: "例: PostgreSQL / MySQL / SQL Serverに変換",
sampleData: "生成するサンプルデータやテスト文を説明してください", sampleData: "生成するサンプルデータやテスト文を説明してください",
}, },
editMessage: "メッセージを編集",
editResend: "再送信",
editCancel: "キャンセル",
}, },
contextMenu: { contextMenu: {
openConnection: "接続を開く", openConnection: "接続を開く",

View File

@ -1220,6 +1220,9 @@ export default withEnglishFallback({
convert: "por exemplo, converter para PostgreSQL / MySQL / SQL Server", convert: "por exemplo, converter para PostgreSQL / MySQL / SQL Server",
sampleData: "Descreva os dados de exemplo ou instruções de teste a gerar", sampleData: "Descreva os dados de exemplo ou instruções de teste a gerar",
}, },
editMessage: "Editar mensagem",
editResend: "Reenviar",
editCancel: "Cancelar",
}, },
contextMenu: { contextMenu: {
openConnection: "Abrir Conexão", openConnection: "Abrir Conexão",

View File

@ -1220,6 +1220,9 @@ export default withEnglishFallback({
convert: "例如:转换成 PostgreSQL / MySQL / SQL Server", convert: "例如:转换成 PostgreSQL / MySQL / SQL Server",
sampleData: "描述要生成的样例数据或测试语句", sampleData: "描述要生成的样例数据或测试语句",
}, },
editMessage: "编辑消息",
editResend: "重新发送",
editCancel: "取消",
}, },
contextMenu: { contextMenu: {
openConnection: "打开连接", openConnection: "打开连接",

View File

@ -1220,6 +1220,9 @@ export default withEnglishFallback({
convert: "例如:轉換成 PostgreSQL / MySQL / SQL Server", convert: "例如:轉換成 PostgreSQL / MySQL / SQL Server",
sampleData: "描述要產生的範例資料或測試語句", sampleData: "描述要產生的範例資料或測試語句",
}, },
editMessage: "編輯訊息",
editResend: "重新傳送",
editCancel: "取消",
}, },
contextMenu: { contextMenu: {
openConnection: "開啟連線", openConnection: "開啟連線",

View File

@ -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);
});
});

View File

@ -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;
}