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 (
+
{t('menu.find.failed')}
+