mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(ai): add conversation history search
This commit is contained in:
@@ -92,6 +92,7 @@ import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
|
||||
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
|
||||
import { saveTextFile } from "@/lib/export/saveTextFile";
|
||||
import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport";
|
||||
import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
@@ -178,6 +179,10 @@ watch(
|
||||
const currentSessionId = ref("");
|
||||
const conversationId = ref("");
|
||||
const conversations = ref<AiConversation[]>([]);
|
||||
const conversationSearchQuery = ref("");
|
||||
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 showTemplateSelector = ref(false);
|
||||
const modeActionOpen = ref(false);
|
||||
@@ -2021,7 +2026,12 @@ async function persistConversation() {
|
||||
|
||||
async function setConversationListOpen(open: boolean) {
|
||||
showConversationList.value = open;
|
||||
if (open) conversations.value = await loadAiConversations().catch(() => []);
|
||||
if (open) {
|
||||
conversationSearchQuery.value = "";
|
||||
await nextTick();
|
||||
conversationSearchInput.value?.focus();
|
||||
conversations.value = await loadAiConversations().catch(() => []);
|
||||
}
|
||||
}
|
||||
|
||||
function selectConversation(conv: AiConversation) {
|
||||
@@ -2220,11 +2230,29 @@ async function openExternalUrl(url: string) {
|
||||
<MessageSquarePlus class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative 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
|
||||
ref="conversationSearchInput"
|
||||
v-model="conversationSearchQuery"
|
||||
type="search"
|
||||
:aria-label="t('history.conversationSearch')"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="h-5 w-full rounded border bg-transparent pl-5 pr-1 text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('history.conversationSearch')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!conversations.length" class="p-3 text-center text-xs text-muted-foreground">
|
||||
{{ t("history.empty") }}
|
||||
</div>
|
||||
<div v-else-if="!filteredConversations.length" class="p-3 text-center text-xs text-muted-foreground">
|
||||
{{ t("history.emptyConversationSearch") }}
|
||||
</div>
|
||||
<div v-else class="max-h-64 overflow-auto p-1">
|
||||
<div v-for="conv in conversations" :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)">
|
||||
<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">{{ conv.title }}</span>
|
||||
<button class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-background hover:text-destructive" @click.stop="deleteConversation(conv.id)">
|
||||
<X class="h-3 w-3" />
|
||||
|
||||
@@ -4425,7 +4425,9 @@ export default {
|
||||
history: {
|
||||
title: "History",
|
||||
search: "Search history...",
|
||||
conversationSearch: "Search conversations...",
|
||||
empty: "No history yet",
|
||||
emptyConversationSearch: "No conversations match your search",
|
||||
emptyFilteredRecent: "No matching records in the latest 200 execution history entries",
|
||||
failed: "Failed",
|
||||
success: "Succeeded",
|
||||
|
||||
@@ -4272,7 +4272,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "Historial",
|
||||
search: "Buscar en historial...",
|
||||
conversationSearch: "Buscar conversaciones...",
|
||||
empty: "Sin historial aún",
|
||||
emptyConversationSearch: "Ninguna conversación coincide con la búsqueda",
|
||||
emptyFilteredRecent: "No hay registros coincidentes en las últimas 200 entradas del historial de ejecución",
|
||||
failed: "Fallida",
|
||||
success: "Exitosa",
|
||||
|
||||
@@ -4270,7 +4270,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "Cronologia",
|
||||
search: "Cerca nella cronologia...",
|
||||
conversationSearch: "Cerca conversazioni...",
|
||||
empty: "Ancora nessuna cronologia",
|
||||
emptyConversationSearch: "Nessuna conversazione corrisponde alla ricerca",
|
||||
emptyFilteredRecent: "Nessun record corrispondente nelle ultime 200 voci della cronologia di esecuzione",
|
||||
failed: "Non riuscita",
|
||||
success: "Riuscita",
|
||||
|
||||
@@ -4300,7 +4300,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "履歴",
|
||||
search: "履歴を検索...",
|
||||
conversationSearch: "会話を検索...",
|
||||
empty: "まだ履歴がありません",
|
||||
emptyConversationSearch: "検索に一致する会話はありません",
|
||||
emptyFilteredRecent: "最近 200 件の実行履歴に一致する記録はありません",
|
||||
failed: "失敗",
|
||||
success: "成功",
|
||||
|
||||
@@ -3929,7 +3929,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "기록",
|
||||
search: "기록 검색...",
|
||||
conversationSearch: "대화 검색...",
|
||||
empty: "아직 기록이 없습니다",
|
||||
emptyConversationSearch: "검색과 일치하는 대화가 없습니다",
|
||||
emptyFilteredRecent: "최근 200개의 실행 기록에서 일치하는 항목이 없습니다",
|
||||
failed: "실패",
|
||||
success: "성공",
|
||||
|
||||
@@ -4272,7 +4272,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "Histórico",
|
||||
search: "Pesquisar histórico...",
|
||||
conversationSearch: "Pesquisar conversas...",
|
||||
empty: "Nenhum histórico ainda",
|
||||
emptyConversationSearch: "Nenhuma conversa corresponde à pesquisa",
|
||||
emptyFilteredRecent: "Nenhum registro correspondente nas 200 entradas mais recentes do histórico de execução",
|
||||
failed: "Falhou",
|
||||
success: "Concluído",
|
||||
|
||||
@@ -4413,7 +4413,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "历史",
|
||||
search: "搜索历史...",
|
||||
conversationSearch: "搜索历史会话...",
|
||||
empty: "暂无历史记录",
|
||||
emptyConversationSearch: "没有匹配的历史会话",
|
||||
emptyFilteredRecent: "最近 200 条执行历史中没有匹配记录",
|
||||
failed: "失败",
|
||||
success: "成功",
|
||||
|
||||
@@ -3616,7 +3616,9 @@ export default withEnglishFallback({
|
||||
history: {
|
||||
title: "歷史",
|
||||
search: "搜尋歷史……",
|
||||
conversationSearch: "搜尋歷史會話……",
|
||||
empty: "暫無歷史記錄",
|
||||
emptyConversationSearch: "沒有相符的歷史會話",
|
||||
emptyFilteredRecent: "最近 200 條執行歷史中沒有相符記錄",
|
||||
failed: "失敗",
|
||||
success: "成功",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
|
||||
|
||||
const conversations = [
|
||||
{
|
||||
id: "mysql-users",
|
||||
title: "Repair user records",
|
||||
connectionName: "dev-mysql",
|
||||
database: "accounts",
|
||||
messages: [
|
||||
{ role: "user", content: "How can I find duplicate email addresses?" },
|
||||
{ role: "assistant", content: "Use a GROUP BY query with HAVING COUNT(*) > 1." },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "postgres-orders",
|
||||
title: "订单排查",
|
||||
connectionName: "prod-postgres",
|
||||
database: "commerce",
|
||||
messages: [{ role: "user", content: "查询昨天失败的订单" }],
|
||||
},
|
||||
];
|
||||
|
||||
describe("AI conversation search index", () => {
|
||||
const index = buildAiConversationSearchIndex(conversations);
|
||||
|
||||
it("finds history by title, connection, database, or message text without case sensitivity", () => {
|
||||
expect(filterAiConversationSearchIndex(index, " REPAIR ").map((conversation) => conversation.id)).toEqual(["mysql-users"]);
|
||||
expect(filterAiConversationSearchIndex(index, "POSTGRES").map((conversation) => conversation.id)).toEqual(["postgres-orders"]);
|
||||
expect(filterAiConversationSearchIndex(index, "ACCOUNTS").map((conversation) => conversation.id)).toEqual(["mysql-users"]);
|
||||
expect(filterAiConversationSearchIndex(index, "duplicate EMAIL").map((conversation) => conversation.id)).toEqual(["mysql-users"]);
|
||||
expect(filterAiConversationSearchIndex(index, "失败的订单").map((conversation) => conversation.id)).toEqual(["postgres-orders"]);
|
||||
});
|
||||
|
||||
it("restores the complete history when the search is cleared", () => {
|
||||
expect(filterAiConversationSearchIndex(index, " ")).toEqual(conversations);
|
||||
});
|
||||
|
||||
it("returns an empty history when no conversation matches", () => {
|
||||
expect(filterAiConversationSearchIndex(index, "oracle")).toEqual([]);
|
||||
});
|
||||
|
||||
it("precomputes normalized searchable text once per conversation", () => {
|
||||
expect(index.map((entry) => entry.searchText)).toEqual(["repair user records\ndev-mysql\naccounts\nhow can i find duplicate email addresses?\nuse a group by query with having count(*) > 1.", "订单排查\nprod-postgres\ncommerce\n查询昨天失败的订单"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface AiConversationSearchItem {
|
||||
title: string;
|
||||
connectionName: string;
|
||||
database: string;
|
||||
messages: readonly { content: string }[];
|
||||
}
|
||||
|
||||
export interface AiConversationSearchEntry<T extends AiConversationSearchItem> {
|
||||
conversation: T;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
export function buildAiConversationSearchIndex<T extends AiConversationSearchItem>(conversations: readonly T[]): AiConversationSearchEntry<T>[] {
|
||||
return conversations.map((conversation) => ({
|
||||
conversation,
|
||||
searchText: [conversation.title, conversation.connectionName, conversation.database, ...conversation.messages.map((message) => message.content)].join("\n").toLowerCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterAiConversationSearchIndex<T extends AiConversationSearchItem>(index: readonly AiConversationSearchEntry<T>[], query: string): T[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return index.map((entry) => entry.conversation);
|
||||
|
||||
return index.filter((entry) => entry.searchText.includes(normalizedQuery)).map((entry) => entry.conversation);
|
||||
}
|
||||
Reference in New Issue
Block a user