From cde3a2d6d9ea9ba308837c016c4cb4e18a738db4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 17:47:11 +0000 Subject: [PATCH] feat(ui): show search hits with highlights, filters and a link to the message The chat search of the Chats page and of the Ctrl or Cmd+K dialog kept the list of the newest chats and only filtered it, so a chat that was not in the list could never be found. From two characters the list now shows the answer of GET /api/chats/search: the hits best first, each with the chat, who wrote the line, its date and a snippet. The matched words are marked from the offsets the server sends and drawn as text nodes, so message text is never read as HTML. Filters for agent, source and date narrow the search. Arrow keys move through the hits, Enter opens one, and Escape clears the search before it closes the dialog. The list shows a searching, empty, failed (with Try again), still-indexing and partial state. A hit opens its chat at the message: /app/chats/?seq= scrolls to the line when the transcript has it and marks it for a moment, again on a second click. The new strings are in every locale. Assisted-by: Claude:claude-sonnet-5-5 --- core/http/react-ui/e2e/chats-origin.spec.js | 4 +- core/http/react-ui/e2e/chats-search.js | 59 ++++ core/http/react-ui/e2e/chats-search.spec.js | 210 +++++++++++ .../http/react-ui/public/locales/de/chat.json | 44 +++ .../http/react-ui/public/locales/en/chat.json | 44 ++- .../http/react-ui/public/locales/es/chat.json | 44 +++ .../http/react-ui/public/locales/fr/chat.json | 44 +++ .../http/react-ui/public/locales/id/chat.json | 46 ++- .../http/react-ui/public/locales/it/chat.json | 44 +++ .../http/react-ui/public/locales/ko/chat.json | 44 +++ .../react-ui/public/locales/pt-BR/chat.json | 44 +++ .../react-ui/public/locales/zh-CN/chat.json | 44 +++ .../react-ui/src/components/ChatsList.jsx | 329 ++++++++++++------ .../react-ui/src/components/ChatsMenu.jsx | 4 +- .../src/components/chat/SearchFilters.jsx | 57 +++ .../src/components/chat/SearchSnippet.jsx | 9 + core/http/react-ui/src/pages/AllChats.jsx | 5 +- core/http/react-ui/src/pages/Chat.jsx | 28 +- core/http/react-ui/src/pages/chat.css | 36 ++ core/http/react-ui/src/utils/api.js | 4 +- core/http/react-ui/src/utils/chatSearch.js | 81 +++++ .../react-ui/tests/unit/chatsearch.test.mjs | 75 ++++ docs/content/features/chats.md | 1 + 23 files changed, 1172 insertions(+), 128 deletions(-) create mode 100644 core/http/react-ui/e2e/chats-search.js create mode 100644 core/http/react-ui/e2e/chats-search.spec.js create mode 100644 core/http/react-ui/src/components/chat/SearchFilters.jsx create mode 100644 core/http/react-ui/src/components/chat/SearchSnippet.jsx create mode 100644 core/http/react-ui/src/utils/chatSearch.js create mode 100644 core/http/react-ui/tests/unit/chatsearch.test.mjs 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.