Feat/multimodal pipeline #19
@@ -120,6 +120,13 @@ MIGRATIONS: list[str] = [
|
|||||||
PRIMARY KEY(job_id, revision, options_hash)
|
PRIMARY KEY(job_id, revision, options_hash)
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
# v5: application-owned search history, shared by web and desktop clients.
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
query TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -303,9 +303,24 @@ async def rename_note(note_id: str, request: NoteRenameRequest) -> Note:
|
|||||||
# Retrieval and chat
|
# Retrieval and chat
|
||||||
@router.post("/search", response_model=SearchResponse, tags=["Search"])
|
@router.post("/search", response_model=SearchResponse, tags=["Search"])
|
||||||
async def search_notes(request: SearchRequest) -> SearchResponse:
|
async def search_notes(request: SearchRequest) -> SearchResponse:
|
||||||
|
from app.services import search_history
|
||||||
|
search_history.record(request.query)
|
||||||
return await engine.search(request)
|
return await engine.search(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/history", tags=["Search"])
|
||||||
|
async def get_search_history() -> dict[str, list[str]]:
|
||||||
|
from app.services import search_history
|
||||||
|
return {"queries": search_history.list_queries()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/search/history", tags=["Search"])
|
||||||
|
async def clear_search_history() -> dict[str, list[str]]:
|
||||||
|
from app.services import search_history
|
||||||
|
search_history.clear()
|
||||||
|
return {"queries": []}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/chat",
|
"/chat",
|
||||||
response_class=StreamingResponse,
|
response_class=StreamingResponse,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
from app.database.db import connect, transaction
|
||||||
|
|
||||||
|
|
||||||
|
def list_queries():
|
||||||
|
with closing(connect()) as conn:
|
||||||
|
return [row['query'] for row in conn.execute('SELECT query FROM search_history ORDER BY id DESC LIMIT 10')]
|
||||||
|
|
||||||
|
|
||||||
|
def record(query: str):
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
with closing(connect()) as conn, transaction(conn):
|
||||||
|
conn.execute('DELETE FROM search_history WHERE query=?', (query,))
|
||||||
|
conn.execute('INSERT INTO search_history(query) VALUES (?)', (query,))
|
||||||
|
conn.execute('DELETE FROM search_history WHERE id NOT IN (SELECT id FROM search_history ORDER BY id DESC LIMIT 10)')
|
||||||
|
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
with closing(connect()) as conn, transaction(conn):
|
||||||
|
conn.execute('DELETE FROM search_history')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.services import search_history
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_survives_new_clients_and_clear():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
for query in ['first', 'second', ' first ']:
|
||||||
|
assert client.post('/api/search', json={'query': query, 'mode': 'fts'}).status_code == 200
|
||||||
|
assert client.get('/api/search/history').json() == {'queries': ['first', 'second']}
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.get('/api/search/history').json() == {'queries': ['first', 'second']}
|
||||||
|
assert client.delete('/api/search/history').json() == {'queries': []}
|
||||||
|
assert search_history.list_queries() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_is_bounded_and_blank_queries_are_ignored():
|
||||||
|
for number in range(12):
|
||||||
|
search_history.record(str(number))
|
||||||
|
search_history.record(' ')
|
||||||
|
assert search_history.list_queries() == [str(number) for number in range(11, 1, -1)]
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
| POST | `/api/notes/{note_id}/move` | 移动笔记 |
|
| POST | `/api/notes/{note_id}/move` | 移动笔记 |
|
||||||
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
|
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
|
||||||
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 |
|
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 |
|
||||||
|
| GET | `/api/search/history` | 读取当前应用数据库最近 10 条去重搜索记录 |
|
||||||
|
| DELETE | `/api/search/history` | 清空当前应用数据库的搜索记录 |
|
||||||
|
|
||||||
### Workspace
|
### Workspace
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import type { SearchResult } from '@/contracts'
|
import type { SearchResult } from '@/contracts'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
@@ -7,6 +7,7 @@ import { useSearchStore } from '@/stores/search'
|
|||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
|
||||||
const searchStore = useSearchStore()
|
const searchStore = useSearchStore()
|
||||||
|
onMounted(() => { void searchStore.loadHistory() })
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -48,7 +49,7 @@ async function openResult(result: SearchResult) {
|
|||||||
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
|
<div v-if="searchStore.error" class="error-banner">{{ searchStore.error }}</div>
|
||||||
<div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div>
|
<div v-if="searchStore.historyError" class="notice-banner">{{ searchStore.historyError }}</div>
|
||||||
<div v-if="searchStore.recentQueries.length" class="search-history">
|
<div v-if="searchStore.recentQueries.length" class="search-history">
|
||||||
<span class="subtle">最近搜索(保存在当前浏览器)</span>
|
<span class="subtle">最近搜索(保存在应用数据中)</span>
|
||||||
<button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button>
|
<button v-for="item in searchStore.recentQueries" :key="item" class="button-secondary" @click="searchStore.query = item; submitSearch()">{{ item }}</button>
|
||||||
<button class="button-secondary" @click="searchStore.clearHistory">清空记录</button>
|
<button class="button-secondary" @click="searchStore.clearHistory">清空记录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import type { ApiSearchResult, PageMeta, SearchRequest, SearchResult } from '@/contracts'
|
import type { ApiSearchResult, PageMeta, SearchRequest, SearchResult } from '@/contracts'
|
||||||
|
|
||||||
|
export function getHistory() { return apiClient.get<{ queries: string[] }>('/api/search/history') }
|
||||||
|
export function clearHistory() { return apiClient.delete<{ queries: string[] }>('/api/search/history') }
|
||||||
|
|
||||||
export async function search(request: SearchRequest): Promise<{
|
export async function search(request: SearchRequest): Promise<{
|
||||||
results: SearchResult[]
|
results: SearchResult[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@@ -1,34 +1,35 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { useSearchStore } from './search'
|
import { useSearchStore } from './search'
|
||||||
import { search } from '@/services/searchService'
|
import * as service from '@/services/searchService'
|
||||||
|
vi.mock('@/services/searchService', () => ({ search: vi.fn(), getHistory: vi.fn(), clearHistory: vi.fn() }))
|
||||||
vi.mock('@/services/searchService', () => ({ search: vi.fn() }))
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.mocked(search).mockReset().mockResolvedValue({ results: [], total: 0, mode: 'hybrid' })
|
vi.mocked(service.getHistory).mockReset().mockResolvedValue({ queries: ['saved'] })
|
||||||
|
vi.mocked(service.clearHistory).mockReset().mockResolvedValue({ queries: [] })
|
||||||
|
vi.mocked(service.search).mockReset().mockResolvedValue({ results: [], total: 0, mode: 'hybrid' })
|
||||||
})
|
})
|
||||||
|
it('loads application history after recreation and clears through the backend', async () => {
|
||||||
it('persists real queries across store recreation, reorders duplicates and clears history', async () => {
|
await useSearchStore().loadHistory()
|
||||||
|
setActivePinia(createPinia())
|
||||||
const store = useSearchStore()
|
const store = useSearchStore()
|
||||||
|
await store.loadHistory()
|
||||||
|
expect(store.recentQueries).toEqual(['saved'])
|
||||||
|
await store.clearHistory()
|
||||||
|
expect(service.clearHistory).toHaveBeenCalledOnce()
|
||||||
expect(store.recentQueries).toEqual([])
|
expect(store.recentQueries).toEqual([])
|
||||||
await store.doSearch({ query: ' first ' })
|
|
||||||
await store.doSearch({ query: 'second' })
|
|
||||||
await store.doSearch({ query: 'first' })
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
const restored = useSearchStore()
|
|
||||||
expect(restored.recentQueries).toEqual(['first', 'second'])
|
|
||||||
restored.clearHistory()
|
|
||||||
setActivePinia(createPinia())
|
|
||||||
expect(useSearchStore().recentQueries).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
it('retains history and reports a failed delete', async () => {
|
||||||
it('ignores corrupt storage and does not let a stale request overwrite the latest search', async () => {
|
const store = useSearchStore()
|
||||||
localStorage.setItem('notes-agent.search-history.v1', '{bad')
|
await store.loadHistory()
|
||||||
let finish!: (value: Awaited<ReturnType<typeof search>>) => void
|
vi.mocked(service.clearHistory).mockRejectedValue(new Error('offline'))
|
||||||
vi.mocked(search).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
await store.clearHistory()
|
||||||
|
expect(store.recentQueries).toEqual(['saved'])
|
||||||
|
expect(store.historyError).toBeTruthy()
|
||||||
|
})
|
||||||
|
it('ignores stale search responses and reloads server history', async () => {
|
||||||
|
let finish!: (value: Awaited<ReturnType<typeof service.search>>) => void
|
||||||
|
vi.mocked(service.search).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||||
const store = useSearchStore()
|
const store = useSearchStore()
|
||||||
const first = store.doSearch({ query: 'old' })
|
const first = store.doSearch({ query: 'old' })
|
||||||
await store.doSearch({ query: 'new' })
|
await store.doSearch({ query: 'new' })
|
||||||
@@ -36,5 +37,5 @@ it('ignores corrupt storage and does not let a stale request overwrite the lates
|
|||||||
await first
|
await first
|
||||||
expect(store.total).toBe(0)
|
expect(store.total).toBe(0)
|
||||||
expect(store.query).toBe('new')
|
expect(store.query).toBe('new')
|
||||||
expect(store.recentQueries).toEqual(['new', 'old'])
|
expect(store.recentQueries).toEqual(['saved'])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ const VECTOR_ERROR_CODES = new Set([
|
|||||||
'MODEL_NOT_FOUND', 'MODEL_CAPABILITY_MISMATCH', 'PROVIDER_UNAVAILABLE',
|
'MODEL_NOT_FOUND', 'MODEL_CAPABILITY_MISMATCH', 'PROVIDER_UNAVAILABLE',
|
||||||
])
|
])
|
||||||
|
|
||||||
const HISTORY_KEY = 'notes-agent.search-history.v1'
|
|
||||||
function readHistory(): string[] {
|
|
||||||
try {
|
|
||||||
const value: unknown = JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '[]')
|
|
||||||
return Array.isArray(value) ? [...new Set(value.filter((item): item is string => typeof item === 'string').map(item => item.trim()).filter(Boolean))].slice(0, 10) : []
|
|
||||||
} catch { return [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSearchStore = defineStore('search', () => {
|
export const useSearchStore = defineStore('search', () => {
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
|
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
|
||||||
@@ -25,14 +17,27 @@ export const useSearchStore = defineStore('search', () => {
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const isSearching = ref(false)
|
const isSearching = ref(false)
|
||||||
const selectedIndex = ref(0)
|
const selectedIndex = ref(0)
|
||||||
const recentQueries = ref<string[]>(readHistory())
|
const recentQueries = ref<string[]>([])
|
||||||
const historyError = ref('')
|
const historyError = ref('')
|
||||||
let searchVersion = 0
|
let searchVersion = 0
|
||||||
function persistHistory() {
|
let historyVersion = 0
|
||||||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(recentQueries.value)); historyError.value = '' }
|
async function loadHistory() {
|
||||||
catch { historyError.value = '浏览器无法保存搜索记录,本次记录仅保留到页面关闭。' }
|
const version = ++historyVersion
|
||||||
|
try {
|
||||||
|
const response = await searchService.getHistory()
|
||||||
|
if (version !== historyVersion) return
|
||||||
|
recentQueries.value = response.queries
|
||||||
|
historyError.value = ''
|
||||||
|
} catch { if (version === historyVersion) historyError.value = '无法读取应用搜索记录,请检查后端连接。' }
|
||||||
|
}
|
||||||
|
async function clearHistory() {
|
||||||
|
const version = ++historyVersion
|
||||||
|
try {
|
||||||
|
await searchService.clearHistory()
|
||||||
|
if (version !== historyVersion) return
|
||||||
|
recentQueries.value = []; historyError.value = ''
|
||||||
|
} catch { if (version === historyVersion) historyError.value = '清空搜索记录失败,请重试。' }
|
||||||
}
|
}
|
||||||
function clearHistory() { recentQueries.value = []; persistHistory() }
|
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const vectorUnavailable = ref(false)
|
const vectorUnavailable = ref(false)
|
||||||
|
|
||||||
@@ -40,8 +45,6 @@ export const useSearchStore = defineStore('search', () => {
|
|||||||
request = { ...request, query: request.query.trim() }
|
request = { ...request, query: request.query.trim() }
|
||||||
if (!request.query) return
|
if (!request.query) return
|
||||||
const version = ++searchVersion
|
const version = ++searchVersion
|
||||||
recentQueries.value = [request.query, ...recentQueries.value.filter(item => item !== request.query)].slice(0, 10)
|
|
||||||
persistHistory()
|
|
||||||
query.value = request.query
|
query.value = request.query
|
||||||
mode.value = request.mode || 'hybrid'
|
mode.value = request.mode || 'hybrid'
|
||||||
isSearching.value = true
|
isSearching.value = true
|
||||||
@@ -78,7 +81,7 @@ export const useSearchStore = defineStore('search', () => {
|
|||||||
total.value = 0
|
total.value = 0
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (version === searchVersion) isSearching.value = false
|
if (version === searchVersion) { isSearching.value = false; await loadHistory() }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,7 @@ export const useSearchStore = defineStore('search', () => {
|
|||||||
recentQueries,
|
recentQueries,
|
||||||
historyError,
|
historyError,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
|
loadHistory,
|
||||||
error,
|
error,
|
||||||
vectorUnavailable,
|
vectorUnavailable,
|
||||||
doSearch,
|
doSearch,
|
||||||
|
|||||||
Reference in New Issue
Block a user