feat(ai): rename conversations and improve database picker

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-15 23:56:55 +08:00
1 parent 2d5fc80c91
commit 1938c4a8aa
12 files changed
+134 -7

No files matched your search

@@ -323,6 +323,10 @@ const conversationSearchInput = ref<HTMLInputElement | null>(null);
const conversationSearchIndex = computed(() => buildAiConversationSearchIndex(conversations.value));
const filteredConversations = computed(() => filterAiConversationSearchIndex(conversationSearchIndex.value, conversationSearchQuery.value));
const showConversationList = ref(false);
const renamingConversationId = ref<string | null>(null);
const renamingConversationTitle = ref("");
const renamingConversationInput = ref<HTMLInputElement | null>(null);
const renamedConversationTitles = reactive(new Map<string, string>());
const showTemplateSelector = ref(false);
const modeActionOpen = ref(false);
// A normal-send FIFO run recovered at startup as an editable pending draft.
@@ -1407,6 +1411,12 @@ const dbSelectOptions = computed(() => {
// AI can inspect more than the tab's active database. Keep this selection local
// to the composer so changing the visible query tab does not rewrite SQL state.
const selectedDatabases = ref<string[]>([]);
const databaseSearchQuery = ref("");
const filteredDbSelectOptions = computed(() => {
const query = databaseSearchQuery.value.trim().toLowerCase();
if (!query) return dbSelectOptions.value;
return dbSelectOptions.value.filter((option) => option.label.toLowerCase().includes(query) || option.database.toLowerCase().includes(query));
});
const selectedDatabaseValues = computed(() => new Set(selectedDatabases.value));
const selectedDatabaseLabel = computed(() => {
@@ -3835,9 +3845,10 @@ function clearAttachmentDraftState() {
function buildConversationSnapshot(targetConversationId: string, targetMessages: ChatMessage[], connectionName: string, database: string, createdAt = new Date().toISOString()): AiConversation | null {
if (!targetConversationId || !targetMessages.length) return null;
const first = targetMessages.find((m) => m.role === "user" && m.kind !== "contextSummary");
const existingConversation = conversations.value.find((conversation) => conversation.id === targetConversationId);
return {
id: targetConversationId,
title: first ? messageTitle(first).slice(0, 50) : "Untitled",
title: renamedConversationTitles.get(targetConversationId) || existingConversation?.title || (first ? messageTitle(first).slice(0, 50) : "Untitled"),
connectionName,
database,
messages: targetMessages.map((m) => ({
@@ -3992,6 +4003,38 @@ async function setConversationListOpen(open: boolean) {
}
}
async function startRenameConversation(conv: AiConversation) {
renamingConversationId.value = conv.id;
renamingConversationTitle.value = conv.title;
await nextTick();
renamingConversationInput.value?.focus();
renamingConversationInput.value?.select();
}
function cancelRenameConversation() {
renamingConversationId.value = null;
renamingConversationTitle.value = "";
}
async function commitRenameConversation(conv: AiConversation) {
const title = renamingConversationTitle.value.trim().slice(0, 50);
if (!title || title === conv.title) return cancelRenameConversation();
try {
const activeRun = desktopAiRun<ChatMessage>(conv.id);
if (activeRun) await runSnapshotScheduler.save(activeRun);
const latestConversation = conversations.value.find((item) => item.id === conv.id) ?? conv;
const updated = { ...latestConversation, title, updatedAt: new Date().toISOString() };
// Claim the title before the async save so a throttled snapshot firing
// during the await window cannot persist the previous title over it.
renamedConversationTitles.set(conv.id, title);
await saveAiConversation(updated);
const i = conversations.value.findIndex((item) => item.id === conv.id);
if (i >= 0) conversations.value[i] = updated;
} catch {
toast(t("ai.conversationRenameFailed"), 5000);
} finally {
cancelRenameConversation();
}
}
function chatMessagesFromConversation(conv: AiConversation): ChatMessage[] {
return conv.messages.map((m) => ({
role: m.role as "user" | "assistant",
@@ -4580,7 +4623,22 @@ async function openExternalUrl(url: string) {
</div>
<div v-else class="max-h-64 overflow-auto p-1">
<div v-for="conv in filteredConversations" :key="conv.id" class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted" :class="{ 'bg-muted': conv.id === conversationId }" @click="selectConversation(conv)">
<span class="min-w-0 flex-1 truncate" :title="conv.title">{{ conv.title }}</span>
<input
v-if="renamingConversationId === conv.id"
:ref="
(element) => {
renamingConversationInput = element as HTMLInputElement | null;
}
"
v-model="renamingConversationTitle"
class="min-w-0 flex-1 rounded border bg-background px-1 py-0.5 text-xs"
@click.stop
@keydown.enter.stop.prevent="commitRenameConversation(conv)"
@keydown.esc.stop.prevent="cancelRenameConversation"
@blur="commitRenameConversation(conv)"
/>
<span v-else class="min-w-0 flex-1 truncate" :title="conv.title">{{ conv.title }}</span>
<button v-if="renamingConversationId !== conv.id" type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground" :title="t('ai.renameConversation')" @click.stop="startRenameConversation(conv)"><Pencil class="h-3 w-3" /></button>
<span v-if="conversationRowDetail(conv).hasQueuedInput" class="shrink-0 rounded border border-primary/40 bg-primary/10 px-1 py-px text-[10px] text-primary" :aria-label="t('ai.rowQueuedInput')" :title="t('ai.rowQueuedInput')">{{ t("ai.rowQueuedInput") }}</span>
<span v-if="conversationRowDetail(conv).status === 'preparing' || conversationRowDetail(conv).status === 'running'" class="flex min-w-0 shrink-0 items-center gap-1 text-muted-foreground" :aria-label="t('ai.runStatusRunning')" :title="t('ai.runStatusRunning')">
<Loader2 class="h-3 w-3 shrink-0 animate-spin" />
@@ -5011,11 +5069,18 @@ async function openExternalUrl(url: string) {
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-64 p-1">
<button v-for="option in dbSelectOptions" :key="option.value" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" @click="toggleDatabase(option.database)">
<Check :class="['h-4 w-4', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
<span class="truncate">{{ option.label }}</span>
</button>
<div v-if="!dbSelectOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">{{ t("editor.selectDatabase") }}</div>
<div class="relative mb-1 flex items-center border-b px-2 py-1">
<Search class="pointer-events-none absolute left-3 h-3 w-3 text-muted-foreground" />
<input v-model="databaseSearchQuery" type="search" class="h-6 w-full bg-transparent pl-5 text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('ai.searchDatabases')" />
</div>
<button v-if="selectedDatabases.length > 1" type="button" class="mb-1 flex w-full items-center justify-end gap-1 border-b px-2 py-1 text-xs" @click.stop="selectedDatabases = []"><X class="h-3 w-3" />{{ t("ai.clearDatabaseSelection") }}</button>
<div class="max-h-64 overflow-y-auto overscroll-contain">
<button v-for="option in filteredDbSelectOptions" :key="option.value" type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted" @click="toggleDatabase(option.database)">
<Check :class="['h-4 w-4', selectedDatabaseValues.has(option.database) ? 'opacity-100' : 'opacity-0']" />
<span class="truncate">{{ option.label }}</span>
</button>
<div v-if="!filteredDbSelectOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">{{ t("ai.noDatabasesFound") }}</div>
</div>
</PopoverContent>
</Popover>
<template v-if="showAiSchemaSelector">
@@ -32,6 +32,10 @@ const AI_HTML_PREVIEW_KEYS = ["htmlPreviewLabel", "htmlExpandPreview", "htmlExpa
// header menu entry, report role labels, failure marker, and empty-state copy.
const AI_CONVERSATION_EXPORT_KEYS = ["exportConversation", "conversationExportMarkdown", "conversationExportHtml", "conversationRoleUser", "conversationRoleAssistant", "conversationFailedMarker", "conversationExportEmpty"] as const;
// Keys conversation rename and the database picker add to the `ai` namespace —
// the picker renders in every locale and a missing key leaks raw keys into it.
const AI_CONVERSATION_MANAGEMENT_KEYS = ["renameConversation", "conversationRenameFailed", "clearDatabaseSelection", "searchDatabases", "noDatabasesFound"] as const;
describe("AI rich content locale parity", () => {
it.each(locales)("%s exposes the full ai.html* key set with non-empty copy", (_name, locale) => {
const ai = (locale as { ai: Record<string, unknown> }).ai;
@@ -48,4 +52,12 @@ describe("AI rich content locale parity", () => {
expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
}
});
it.each(locales)("%s exposes the full conversation-management key set with non-empty copy", (_name, locale) => {
const ai = (locale as { ai: Record<string, unknown> }).ai;
for (const key of AI_CONVERSATION_MANAGEMENT_KEYS) {
expect(ai[key], `${_name}: ai.${key}`).toBeTypeOf("string");
expect(ai[key] as string, `${_name}: ai.${key}`).not.toHaveLength(0);
}
});
});
+5
View File
@@ -3150,6 +3150,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "AI",
conversationFailedMarker: "Bu cavabı yaratmaq mümkün olmadı",
conversationExportEmpty: "Hələ ixrac ediləcək mesaj yoxdur",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "Verilənlər bazalarında axtarış…",
noDatabasesFound: "Verilənlər bazası tapılmadı",
},
contextMenu: {
openConnection: "Əlaqəni aç",
+5
View File
@@ -3283,6 +3283,11 @@ export default {
conversationRoleAssistant: "AI",
conversationFailedMarker: "This reply failed to generate",
conversationExportEmpty: "No messages to export yet",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "Search databases…",
noDatabasesFound: "No databases found",
},
contextMenu: {
openConnection: "Open Connection",
+5
View File
@@ -3061,6 +3061,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "IA",
conversationFailedMarker: "Esta respuesta no se pudo generar",
conversationExportEmpty: "Aún no hay mensajes para exportar",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "Buscar bases de datos…",
noDatabasesFound: "No se encontraron bases de datos",
agentSteps: {
generated: "SQL generado",
noSql: "No se encontró SQL",
+5
View File
@@ -2955,6 +2955,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "IA",
conversationFailedMarker: "Generazione di questa risposta non riuscita",
conversationExportEmpty: "Nessun messaggio da esportare",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "Cerca database…",
noDatabasesFound: "Nessun database trovato",
agentSteps: {
generated: "SQL generato",
noSql: "Nessun SQL trovato",
+5
View File
@@ -3097,6 +3097,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "AI",
conversationFailedMarker: "この返信の生成に失敗しました",
conversationExportEmpty: "エクスポートできるメッセージがありません",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "データベースを検索…",
noDatabasesFound: "データベースが見つかりません",
agentSteps: {
generated: "SQL生成完了",
noSql: "SQLが見つかりません",
+5
View File
@@ -3076,6 +3076,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "AI",
conversationFailedMarker: "이 응답 생성에 실패했습니다",
conversationExportEmpty: "내보낼 메시지가 없습니다",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "데이터베이스 검색…",
noDatabasesFound: "데이터베이스를 찾을 수 없습니다",
},
contextMenu: {
openConnection: "연결 열기",
+5
View File
@@ -3063,6 +3063,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "IA",
conversationFailedMarker: "Falha ao gerar esta resposta",
conversationExportEmpty: "Ainda não há mensagens para exportar",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "Pesquisar bancos de dados…",
noDatabasesFound: "Nenhum banco de dados encontrado",
agentSteps: {
generated: "SQL gerado",
noSql: "Nenhum SQL encontrado",
+5
View File
@@ -3123,6 +3123,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "AI",
conversationFailedMarker: "Bu yanıt oluşturulamadı",
conversationExportEmpty: "Dışa aktarılacak mesaj yok",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "Veritabanlarında ara…",
noDatabasesFound: "Veritabanı bulunamadı",
},
contextMenu: {
openConnection: "Bağlantıyı Aç",
+5
View File
@@ -3197,6 +3197,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "AI",
conversationFailedMarker: "此回复生成失败",
conversationExportEmpty: "暂无可导出的消息",
renameConversation: "重命名会话",
conversationRenameFailed: "会话重命名失败",
clearDatabaseSelection: "清空选择",
searchDatabases: "搜索数据库…",
noDatabasesFound: "未找到数据库",
},
contextMenu: {
openConnection: "打开连接",
+5
View File
@@ -3175,6 +3175,11 @@ export default withEnglishFallback({
conversationRoleAssistant: "AI",
conversationFailedMarker: "此回覆產生失敗",
conversationExportEmpty: "尚無可匯出的訊息",
renameConversation: "Rename conversation",
conversationRenameFailed: "Failed to rename conversation",
clearDatabaseSelection: "Clear selection",
searchDatabases: "搜尋資料庫…",
noDatabasesFound: "未找到資料庫",
},
contextMenu: {
openConnection: "開啟連線",