diff --git a/core/http/react-ui/e2e/chats-origin.spec.js b/core/http/react-ui/e2e/chats-origin.spec.js index 6104a40f76..64b288058b 100644 --- a/core/http/react-ui/e2e/chats-origin.spec.js +++ b/core/http/react-ui/e2e/chats-origin.spec.js @@ -119,11 +119,11 @@ test.describe('Chats that no person started', () => { await page.getByTestId('chats-trigger').click() const menu = page.getByTestId('chats-menu') await expect(menu.getByTestId('chats-row')).toHaveCount(8) - await menu.getByRole('combobox').fill('alice') + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('alice') const row = menu.getByTestId('chats-row') await expect(row).toHaveCount(1) await expect(row.getByTestId('chats-origin-badge')).toHaveText('Team assistant') - await menu.getByRole('combobox').fill('Plan') + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('Plan') await expect(menu.getByTestId('chats-row').getByTestId('chats-origin-badge')).toHaveCount(0) }) diff --git a/core/http/react-ui/e2e/chats-search.js b/core/http/react-ui/e2e/chats-search.js new file mode 100644 index 0000000000..5f2e33d246 --- /dev/null +++ b/core/http/react-ui/e2e/chats-search.js @@ -0,0 +1,59 @@ +// Stubs and fixtures of the chat search specs: a list API, the chats a search +// can find, a long transcript and a search route that records its parameters. +import { mockChat } from './chat-fixtures.js' + +export const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString() + +export const RECENT = [ + { id: 'c1', title: 'Vector database setup', model: 'qwen3-8b', kind: 'chat', created: minutesAgo(300), updated: minutesAgo(20), running: false }, + { id: 'c2', title: 'Plan the week', model: 'qwen3-8b', kind: 'chat', agent_id: 'helper', created: minutesAgo(900), updated: minutesAgo(600), running: false }, +] +// A chat the list of recent chats does not hold: only the search finds it. +export const OLD = { id: 'old1', title: 'Backup notes', model: 'qwen3-8b', kind: 'chat', created: '2025-01-10T09:00:00Z', updated: '2025-01-10T09:30:00Z', running: false } +export const TRIG = { id: 't1', title: 'Morning digest', model: 'qwen3-8b', kind: 'chat', origin: 'trigger', origin_id: 'trig1', created: minutesAgo(200), updated: minutesAgo(180), running: false } + +export const hit = (chat, seq, role, snippet, highlights, extra = {}) => ({ chat, in_title: false, seq, role, at: minutesAgo(30), snippet, highlights, matches: 1, ...extra }) + +// A long transcript, so that a deep link has to scroll. +export const TRANSCRIPT = Array.from({ length: 60 }, (_, i) => ({ + seq: i + 1, turn: Math.floor(i / 2) + 1, role: i % 2 === 0 ? 'user' : 'assistant', + text: i === 41 ? 'The Qdrant port is 6333.' : `Message number ${i + 1}. ${'Filler text for the transcript. '.repeat(6)}`, at: minutesAgo(400 - i), +})) + +export async function mockSearch(page, { answer, delay = 0, fail = false } = {}) { + const requests = [] + await mockChat(page) + await page.route(/\/api\/chats(\?.*)?$/, (route) => { + const origin = new URL(route.request().url()).searchParams.get('origin') || '' + const conversations = origin === 'automated' ? [TRIG] : origin === 'person' ? RECENT : [...RECENT, TRIG] + route.fulfill({ json: { conversations, persistent: true, files: true } }) + }) + await page.route('**/api/chats/agents', route => route.fulfill({ json: { agents: [{ id: 'helper', name: 'Helper' }, { id: 'writer', name: 'Writer' }] } })) + await page.route(/\/api\/chats\/connectors(\?.*)?$/, route => route.fulfill({ json: { connectors: [] } })) + await page.route('**/api/chats/connectors/kinds', route => route.fulfill({ json: { kinds: [] } })) + await page.route(/\/api\/chats\/triggers(\?.*)?$/, route => route.fulfill({ json: { triggers: [{ id: 'trig1', name: 'Morning digest', kind: 'schedule', enabled: true }] } })) + await page.route(/\/api\/chats\/notifications(\?.*)?$/, route => route.fulfill({ json: { notifications: [], unread: 0, total: 0 } })) + await page.route('**/api/chats/notifications/events', route => route.abort()) + await page.route(/\/api\/chats\/search(\?.*)?$/, async (route) => { + const params = Object.fromEntries(new URL(route.request().url()).searchParams) + requests.push(params) + if (delay) await new Promise(r => setTimeout(r, delay)) + if (fail) return route.fulfill({ status: 500, json: { error: { message: 'boom' } } }) + return route.fulfill({ json: typeof answer === 'function' ? answer(params) : answer }) + }) + for (const c of [...RECENT, OLD, TRIG]) { + await page.route(`**/api/chats/${c.id}/events`, route => route.abort()) + await page.route(new RegExp(`/api/chats/${c.id}/messages(\\?.*)?$`), route => route.fulfill({ json: { messages: c.id === 'c1' ? TRANSCRIPT : [{ seq: 1, turn: 1, role: 'user', text: 'hello', at: c.created }] } })) + await page.route(new RegExp(`/api/chats/${c.id}$`), route => route.fulfill({ json: { ...c, pending: [] } })) + } + return { requests } +} + +export const TWO_HITS = { + engine: 'fts5', + hits: [ + hit(RECENT[0], 42, 'assistant', 'The Qdrant port is 6333.', [{ start: 4, end: 10 }, { start: 11, end: 15 }], { matches: 3 }), + hit(OLD, 7, 'user', 'Which port does qdrant use?', [{ start: 9, end: 13 }]), + ], +} + diff --git a/core/http/react-ui/e2e/chats-search.spec.js b/core/http/react-ui/e2e/chats-search.spec.js new file mode 100644 index 0000000000..6d1b85d5ba --- /dev/null +++ b/core/http/react-ui/e2e/chats-search.spec.js @@ -0,0 +1,210 @@ +import { test, expect } from './coverage-fixtures.js' +import { RECENT, TRIG, TWO_HITS, hit, mockSearch } from './chats-search.js' + +// Search across all chats: the Ctrl or Cmd K dialog of a transcript and the All +// chats page show the hits of GET /api/chats/search best first, each with the +// words that matched marked in a snippet, filters for agent, origin and date, +// keyboard control, and a link that opens the chat at the matching message. +// The API is stubbed. + +async function openDialog(page) { + await page.goto('/app/chats/c1') + await page.getByTestId('chats-trigger').click() + return page.getByTestId('chats-menu') +} + +test.describe('Chat search in the Ctrl K dialog', () => { + test('shows the hits with the matched words marked, and never reads message text as HTML', async ({ page }) => { + await mockSearch(page, { answer: TWO_HITS }) + const menu = await openDialog(page) + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant port') + const rows = menu.getByTestId('chats-row') + await expect(rows).toHaveCount(2) + await expect(menu.getByTestId('chats-search-status')).toContainText('2 results') + // The chat that only the search can find is in the list. + await expect(rows.nth(1)).toContainText('Backup notes') + const first = rows.nth(0).getByTestId('chats-snippet') + await expect(first).toContainText('Assistant') + await expect(first.locator('mark')).toHaveText(['Qdrant', 'port']) + await expect(rows.nth(0)).toContainText('2 more matches in this chat') + // The second snippet holds tags: they show as text, and nothing runs. + const second = rows.nth(1).getByTestId('chats-snippet') + await expect(second).toContainText('') + await expect(second.locator('img, b')).toHaveCount(0) + await expect(second.locator('mark')).toHaveText(['port']) + expect(await page.evaluate(() => window.__pwned)).toBeUndefined() + }) + + test('arrows move through the hits, Enter opens the hit at its message', async ({ page }) => { + await mockSearch(page, { answer: TWO_HITS }) + const menu = await openDialog(page) + const box = menu.getByRole('combobox', { name: 'Search conversations...' }) + await box.fill('qdrant') + const rows = menu.getByTestId('chats-row') + await expect(rows).toHaveCount(2) + await expect(rows.nth(0)).toHaveAttribute('aria-selected', 'true') + await box.press('ArrowDown') + await expect(rows.nth(1)).toHaveAttribute('aria-selected', 'true') + await expect(box).toHaveAttribute('aria-activedescendant', /old1:7$/) + await box.press('ArrowUp') + await expect(rows.nth(0)).toHaveAttribute('aria-selected', 'true') + await box.press('Enter') + await expect(page).toHaveURL(/\/app\/chats\/c1\?seq=42$/) + await expect(menu).toHaveCount(0) + }) + + test('the first Escape clears the search, the second closes the dialog', async ({ page }) => { + await mockSearch(page, { answer: TWO_HITS }) + const menu = await openDialog(page) + const box = menu.getByRole('combobox', { name: 'Search conversations...' }) + await box.fill('qdrant') + await expect(menu.getByTestId('chats-row')).toHaveCount(2) + await box.press('Escape') + await expect(box).toHaveValue('') + await expect(menu).toBeVisible() + await expect(menu.getByTestId('chats-day-today')).toBeVisible() + await box.press('Escape') + await expect(menu).toHaveCount(0) + }) + + test('filters narrow the search and Reset takes them off', async ({ page }) => { + const { requests } = await mockSearch(page, { answer: TWO_HITS }) + const menu = await openDialog(page) + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant') + await expect(menu.getByTestId('chats-row')).toHaveCount(2) + await menu.getByTestId('chats-filter-agent').selectOption('helper') + await expect.poll(() => requests.at(-1)?.agent).toBe('helper') + await menu.getByTestId('chats-filter-origin').selectOption('trigger') + await expect.poll(() => requests.at(-1)?.origin).toBe('trigger') + await menu.getByTestId('chats-filter-range').selectOption('week') + await expect.poll(() => Date.now() - Date.parse(requests.at(-1)?.since)).toBeGreaterThan(6.9 * 86400000) + await menu.getByTestId('chats-filter-range').selectOption('custom') + await menu.getByTestId('chats-filter-from').fill('2025-01-01') + await menu.getByTestId('chats-filter-to').fill('2025-01-31') + await expect.poll(() => requests.at(-1)).toMatchObject({ agent: 'helper', origin: 'trigger', since: '2025-01-01', until: '2025-01-31', q: 'qdrant' }) + await menu.getByTestId('chats-filter-reset').click() + await expect.poll(() => Object.keys(requests.at(-1)).sort()).toEqual(['limit', 'q']) + await expect(menu.getByTestId('chats-filter-reset')).toHaveCount(0) + }) + + test('says so when nothing matches, and names the filters when they are on', async ({ page }) => { + await mockSearch(page, { answer: { hits: [] } }) + const menu = await openDialog(page) + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('zzzz') + await expect(menu.getByTestId('chats-empty')).toHaveText('No chats match “zzzz”.') + await menu.getByTestId('chats-filter-origin').selectOption('job') + await expect(menu.getByTestId('chats-empty')).toHaveText('No chats match “zzzz” with these filters.') + }) + + test('shows a loading state, keeps the old hits dimmed, and says when older chats are still being indexed', async ({ page }) => { + await mockSearch(page, { answer: (p) => ({ ...TWO_HITS, indexing: p.q === 'qdrant', partial: p.q === 'port', searched_chats: 40, total_chats: 120 }), delay: 400 }) + const menu = await openDialog(page) + const box = menu.getByRole('combobox', { name: 'Search conversations...' }) + await box.fill('qdrant') + await expect(menu.getByTestId('chats-search-status')).toHaveText('Searching…') + await expect(menu.getByTestId('chats-loading')).toBeVisible() + await expect(menu.getByTestId('chats-row')).toHaveCount(2) + await expect(menu.getByTestId('chats-search-indexing')).toContainText('still being indexed') + await box.fill('port') + await expect(menu.getByTestId('chats-row')).toHaveCount(2) + await expect(menu.locator('.cx-find-list[data-dim]')).toBeVisible() + await expect(menu.getByTestId('chats-search-partial')).toHaveText('Searched the newest 40 of 120 chats.') + }) + + test('shows an error with Try again', async ({ page }) => { + let fail = true + await mockSearch(page, { answer: () => TWO_HITS }) + await page.route(/\/api\/chats\/search(\?.*)?$/, route => (fail ? route.fulfill({ status: 500, json: { error: { message: 'boom' } } }) : route.fallback()), { times: 1 }) + const menu = await openDialog(page) + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant') + await expect(menu.getByTestId('chats-search-error')).toContainText('The search failed.') + fail = false + await menu.getByRole('button', { name: 'Try again' }).click() + await expect(menu.getByTestId('chats-row')).toHaveCount(2) + }) +}) + +test.describe('Chat search on the All chats page', () => { + test('finds a chat that the list does not hold and opens it at the message', async ({ page }) => { + await mockSearch(page, { answer: TWO_HITS }) + await page.goto('/app/chats/all') + const list = page.getByTestId('all-chats-list') + await list.getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant') + const rows = list.getByTestId('chats-row') + await expect(rows).toHaveCount(2) + await rows.nth(1).click() + await expect(page).toHaveURL(/\/app\/chats\/old1\?seq=7$/) + }) + + test('a title-only hit opens the chat without a message', async ({ page }) => { + await mockSearch(page, { answer: { hits: [{ chat: RECENT[1], in_title: true }] } }) + await page.goto('/app/chats/all') + const list = page.getByTestId('all-chats-list') + await list.getByRole('combobox', { name: 'Search conversations...' }).fill('plan') + await expect(list.getByTestId('chats-row')).toContainText('Matches the title') + await list.getByTestId('chats-row').click() + await expect(page).toHaveURL(/\/app\/chats\/c2$/) + }) + + test('a chat started by a trigger carries the name of the trigger', async ({ page }) => { + await mockSearch(page, { answer: { hits: [hit(TRIG, 1, 'assistant', 'The digest has the Qdrant news.', [{ start: 18, end: 24 }])] } }) + await page.goto('/app/chats/all') + const list = page.getByTestId('all-chats-list') + await list.getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant') + await expect(list.getByTestId('chats-origin-badge')).toHaveText('Morning digest') + }) +}) + +test.describe('The link to a message', () => { + test('scrolls to the message of ?seq and marks it for a moment', async ({ page }) => { + await mockSearch(page, { answer: { hits: [] } }) + await page.goto('/app/chats/c1?seq=42') + const msg = page.locator('[data-testid="chat-message"][data-index="42"]') + await expect(msg).toBeVisible() + await expect(msg).toHaveAttribute('data-flash', 'on') + await expect(msg).toBeInViewport() + await expect(msg).not.toHaveAttribute('data-flash', /.*/, { timeout: 6000 }) + // The first lines are above the fold. + await expect(page.locator('[data-testid="chat-message"][data-index="1"]')).not.toBeInViewport() + }) + + test('ignores a seq that is not in the chat', async ({ page }) => { + await mockSearch(page, { answer: { hits: [] } }) + await page.goto('/app/chats/c1?seq=999') + await expect(page.locator('[data-testid="chat-message"]').first()).toBeVisible() + await expect(page.locator('[data-flash]')).toHaveCount(0) + }) + + test('a hit in the open chat jumps again on a second click', async ({ page }) => { + await mockSearch(page, { answer: { hits: [hit(RECENT[0], 42, 'assistant', 'The Qdrant port is 6333.', [{ start: 4, end: 10 }])] } }) + await page.goto('/app/chats/c1?seq=42') + await expect(page.locator('[data-index="42"]')).toBeInViewport() + await page.evaluate(() => document.querySelector('[data-testid="chat-message"][data-index="1"]').scrollIntoView()) + await expect(page.locator('[data-index="42"]')).not.toBeInViewport() + await page.getByTestId('chats-trigger').click() + await page.getByTestId('chats-menu').getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant') + await page.getByTestId('chats-menu').getByTestId('chats-row').click() + await expect(page.locator('[data-index="42"]')).toBeInViewport() + }) +}) + +test.describe('Chat search on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + test('fits the screen: filters wrap, rows do not overflow', async ({ page }) => { + await mockSearch(page, { answer: TWO_HITS }) + await page.goto('/app/chats/c1') + await page.getByTestId('chats-trigger').click() + const menu = page.getByTestId('chats-menu') + await menu.getByRole('combobox', { name: 'Search conversations...' }).fill('qdrant port') + await expect(menu.getByTestId('chats-row')).toHaveCount(2) + const box = await menu.boundingBox() + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + const overflow = await menu.evaluate(el => el.scrollWidth - el.clientWidth) + expect(overflow).toBeLessThanOrEqual(0) + for (const sel of ['chats-filter-origin', 'chats-filter-range']) { + const b = await menu.getByTestId(sel).boundingBox() + expect(b.x + b.width).toBeLessThanOrEqual(390) + } + }) +}) diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index e6ddb3bf5f..6df4e4379a 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -113,6 +113,50 @@ "job": "Job", "page": "Seite", "other": "Automatisch" + }, + "keys": { + "clear": "leeren" + }, + "find": { + "searching": "Suche läuft…", + "count_one": "{{count}} Treffer", + "count_other": "{{count}} Treffer", + "more_one": "{{count}} weiterer Treffer in diesem Chat", + "more_other": "{{count}} weitere Treffer in diesem Chat", + "indexing": "Ältere Chats werden noch indexiert. Die Ergebnisse können unvollständig sein.", + "partial": "Die neuesten {{searched}} von {{total}} Chats wurden durchsucht.", + "failed": "Die Suche ist fehlgeschlagen.", + "retry": "Erneut versuchen", + "noMatchFor": "Kein Chat passt zu „{{query}}“.", + "noMatchFiltered": "Kein Chat passt mit diesen Filtern zu „{{query}}“.", + "inTitle": "Passt zum Titel", + "you": "Du", + "assistant": "Assistent", + "filters": "Suchfilter", + "agent": "Agent", + "anyAgent": "Jeder Agent", + "origin": "Quelle", + "anyOrigin": "Überall", + "originName": { + "person": "Meine Chats", + "automated": "Add-ons und Automatisierungen", + "trigger": "Trigger", + "connector": "Messaging-Apps", + "job": "Jobs", + "page": "Seiten" + }, + "when": "Wann", + "anyTime": "Jederzeit", + "range": { + "day": "Letzte 24 Stunden", + "week": "Letzte 7 Tage", + "month": "Letzte 30 Tage", + "year": "Letztes Jahr", + "custom": "Datum wählen…" + }, + "from": "Von", + "to": "Bis", + "reset": "Filter zurücksetzen" } }, "message": { diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 46cd48f87a..f289fcda0c 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -166,7 +166,8 @@ "move": "move", "open": "open", "rename": "rename", - "delete": "delete" + "delete": "delete", + "clear": "clear" }, "deleted": "Deleted \"{{title}}\"", "undo": "Undo", @@ -187,6 +188,47 @@ "job": "Job", "page": "Page", "other": "Automated" + }, + "find": { + "searching": "Searching…", + "count_one": "{{count}} result", + "count_other": "{{count}} results", + "more_one": "{{count}} more match in this chat", + "more_other": "{{count}} more matches in this chat", + "indexing": "Older chats are still being indexed. Results may be incomplete.", + "partial": "Searched the newest {{searched}} of {{total}} chats.", + "failed": "The search failed.", + "retry": "Try again", + "noMatchFor": "No chats match “{{query}}”.", + "noMatchFiltered": "No chats match “{{query}}” with these filters.", + "inTitle": "Matches the title", + "you": "You", + "assistant": "Assistant", + "filters": "Search filters", + "agent": "Agent", + "anyAgent": "Any agent", + "origin": "Source", + "anyOrigin": "Anywhere", + "originName": { + "person": "My chats", + "automated": "Add-ons and automations", + "trigger": "Triggers", + "connector": "Messaging apps", + "job": "Jobs", + "page": "Pages" + }, + "when": "When", + "anyTime": "Any time", + "range": { + "day": "Last 24 hours", + "week": "Last 7 days", + "month": "Last 30 days", + "year": "Last year", + "custom": "Choose dates…" + }, + "from": "From", + "to": "To", + "reset": "Reset filters" } }, "message": { diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index e61303261e..efb365b805 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -113,6 +113,50 @@ "job": "Trabajo", "page": "Página", "other": "Automático" + }, + "keys": { + "clear": "borrar" + }, + "find": { + "searching": "Buscando…", + "count_one": "{{count}} resultado", + "count_other": "{{count}} resultados", + "more_one": "{{count}} coincidencia más en este chat", + "more_other": "{{count}} coincidencias más en este chat", + "indexing": "Los chats antiguos aún se están indexando. Los resultados pueden estar incompletos.", + "partial": "Se buscó en los {{searched}} chats más recientes de {{total}}.", + "failed": "La búsqueda ha fallado.", + "retry": "Reintentar", + "noMatchFor": "Ningún chat coincide con «{{query}}».", + "noMatchFiltered": "Ningún chat coincide con «{{query}}» con estos filtros.", + "inTitle": "Coincide con el título", + "you": "Tú", + "assistant": "Asistente", + "filters": "Filtros de búsqueda", + "agent": "Agente", + "anyAgent": "Cualquier agente", + "origin": "Origen", + "anyOrigin": "En cualquier lugar", + "originName": { + "person": "Mis chats", + "automated": "Complementos y automatizaciones", + "trigger": "Disparadores", + "connector": "Apps de mensajería", + "job": "Tareas", + "page": "Páginas" + }, + "when": "Cuándo", + "anyTime": "Cualquier fecha", + "range": { + "day": "Últimas 24 horas", + "week": "Últimos 7 días", + "month": "Últimos 30 días", + "year": "Último año", + "custom": "Elegir fechas…" + }, + "from": "Desde", + "to": "Hasta", + "reset": "Restablecer filtros" } }, "message": { diff --git a/core/http/react-ui/public/locales/fr/chat.json b/core/http/react-ui/public/locales/fr/chat.json index 05f1c3b0cd..51f2fde8b5 100644 --- a/core/http/react-ui/public/locales/fr/chat.json +++ b/core/http/react-ui/public/locales/fr/chat.json @@ -111,6 +111,50 @@ "job": "Tâche", "page": "Page", "other": "Automatique" + }, + "keys": { + "clear": "effacer" + }, + "find": { + "searching": "Recherche en cours…", + "count_one": "{{count}} résultat", + "count_other": "{{count}} résultats", + "more_one": "{{count}} autre correspondance dans ce chat", + "more_other": "{{count}} autres correspondances dans ce chat", + "indexing": "Les anciens chats sont encore en cours d’indexation. Les résultats peuvent être incomplets.", + "partial": "Recherche dans les {{searched}} chats les plus récents sur {{total}}.", + "failed": "La recherche a échoué.", + "retry": "Réessayer", + "noMatchFor": "Aucun chat ne correspond à « {{query}} ».", + "noMatchFiltered": "Aucun chat ne correspond à « {{query}} » avec ces filtres.", + "inTitle": "Correspond au titre", + "you": "Vous", + "assistant": "Assistant", + "filters": "Filtres de recherche", + "agent": "Agent", + "anyAgent": "Tous les agents", + "origin": "Source", + "anyOrigin": "Partout", + "originName": { + "person": "Mes chats", + "automated": "Modules et automatisations", + "trigger": "Déclencheurs", + "connector": "Applis de messagerie", + "job": "Tâches", + "page": "Pages" + }, + "when": "Quand", + "anyTime": "À tout moment", + "range": { + "day": "Dernières 24 heures", + "week": "7 derniers jours", + "month": "30 derniers jours", + "year": "Dernière année", + "custom": "Choisir des dates…" + }, + "from": "Du", + "to": "Au", + "reset": "Réinitialiser les filtres" } }, "message": { diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index 4cba3d9b7d..78b3ca0c12 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -158,11 +158,53 @@ "move": "pindah", "open": "buka", "rename": "ganti nama", - "delete": "hapus" + "delete": "hapus", + "clear": "hapus" }, "deleted": "\"{{title}}\" telah dihapus", "undo": "Urungkan", - "dismiss": "Tutup" + "dismiss": "Tutup", + "find": { + "searching": "Mencari…", + "count_one": "{{count}} hasil", + "count_other": "{{count}} hasil", + "more_one": "{{count}} kecocokan lain di chat ini", + "more_other": "{{count}} kecocokan lain di chat ini", + "indexing": "Chat lama masih diindeks. Hasil mungkin belum lengkap.", + "partial": "Mencari di {{searched}} chat terbaru dari {{total}}.", + "failed": "Pencarian gagal.", + "retry": "Coba lagi", + "noMatchFor": "Tidak ada chat yang cocok dengan “{{query}}”.", + "noMatchFiltered": "Tidak ada chat yang cocok dengan “{{query}}” dengan filter ini.", + "inTitle": "Cocok dengan judul", + "you": "Anda", + "assistant": "Asisten", + "filters": "Filter pencarian", + "agent": "Agen", + "anyAgent": "Agen apa pun", + "origin": "Sumber", + "anyOrigin": "Di mana saja", + "originName": { + "person": "Chat saya", + "automated": "Add-on dan otomatisasi", + "trigger": "Pemicu", + "connector": "Aplikasi pesan", + "job": "Job", + "page": "Halaman" + }, + "when": "Kapan", + "anyTime": "Kapan saja", + "range": { + "day": "24 jam terakhir", + "week": "7 hari terakhir", + "month": "30 hari terakhir", + "year": "Setahun terakhir", + "custom": "Pilih tanggal…" + }, + "from": "Dari", + "to": "Sampai", + "reset": "Atur ulang filter" + } }, "message": { "you": "Anda", diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index de81444873..cb0ec9e8ac 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -113,6 +113,50 @@ "job": "Job", "page": "Pagina", "other": "Automatico" + }, + "keys": { + "clear": "cancella" + }, + "find": { + "searching": "Ricerca in corso…", + "count_one": "{{count}} risultato", + "count_other": "{{count}} risultati", + "more_one": "{{count}} altra corrispondenza in questa chat", + "more_other": "{{count}} altre corrispondenze in questa chat", + "indexing": "Le chat più vecchie sono ancora in fase di indicizzazione. I risultati potrebbero essere incompleti.", + "partial": "Cercato nelle {{searched}} chat più recenti su {{total}}.", + "failed": "La ricerca non è riuscita.", + "retry": "Riprova", + "noMatchFor": "Nessuna chat corrisponde a “{{query}}”.", + "noMatchFiltered": "Nessuna chat corrisponde a “{{query}}” con questi filtri.", + "inTitle": "Corrisponde al titolo", + "you": "Tu", + "assistant": "Assistente", + "filters": "Filtri di ricerca", + "agent": "Agente", + "anyAgent": "Qualsiasi agente", + "origin": "Da", + "anyOrigin": "Ovunque", + "originName": { + "person": "Le mie chat", + "automated": "Add-on e automazioni", + "trigger": "Trigger", + "connector": "App di messaggistica", + "job": "Job", + "page": "Pagine" + }, + "when": "Quando", + "anyTime": "In qualsiasi momento", + "range": { + "day": "Ultime 24 ore", + "week": "Ultimi 7 giorni", + "month": "Ultimi 30 giorni", + "year": "Ultimo anno", + "custom": "Scegli le date…" + }, + "from": "Dal", + "to": "Al", + "reset": "Azzera i filtri" } }, "message": { diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index 018f7dd347..1af7a0a8dc 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -113,6 +113,50 @@ "job": "작업", "page": "페이지", "other": "자동" + }, + "keys": { + "clear": "지우기" + }, + "find": { + "searching": "검색 중…", + "count_one": "결과 {{count}}개", + "count_other": "결과 {{count}}개", + "more_one": "이 대화에 일치 항목 {{count}}개 더 있음", + "more_other": "이 대화에 일치 항목 {{count}}개 더 있음", + "indexing": "이전 대화를 아직 색인하는 중입니다. 결과가 완전하지 않을 수 있습니다.", + "partial": "전체 {{total}}개 대화 중 최신 {{searched}}개를 검색했습니다.", + "failed": "검색에 실패했습니다.", + "retry": "다시 시도", + "noMatchFor": "“{{query}}”와(과) 일치하는 대화가 없습니다.", + "noMatchFiltered": "이 필터로 “{{query}}”와(과) 일치하는 대화가 없습니다.", + "inTitle": "제목과 일치", + "you": "나", + "assistant": "어시스턴트", + "filters": "검색 필터", + "agent": "에이전트", + "anyAgent": "모든 에이전트", + "origin": "출처", + "anyOrigin": "모든 곳", + "originName": { + "person": "내 대화", + "automated": "애드온 및 자동화", + "trigger": "트리거", + "connector": "메신저 앱", + "job": "작업", + "page": "페이지" + }, + "when": "시기", + "anyTime": "전체 기간", + "range": { + "day": "최근 24시간", + "week": "최근 7일", + "month": "최근 30일", + "year": "최근 1년", + "custom": "날짜 선택…" + }, + "from": "시작", + "to": "종료", + "reset": "필터 초기화" } }, "message": { diff --git a/core/http/react-ui/public/locales/pt-BR/chat.json b/core/http/react-ui/public/locales/pt-BR/chat.json index b17577a9c8..ea75ffe568 100644 --- a/core/http/react-ui/public/locales/pt-BR/chat.json +++ b/core/http/react-ui/public/locales/pt-BR/chat.json @@ -116,6 +116,50 @@ "job": "Job", "page": "Página", "other": "Automático" + }, + "keys": { + "clear": "limpar" + }, + "find": { + "searching": "Pesquisando…", + "count_one": "{{count}} resultado", + "count_other": "{{count}} resultados", + "more_one": "mais {{count}} ocorrência neste chat", + "more_other": "mais {{count}} ocorrências neste chat", + "indexing": "Os chats antigos ainda estão sendo indexados. Os resultados podem estar incompletos.", + "partial": "Pesquisados os {{searched}} chats mais recentes de {{total}}.", + "failed": "A pesquisa falhou.", + "retry": "Tentar de novo", + "noMatchFor": "Nenhum chat corresponde a “{{query}}”.", + "noMatchFiltered": "Nenhum chat corresponde a “{{query}}” com estes filtros.", + "inTitle": "Corresponde ao título", + "you": "Você", + "assistant": "Assistente", + "filters": "Filtros de pesquisa", + "agent": "Agente", + "anyAgent": "Qualquer agente", + "origin": "Origem", + "anyOrigin": "Qualquer lugar", + "originName": { + "person": "Meus chats", + "automated": "Add-ons e automações", + "trigger": "Gatilhos", + "connector": "Apps de mensagens", + "job": "Jobs", + "page": "Páginas" + }, + "when": "Quando", + "anyTime": "Qualquer data", + "range": { + "day": "Últimas 24 horas", + "week": "Últimos 7 dias", + "month": "Últimos 30 dias", + "year": "Último ano", + "custom": "Escolher datas…" + }, + "from": "De", + "to": "Até", + "reset": "Limpar filtros" } }, "message": { diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index 4815cad3cb..c662137dd4 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -113,6 +113,50 @@ "job": "作业", "page": "页面", "other": "自动" + }, + "keys": { + "clear": "清除" + }, + "find": { + "searching": "正在搜索…", + "count_one": "{{count}} 条结果", + "count_other": "{{count}} 条结果", + "more_one": "此对话中还有 {{count}} 处匹配", + "more_other": "此对话中还有 {{count}} 处匹配", + "indexing": "较早的对话仍在建立索引,结果可能不完整。", + "partial": "已搜索 {{total}} 个对话中最新的 {{searched}} 个。", + "failed": "搜索失败。", + "retry": "重试", + "noMatchFor": "没有与“{{query}}”匹配的对话。", + "noMatchFiltered": "在这些筛选条件下,没有与“{{query}}”匹配的对话。", + "inTitle": "匹配标题", + "you": "你", + "assistant": "助手", + "filters": "搜索筛选", + "agent": "智能体", + "anyAgent": "所有智能体", + "origin": "来源", + "anyOrigin": "任何来源", + "originName": { + "person": "我的对话", + "automated": "附加组件和自动化", + "trigger": "触发器", + "connector": "消息应用", + "job": "作业", + "page": "页面" + }, + "when": "时间", + "anyTime": "不限时间", + "range": { + "day": "最近 24 小时", + "week": "最近 7 天", + "month": "最近 30 天", + "year": "最近一年", + "custom": "选择日期…" + }, + "from": "从", + "to": "到", + "reset": "重置筛选" } }, "message": { diff --git a/core/http/react-ui/src/components/ChatsList.jsx b/core/http/react-ui/src/components/ChatsList.jsx index 28a25dba8f..d2ea6714c7 100644 --- a/core/http/react-ui/src/components/ChatsList.jsx +++ b/core/http/react-ui/src/components/ChatsList.jsx @@ -2,15 +2,21 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { groupConversations } from '../utils/homeConversations' import Icon from './Icon' -import { knownKind } from '../utils/originChats' +import { knownKind, resolveOrigin } from '../utils/originChats' +import { menuChats } from '../utils/chatThread' +import { chatAgentsApi } from '../utils/api' +import { useOriginContext } from '../hooks/useOriginContext' +import { filtersActive, filtersKey, hitRows, noFilters, searchParams } from '../utils/chatSearch' +import SearchSnippet from './chat/SearchSnippet' +import SearchFilters from './chat/SearchFilters' // The chats come from the server list (see menuChats in utils/chatThread.js): -// a name, the model and the times. The names are matched here at once; with -// onSearch the server also finds the chats whose messages hold the text -// (GET /api/chats/search), and the line under a name shows where it matched. -function matches(chat, query) { - return (chat.name || '').toLowerCase().includes(query) -} +// a name, the model and the times. With onSearch, a search of two characters +// or more leaves the day blocks: the server searches every chat, titles and +// messages (GET /api/chats/search), and the list shows its hits best first, +// each with the words that matched marked in a snippet. A hit opens its chat +// at the message (onSelect gets { seq }). Filters narrow the search by agent, +// origin and date. const SEARCH_DELAY_MS = 250 @@ -48,40 +54,65 @@ export default function ChatsList({ const searchRef = useRef(null) const listRef = useRef(null) - const query = search.trim().toLowerCase() - // What the server found for the query: chat id -> snippet ('' for a title hit). - const [hits, setHits] = useState({ query: '', snippets: {} }) + const query = search.trim() + const searching = Boolean(onSearch) && query.length >= 2 + const [filters, setFilters] = useState(noFilters) + const fkey = filtersKey(filters) + const [tries, setTries] = useState(0) + // What the server found: the answer to the search named by `key`. The rows + // of an earlier search stay on screen, dimmed, while the next one runs. + const [found, setFound] = useState({ key: '', status: 'idle', rows: [], meta: null }) + const searchKey = `${query}|${fkey}` + const onSearchRef = useRef(onSearch) + onSearchRef.current = onSearch + const filtersRef = useRef(filters) + filtersRef.current = filters useEffect(() => { - if (!onSearch || query.length < 2) return undefined + if (!searching) return undefined let live = true + setFound((f) => (f.key === searchKey && f.status === 'ready' ? f : { ...f, key: searchKey, status: 'loading' })) const timer = setTimeout(() => { - onSearch(query) + Promise.resolve(onSearchRef.current(query, searchParams(filtersRef.current))) .then((res) => { if (!live) return - const snippets = {} - for (const h of res?.hits || []) if (h?.chat?.id) snippets[h.chat.id] = h.in_title ? '' : (h.snippet || '') - setHits({ query, snippets }) + setFound({ + key: searchKey, status: 'ready', rows: hitRows(res, (c) => menuChats([c])[0]), + meta: { indexing: !!res?.indexing, partial: !!res?.partial, searched: res?.searched_chats || 0, total: res?.total_chats || 0 }, + }) }) - .catch(() => { if (live) setHits({ query, snippets: {} }) }) + .catch(() => { if (live) setFound({ key: searchKey, status: 'error', rows: [], meta: null }) }) }, SEARCH_DELAY_MS) return () => { live = false; clearTimeout(timer) } - }, [onSearch, query]) - const snippets = useMemo(() => (hits.query === query ? hits.snippets : {}), [hits, query]) + }, [searching, query, searchKey, tries]) + const searchState = !searching ? 'idle' : found.key === searchKey ? found.status : 'loading' - // Chats that hold a message, and the one you are in. An empty placeholder is - // not worth a row. Newest first, which is what the day blocks need. - const items = useMemo(() => { - const found = query ? chats.filter(c => matches(c, query) || c.id in snippets) : chats - return [...found].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)) - }, [chats, query, snippets]) + // Saved agents for the filter, read once the first search starts. + const [agents, setAgents] = useState(null) + useEffect(() => { + if (!searching || agents) return undefined + let live = true + chatAgentsApi.list().then((r) => { if (live) setAgents(r.agents || []) }).catch(() => { if (live) setAgents([]) }) + return () => { live = false } + }, [searching, agents]) + // What started the chats of the hits: the names of triggers and connections. + const originCtx = useOriginContext(searching && found.rows.some((r) => r.chat.origin)) - const groups = useMemo(() => groupConversations(items), [items]) - const flat = useMemo(() => groups.flatMap(g => g.items), [groups]) + // The chats shown in day blocks, newest first. Rows of a search come as the + // server ranks them. + const items = useMemo(() => [...chats].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)), [chats]) + const groups = useMemo(() => (searching ? [] : groupConversations(items)), [items, searching]) + const flat = useMemo(() => ( + searching + ? found.rows.map((r) => ({ key: r.key, chat: r.chat, hit: r })) + : groups.flatMap((g) => g.items.map((c) => ({ key: c.id, chat: c, group: g.key }))) + ), [searching, found.rows, groups]) const fmtTime = useMemo(() => new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }), [i18n.language]) const fmtDay = useMemo(() => new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }), [i18n.language]) const fmtDate = useMemo(() => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), [i18n.language]) const when = (group, ts) => (group === 'older' ? fmtDate.format(ts) : group === 'week' ? fmtDay.format(ts) : fmtTime.format(ts)) + // A hit has no day block, so its time carries the date. + const fmtHit = useMemo(() => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', year: 'numeric' }), [i18n.language]) // On mount: start on the current chat and, in the dialog, put the cursor in // the search box. @@ -94,7 +125,9 @@ export default function ChatsList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - useEffect(() => { setActiveIdx(0) }, [query]) + useEffect(() => { setActiveIdx(0) }, [query, fkey]) + // A new answer starts on its best hit. + useEffect(() => { if (found.status === 'ready') setActiveIdx(0) }, [found]) useEffect(() => { listRef.current?.querySelector(`[data-idx="${activeIdx}"]`)?.scrollIntoView({ block: 'nearest' }) @@ -105,7 +138,7 @@ export default function ChatsList({ if (activeIdx > flat.length - 1) setActiveIdx(Math.max(0, flat.length - 1)) }, [flat.length, activeIdx]) - const choose = useCallback((id) => { onSelect?.(id) }, [onSelect]) + const choose = useCallback((id, hit) => { onSelect?.(id, hit?.seq ? { seq: hit.seq } : undefined) }, [onSelect]) const cancelledRef = useRef(false) const startRename = (chat) => { cancelledRef.current = false; setEditingId(chat.id); setEditName(chat.name || '') } @@ -128,28 +161,131 @@ export default function ChatsList({ e.preventDefault() e.stopPropagation() cancelRename() + } else if (search) { + // The first Escape clears the search; the next one leaves. + e.preventDefault() + e.stopPropagation() + setSearch('') + setFilters(noFilters) } else if (onEscape) { e.preventDefault() e.stopPropagation() onEscape() - } else if (search) { - e.preventDefault() - setSearch('') } return } if (editingId) return if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, flat.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)) } - else if (e.key === 'Enter' && e.target === searchRef.current && current) { e.preventDefault(); choose(current.id) } - else if (e.key === 'F2' && current) { e.preventDefault(); startRename(current) } + else if (e.key === 'PageDown' && e.target === searchRef.current) { e.preventDefault(); setActiveIdx(i => Math.min(i + 5, flat.length - 1)) } + else if (e.key === 'PageUp' && e.target === searchRef.current) { e.preventDefault(); setActiveIdx(i => Math.max(i - 5, 0)) } + else if (e.key === 'Enter' && e.target === searchRef.current && current) { e.preventDefault(); choose(current.chat.id, current.hit) } + else if (e.key === 'F2' && current) { e.preventDefault(); startRename(current.chat) } else if (e.key === 'Delete' && current && canDelete && (e.target !== searchRef.current || !search)) { e.preventDefault() - onDelete?.(current) + onDelete?.(current.chat) } } const listId = `${idPrefix}-menu-list` + const showFilters = Boolean(onSearch) && search.trim().length > 0 + const meta = searching && found.status === 'ready' ? found.meta : null + + // The words under the list while a search runs or ends. + const statusLine = (() => { + if (!searching) return '' + if (searchState === 'loading') return t('menu.find.searching') + if (searchState === 'error') return '' + return flat.length > 0 ? t('menu.find.count', { count: flat.length }) : '' + })() + + const renderRow = (entry, idx, groupKey) => { + const { chat, hit } = entry + const selected = idx === activeIdx + const editing = editingId === chat.id + const originKind = chat.origin ? (knownKind(chat.origin)) : '' + const role = hit?.role === 'user' ? t('menu.find.you') : hit?.role === 'assistant' ? t('menu.find.assistant') : '' + const stamp = hit ? (hit.at || chat.updatedAt || chat.createdAt || 0) : (chat.updatedAt || chat.createdAt || 0) + return ( +
!editing && choose(chat.id, hit)} + onMouseMove={() => { if (!selected) setActiveIdx(idx) }} + > +
+ {chat.running && } + {editing ? ( + setEditName(e.target.value)} + onBlur={finishRename} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { e.preventDefault(); finishRename() } + if (e.key === 'Escape') { e.preventDefault(); cancelRename() } + }} + onClick={(e) => e.stopPropagation()} + aria-label={t('menu.rename')} + autoFocus + /> + ) : ( + { e.stopPropagation(); startRename(chat) }} + > + {chat.name || t('menu.untitled')} + + )} + {originKind && ( + + {(originCtx && resolveOrigin({ origin: chat.origin, originId: chat.originId }, originCtx).title) || originTitle?.(chat) || t(`menu.originBadge.${originKind}`)} + + )} +
+ {hit && hit.snippet && ( +
+ {role && {role}} + + {hit.more > 0 && {t('menu.find.more', { count: hit.more })}} +
+ )} + {hit && !hit.snippet && hit.inTitle && ( +
{t('menu.find.inTitle')}
+ )} + {chat.model || t('menu.noModel')} + {hit ? fmtHit.format(stamp) : when(groupKey, stamp)} +
+ + {onCopyChat && ( + + )} + {onExport && ( + + )} + {canDelete && ( + + )} +
+
+ ) + } return (
@@ -161,14 +297,14 @@ export default function ChatsList({ role="combobox" aria-expanded="true" aria-controls={listId} - aria-activedescendant={current ? `${idPrefix}-opt-${current.id}` : undefined} + aria-activedescendant={current ? `${idPrefix}-opt-${current.key}` : undefined} value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t('menu.search')} aria-label={t('menu.search')} /> {search && ( - )} @@ -179,91 +315,51 @@ export default function ChatsList({ )}
-
- {flat.length === 0 && ( -
{search ? t('menu.noMatch') : t('menu.noConversations')}
+ {showFilters && ( + + )} + {searching && ( +
+ {statusLine} + {meta?.indexing && {t('menu.find.indexing')}} + {meta?.partial && {t('menu.find.partial', { searched: meta.searched, total: meta.total })}} +
+ )} + +
+ {searchState === 'error' && ( +
+

{t('menu.find.failed')}

+ +
)} - {(() => { + {searchState !== 'error' && flat.length === 0 && searchState !== 'loading' && ( +
+ {searching + ? (filtersActive(filters) ? t('menu.find.noMatchFiltered', { query }) : t('menu.find.noMatchFor', { query })) + : search ? t('menu.noMatch') : t('menu.noConversations')} +
+ )} + {searchState !== 'error' && searchState === 'loading' && flat.length === 0 && ( +
{t('menu.find.searching')}
+ )} + {searching && searchState !== 'error' && ( +
+ {flat.map((entry, idx) => renderRow(entry, idx))} +
+ )} + {!searching && (() => { let n = 0 return groups.map(g => (

{t(`menu.group.${g.key}`)}

- {g.items.map(chat => { - const idx = n++ - const selected = idx === activeIdx - const editing = editingId === chat.id - const preview = snippets[chat.id] || '' - const originKind = chat.origin ? (knownKind(chat.origin)) : '' - return ( -
!editing && choose(chat.id)} - onMouseMove={() => { if (!selected) setActiveIdx(idx) }} - > -
- {chat.running && } - {editing ? ( - setEditName(e.target.value)} - onBlur={finishRename} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === 'Enter') { e.preventDefault(); finishRename() } - if (e.key === 'Escape') { e.preventDefault(); cancelRename() } - }} - onClick={(e) => e.stopPropagation()} - aria-label={t('menu.rename')} - autoFocus - /> - ) : ( - { e.stopPropagation(); startRename(chat) }} - > - {chat.name || t('menu.untitled')} - - )} - {originKind && ( - - {originTitle?.(chat) || t(`menu.originBadge.${originKind}`)} - - )} -
- {preview &&
{preview}
} - {chat.model || t('menu.noModel')} - {when(g.key, chat.updatedAt || chat.createdAt || 0)} -
- - {onCopyChat && ( - - )} - {onExport && ( - - )} - {canDelete && ( - - )} -
-
- ) - })} + {g.items.map(chat => renderRow(flat[n], n++, g.key))}
)) })()} @@ -274,6 +370,7 @@ export default function ChatsList({ ↵ {t('menu.keys.open')} F2 {t('menu.keys.rename')} Del {t('menu.keys.delete')} + Esc {t('menu.keys.clear')} {onDeleteAll && chats.length > 1 && ( + )} +
+ ) +} diff --git a/core/http/react-ui/src/components/chat/SearchSnippet.jsx b/core/http/react-ui/src/components/chat/SearchSnippet.jsx new file mode 100644 index 0000000000..113fb3fe91 --- /dev/null +++ b/core/http/react-ui/src/components/chat/SearchSnippet.jsx @@ -0,0 +1,9 @@ +import { highlightParts } from '../../utils/chatSearch' + +// The snippet of a search hit with the words that matched marked. The parts +// are text nodes and elements: message text is never read as HTML. +export default function SearchSnippet({ text, ranges }) { + return highlightParts(text, ranges).map((p, i) => ( + p.mark ? {p.text} : p.text + )) +} diff --git a/core/http/react-ui/src/pages/AllChats.jsx b/core/http/react-ui/src/pages/AllChats.jsx index f75dbc8adb..c4cdc5d2fb 100644 --- a/core/http/react-ui/src/pages/AllChats.jsx +++ b/core/http/react-ui/src/pages/AllChats.jsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { chatNotificationsApi, chatsApi } from '../utils/api' import { chatFromExport, chatMarkdown, downloadFrom, menuChats } from '../utils/chatThread' import { copyToClipboard } from '../utils/clipboard' +import { chatAddress } from '../utils/chatSearch' import { CAP_CHAT } from '../utils/capabilities' import { useModels } from '../hooks/useModels' import { CHAT_UNDO_MS, useChatDelete } from '../hooks/useChatDelete' @@ -203,14 +204,14 @@ export default function AllChats() { navigate(`/app/chats/${encodeURIComponent(id)}`)} + onSelect={(id, opts) => navigate(chatAddress(id, opts))} onNew={() => navigate('/app')} onDelete={(c) => deletes.request({ id: c.id, name: c.name || tc('list.untitled') })} onDeleteAll={promptDeleteAll} onRename={rename} onExport={(c) => downloadFrom(chatsApi.exportUrl(c.id, 'markdown'))} onCopyChat={copyChat} - onSearch={(text) => chatsApi.search(text, 50)} + onSearch={(text, params) => chatsApi.search(text, 50, params)} />
)} diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index b43c74f647..51631539bf 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -79,6 +79,7 @@ import { useAuth } from '../context/AuthContext' import { useOperations } from '../hooks/useOperations' import { useLoadedModels } from '../hooks/useLoadedModels' import { copyToClipboard } from '../utils/clipboard' +import { chatAddress, seqOfSearch } from '../utils/chatSearch' import Icon from '../components/Icon' // eslint-disable-next-line no-unused-vars import WorkspaceChangeNote from '../components/wellknown/WorkspaceChangeNote' @@ -427,6 +428,29 @@ function ChatPage() { }) }, [find.index, find.count, find.query]) + // A link from the chat search names a message (?seq=14): scroll to it once + // the transcript has it, and flash it so the eye finds it. location.key + // makes a second click on the same hit jump again. + const wantSeq = seqOfSearch(location.search) + const jumpedRef = useRef('') + useEffect(() => { + if (!wantSeq || !activeId || routeId !== activeId) return undefined + const mark = `${location.key}:${activeId}:${wantSeq}` + if (jumpedRef.current === mark) return undefined + const el = messagesRef.current?.querySelector(`[data-testid="chat-message"][data-index="${wantSeq}"]`) + if (!el) return undefined + jumpedRef.current = mark + el.scrollIntoView({ block: 'center' }) + el.focus({ preventScroll: true }) + // on, then off (the colour fades by a transition), then gone. Not cleared + // by the cleanup: a new message in the transcript re-runs this effect and + // must not cut the flash short. + el.setAttribute('data-flash', 'on') + setTimeout(() => el.setAttribute('data-flash', 'off'), 1400) + setTimeout(() => el.removeAttribute('data-flash'), 2800) + return undefined + }, [wantSeq, activeId, routeId, messages, location.key]) + // Highlight code blocks and add per-block Copy and Download buttons, and // Open preview on a block that is a result (a page, an SVG, a long block, a // block the answer names as a file). The observer is disconnected while we @@ -1036,14 +1060,14 @@ function ChatPage() { ref={chatsMenuRef} chats={visibleChats} activeChatId={activeId} - onSelect={(id) => navigate(`/app/chats/${id}`)} + onSelect={(id, opts) => navigate(chatAddress(id, opts))} onNew={() => navigate('/app')} onDelete={requestDelete} onDeleteAll={promptDeleteAll} onRename={(id, name) => chat.rename(id, name)} onExport={(c) => exportChat(c.id)} onCopyChat={(c) => copyChat(c.id)} - onSearch={(text) => chatsApi.search(text, 50)} + onSearch={(text, params) => chatsApi.search(text, 50, params)} originTitle={originTitle} /> )} diff --git a/core/http/react-ui/src/pages/chat.css b/core/http/react-ui/src/pages/chat.css index f15f193066..f32a2f5be8 100644 --- a/core/http/react-ui/src/pages/chat.css +++ b/core/http/react-ui/src/pages/chat.css @@ -1945,3 +1945,39 @@ html[data-handoff='fade']::view-transition-new(root) { .ac-group { padding: 10px; } .ac-row__open { flex-direction: column; align-items: flex-start; gap: 2px; } } + +/* ---- Chat search: filters, hits, deep link ------------------------------------- */ +.cx-find-filters { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 12px; padding: 8px 16px; box-shadow: inset 0 -1px 0 var(--dk-edge); flex: none; } +.cx-find-filter { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.cx-find-filter__label { color: var(--dk-muted); font-size: var(--fs-meta); font-weight: 600; letter-spacing: .04em; } +.cx-find-select { + height: 32px; + max-width: 100%; + padding: 0 8px; + border: 0; + border-radius: var(--dk-radius-1); + background: var(--dk-inset); + box-shadow: inset 0 0 0 1px var(--dk-edge); + color: var(--dk-text); + font: inherit; + font-size: var(--fs-small); +} +.cx-find-select:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 1px; } +.cx-find-reset { align-self: flex-end; } +.cx-find-status { display: flex; flex-wrap: wrap; gap: 4px 12px; padding: 8px 16px 0; color: var(--dk-muted); font-size: var(--fs-meta); } +.cx-find-status__note { color: var(--dk-warn-text, var(--dk-muted)); } +.cx-find-list[data-dim] { opacity: .55; transition: opacity var(--dk-dur-1) var(--dk-ease-out); } +.cx-find-snip { white-space: normal; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; } +.cx-find-snip__who { margin-right: 6px; color: var(--dk-text); font-weight: 600; } +.cx-find-snip__more { margin-left: 8px; font-size: var(--fs-meta); } +.cx-find-mark { padding: 0 1px; border-radius: var(--dk-radius-1); background: var(--dk-accent-wash); color: var(--dk-accent-text); font-weight: 650; } +.cx-msg[data-flash] { border-radius: var(--dk-radius-2); transition: background-color 1.2s ease-out, box-shadow 1.2s ease-out; } +.cx-msg[data-flash="on"] { background: var(--dk-accent-wash); box-shadow: 0 0 0 8px var(--dk-accent-wash); } +.cx-msg[data-flash="off"] { background: transparent; box-shadow: 0 0 0 8px transparent; } +@media (prefers-reduced-motion: reduce) { .cx-msg[data-flash] { transition: none; } } +@media (max-width: 760px) { + .cx-find-filters { padding: 8px 12px; gap: 6px 8px; } + .cx-find-filter { flex: 1 1 42%; } + .cx-find-select { width: 100%; font-size: var(--fs-lead); } + .cx-find-status { padding: 8px 12px 0; } +} diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js index 5aa02dc506..530f125162 100644 --- a/core/http/react-ui/src/utils/api.js +++ b/core/http/react-ui/src/utils/api.js @@ -156,7 +156,9 @@ export const chatsApi = { fork: (id, fromSeq, title) => postJSON(`${API_CONFIG.endpoints.chat(id)}/fork`, { ...(fromSeq ? { from_seq: Number(fromSeq) } : {}), ...(title ? { title } : {}), }), - search: (text, limit) => fetchJSON(`${API_CONFIG.endpoints.chats}/search${q({ q: text, limit })}`), + // Search all chats: params are the filters of GET /api/chats/search (agent, + // origin, role, since, until, group). + search: (text, limit, params = {}) => fetchJSON(`${API_CONFIG.endpoints.chats}/search${q({ q: text, limit, ...params })}`), exportUrl: (id, format) => apiUrl(`${API_CONFIG.endpoints.chat(id)}/export${q({ format })}`), // chat is the JSON export (GET .../export?format=json); model replaces its model. importChat: (chat, model) => postJSON(`${API_CONFIG.endpoints.chats}/import`, { chat, ...(model ? { model } : {}) }), diff --git a/core/http/react-ui/src/utils/chatSearch.js b/core/http/react-ui/src/utils/chatSearch.js new file mode 100644 index 0000000000..2f1dd568c7 --- /dev/null +++ b/core/http/react-ui/src/utils/chatSearch.js @@ -0,0 +1,81 @@ +// Helpers of the chat search (GET /api/chats/search). No React, so a node test +// drives them. + +// The words of a hit's snippet, split at the marked ranges. The server gives +// ranges as offsets in UTF-16 code units, which is how a JavaScript string +// counts, and the page draws the parts as text nodes: nothing from a message is +// ever parsed as HTML. Ranges that are out of order, overlap or lie outside the +// text are cut or dropped, so a wrong range never throws or repeats text. +export function highlightParts(text, ranges) { + const s = String(text || '') + const parts = [] + let at = 0 + const sorted = [...(Array.isArray(ranges) ? ranges : [])] + .filter((r) => r && Number.isInteger(r.start) && Number.isInteger(r.end)) + .sort((a, b) => a.start - b.start) + for (const r of sorted) { + const start = Math.max(r.start, at) + const end = Math.min(r.end, s.length) + if (end <= start) continue + if (start > at) parts.push({ text: s.slice(at, start), mark: false }) + parts.push({ text: s.slice(start, end), mark: true }) + at = end + } + if (at < s.length) parts.push({ text: s.slice(at), mark: false }) + return parts +} + +// The date presets of the filter row, as the number of days back. +export const RANGE_DAYS = { day: 1, week: 7, month: 30, year: 365 } + +const iso = (d) => d.toISOString() + +// The query parameters of the filters: { agent, origin, range, from, to }. +// A preset is a span back from now; a custom range is two dates (YYYY-MM-DD, +// the end day included). Empty values are left out. +export function searchParams(filters, now = new Date()) { + const f = filters || {} + const out = {} + if (f.agent) out.agent = f.agent + if (f.origin) out.origin = f.origin + if (RANGE_DAYS[f.range]) out.since = iso(new Date(now.getTime() - RANGE_DAYS[f.range] * 86400000)) + if (f.range === 'custom') { + if (/^\d{4}-\d{2}-\d{2}$/.test(f.from || '')) out.since = f.from + if (/^\d{4}-\d{2}-\d{2}$/.test(f.to || '')) out.until = f.to + } + return out +} + +export const noFilters = { agent: '', origin: '', range: '', from: '', to: '' } +export const filtersActive = (f) => Boolean(f && (f.agent || f.origin || f.range)) + +// A stable string for the filters, to tell when a search is a new one. +export const filtersKey = (f) => JSON.stringify([f?.agent || '', f?.origin || '', f?.range || '', f?.from || '', f?.to || '']) + +// The address of a chat, at one of its messages when seq is given. +export function chatAddress(id, opts) { + const base = `/app/chats/${encodeURIComponent(id)}` + const seq = Number(opts?.seq) + return Number.isInteger(seq) && seq > 0 ? `${base}?seq=${seq}` : base +} + +// The seq of a chat address (?seq=14), or 0. +export function seqOfSearch(search) { + const seq = Number(new URLSearchParams(search || '').get('seq')) + return Number.isInteger(seq) && seq > 0 ? seq : 0 +} + +// The rows of a search answer: one per hit, the chat as the list draws it. +export function hitRows(res, toChat) { + return (res?.hits || []).filter((h) => h?.chat?.id).map((h) => ({ + key: `${h.chat.id}:${h.seq || 0}`, + chat: toChat(h.chat), + seq: h.seq || 0, + role: h.role || '', + at: h.at ? Date.parse(h.at) || 0 : 0, + snippet: h.snippet || '', + highlights: h.highlights || [], + inTitle: Boolean(h.in_title), + more: Math.max(0, (h.matches || 0) - 1), + })) +} diff --git a/core/http/react-ui/tests/unit/chatsearch.test.mjs b/core/http/react-ui/tests/unit/chatsearch.test.mjs new file mode 100644 index 0000000000..19aeb20bb6 --- /dev/null +++ b/core/http/react-ui/tests/unit/chatsearch.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { highlightParts, searchParams, chatAddress, seqOfSearch, hitRows, filtersActive, noFilters, filtersKey } from '../../src/utils/chatSearch.js' + +test('chat search: highlight splits the text at the ranges and keeps all of it', () => { + const parts = highlightParts('What is the Qdrant port?', [{ start: 12, end: 18 }, { start: 19, end: 23 }]) + assert.deepEqual(parts, [ + { text: 'What is the ', mark: false }, + { text: 'Qdrant', mark: true }, + { text: ' ', mark: false }, + { text: 'port', mark: true }, + { text: '?', mark: false }, + ]) + assert.equal(parts.map((p) => p.text).join(''), 'What is the Qdrant port?') +}) + +test('chat search: highlight never turns message text into markup', () => { + const text = ' bold' + const parts = highlightParts(text, [{ start: 29, end: 36 }]) + assert.equal(parts.map((p) => p.text).join(''), text) + assert.ok(parts.every((p) => typeof p.text === 'string')) +}) + +test('chat search: highlight drops ranges that are bad and never repeats text', () => { + const text = 'abcdef' + assert.deepEqual(highlightParts(text, null), [{ text, mark: false }]) + assert.deepEqual(highlightParts(text, [{ start: 4, end: 2 }, { start: 'a', end: 3 }, null]), [{ text, mark: false }]) + assert.deepEqual(highlightParts(text, [{ start: 2, end: 99 }]).map((p) => p.text), ['ab', 'cdef']) + const overlap = highlightParts(text, [{ start: 0, end: 3 }, { start: 2, end: 5 }]) + assert.equal(overlap.map((p) => p.text).join(''), text) + assert.deepEqual(overlap.filter((p) => p.mark).map((p) => p.text), ['abc', 'de']) + assert.deepEqual(highlightParts('', [{ start: 0, end: 1 }]), []) +}) + +test('chat search: offsets count UTF-16 units, as the server does', () => { + const text = '😀 needle' + assert.deepEqual(highlightParts(text, [{ start: 3, end: 9 }]).filter((p) => p.mark).map((p) => p.text), ['needle']) +}) + +test('chat search: filters become query parameters', () => { + const now = new Date('2026-11-04T12:00:00Z') + assert.deepEqual(searchParams(noFilters, now), {}) + assert.deepEqual(searchParams({ agent: 'helper', origin: 'trigger', range: 'week' }, now), { + agent: 'helper', origin: 'trigger', since: '2026-10-28T12:00:00.000Z', + }) + assert.deepEqual(searchParams({ range: 'custom', from: '2026-01-02', to: '2026-01-09' }, now), { since: '2026-01-02', until: '2026-01-09' }) + assert.deepEqual(searchParams({ range: 'custom', from: 'nope', to: '' }, now), {}) + assert.equal(filtersActive(noFilters), false) + assert.equal(filtersActive({ ...noFilters, origin: 'person' }), true) + assert.notEqual(filtersKey({ ...noFilters, agent: 'a' }), filtersKey(noFilters)) +}) + +test('chat search: the address of a chat names the message', () => { + assert.equal(chatAddress('c 1'), '/app/chats/c%201') + assert.equal(chatAddress('c1', { seq: 14 }), '/app/chats/c1?seq=14') + assert.equal(chatAddress('c1', { seq: 0 }), '/app/chats/c1') + assert.equal(chatAddress('c1', { seq: 'x' }), '/app/chats/c1') + assert.equal(seqOfSearch('?seq=14'), 14) + assert.equal(seqOfSearch('?seq=-1'), 0) + assert.equal(seqOfSearch('?seq=1.5'), 0) + assert.equal(seqOfSearch(''), 0) +}) + +test('chat search: an answer becomes rows', () => { + const rows = hitRows({ hits: [ + { chat: { id: 'a', title: 'A' }, seq: 3, role: 'user', at: '2026-11-04T10:00:00Z', snippet: 'hi', highlights: [{ start: 0, end: 2 }], matches: 3 }, + { chat: { id: 'b', title: 'B' }, in_title: true }, + { chat: {} }, + ] }, (c) => ({ id: c.id, name: c.title })) + assert.equal(rows.length, 2) + assert.deepEqual(rows[0], { key: 'a:3', chat: { id: 'a', name: 'A' }, seq: 3, role: 'user', at: Date.parse('2026-11-04T10:00:00Z'), snippet: 'hi', highlights: [{ start: 0, end: 2 }], inTitle: false, more: 2 }) + assert.equal(rows[1].inTitle, true) + assert.equal(rows[1].seq, 0) + assert.deepEqual(hitRows(null, (c) => c), []) +}) diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index 54d3b37192..f26b71d61c 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -527,6 +527,7 @@ curl -X POST .../api/chats/import -d "{\"chat\": $(cat chat.json)}" - **Ranking.** The best match comes first: a message with the whole phrase or with whole words ranks above one with word parts, and newer messages rank above older ones. A search ranks the newest 600 matching messages. - **One hit for each chat.** A hit carries `chat` (with the agent and the origin), `seq` (the line of the message), `role`, `at`, a `snippet` and `highlights`: the parts of the snippet that match, as `start` and `end` offsets in UTF-16 units, so a client can mark them without parsing HTML. Add `group=message` to get every matching line. - **Filters.** `agent` (a saved agent), `origin` (`person`, `automated`, `trigger`, `connector`, `job` or `page`), `role` (`user` or `assistant`), `since` and `until` (a date `YYYY-MM-DD` or an RFC 3339 time; a date in `until` includes that whole day) and `limit` (default 50, at most 200). +- **In the web UI.** The search box of the Chats page and of the Ctrl or Cmd+K dialog uses this search. From two characters it lists the hits best first, each with the chat, who wrote the line, its date and a snippet with the matched words marked. Three filters (agent, source, date) narrow it, and **Reset filters** takes them off. Arrow keys move through the hits, Enter opens one, and Escape clears the search first and closes the dialog next. A hit opens its chat at the message: the address `/app/chats/?seq=14` scrolls to line 14 and marks it for a moment. The list says when it is still searching, when nothing matches, when the search failed (with **Try again**), and when older chats are still being indexed. - **After an upgrade.** The server keeps a search index. When it starts with chats from before the index, it adds them in the background and logs the progress; the answer says `indexing: true` until that ends, and older chats can be missing meanwhile. The index uses SQLite FTS5 (trigram), or `pg_trgm` on PostgreSQL; without them the server scans. Deleting a chat, or regenerating or editing from a message, removes the old text from the index in the same step. - **Chats in files.** A server that keeps chats in files (no database) has no index. It scans the newest chats first for up to two seconds and sets `partial: true` with `searched_chats` and `total_chats` when it stopped early.