| |
| |
| |
|
|
| import { tr } from '../lang/i18n-lite'; |
|
|
| |
| export const SEMANTIC_QUERY_HISTORY_KEY = 'info_radar_query_search_history'; |
| |
| export const CHAT_RAW_INPUT_HISTORY_KEY = 'info_radar_chat_raw_input_history'; |
| |
| export const CHAT_USER_INPUT_HISTORY_KEY = 'info_radar_chat_user_input_history'; |
| |
| export const CHAT_SYSTEM_INPUT_HISTORY_KEY = 'info_radar_chat_system_input_history'; |
| |
| export const GEN_ATTR_RAW_INPUT_HISTORY_KEY = 'info_radar_gen_attr_raw_input_history'; |
| |
| export const GEN_ATTR_USER_INPUT_HISTORY_KEY = 'info_radar_gen_attr_user_input_history'; |
| |
| export const GEN_ATTR_SYSTEM_INPUT_HISTORY_KEY = 'info_radar_gen_attr_system_input_history'; |
| |
| export const GEN_ATTR_TEACHER_FORCING_INPUT_HISTORY_KEY = 'info_radar_gen_attr_teacher_forcing_input_history'; |
|
|
| const MAX = 100; |
|
|
| |
| |
| |
| |
| |
| function shouldHideDropdownAfterSelect( |
| closeOnSelect: boolean, |
| applyHistoryOnHover: boolean, |
| fromHover: boolean |
| ): boolean { |
| if (applyHistoryOnHover) { |
| return !fromHover; |
| } |
| return closeOnSelect; |
| } |
|
|
| |
| |
| |
| |
| function shouldTouchLinkedMru(applyHistoryOnHover: boolean, fromHover: boolean): boolean { |
| return applyHistoryOnHover ? !fromHover : true; |
| } |
|
|
| function load(storageKey: string): string[] { |
| try { |
| const raw = localStorage.getItem(storageKey); |
| if (!raw) return []; |
| const parsed = JSON.parse(raw); |
| if (!Array.isArray(parsed)) return []; |
| return parsed |
| .map((e: unknown) => { |
| if (typeof e === 'string') return e; |
| if (e && typeof (e as { query?: string }).query === 'string') return (e as { query: string }).query; |
| return null; |
| }) |
| .filter((s): s is string => typeof s === 'string') |
| .slice(0, MAX); |
| } catch { |
| return []; |
| } |
| } |
|
|
| function remove(storageKey: string, query: string): void { |
| const list = load(storageKey).filter((s) => s !== query); |
| localStorage.setItem(storageKey, JSON.stringify(list)); |
| } |
|
|
| export function saveHistory(query: string, storageKey: string = SEMANTIC_QUERY_HISTORY_KEY): void { |
| const list = [query, ...load(storageKey).filter((s) => s !== query)].slice(0, MAX); |
| localStorage.setItem(storageKey, JSON.stringify(list)); |
| } |
|
|
| export interface InitQueryHistoryDropdownOptions { |
| |
| |
| |
| |
| input: HTMLInputElement | HTMLTextAreaElement | null; |
| dropdownId: string; |
| |
| onSelect: () => void; |
| |
| |
| |
| |
| onHistorySelect?: (query: string, shouldTouch?: boolean) => void; |
| |
| onRemove?: (query: string) => void | Promise<void>; |
| |
| onPromote?: (query: string) => void | Promise<void>; |
| |
| historyButton?: HTMLElement | null; |
| |
| |
| |
| |
| storageKey?: string; |
| |
| getHistoryItems?: () => string[]; |
| |
| |
| |
| |
| getHistoryEntries?: () => Array<{ id: string; label: string }>; |
| |
| |
| |
| |
| refreshHistoryItems?: () => void | Promise<void>; |
| |
| |
| |
| |
| openDropdownOnFocusInput?: boolean; |
| |
| filterHistoryByInput?: boolean; |
| |
| |
| |
| |
| clickOutsideRoot?: HTMLElement | null; |
| |
| |
| |
| |
| fillInputOnSelect?: boolean; |
| |
| |
| |
| |
| closeOnSelect?: boolean; |
| |
| |
| |
| |
| |
| |
| applyHistoryOnHover?: boolean; |
| } |
|
|
| export function initQueryHistoryDropdown(options: InitQueryHistoryDropdownOptions): void { |
| const { |
| input, |
| dropdownId, |
| onSelect, |
| onHistorySelect, |
| onRemove, |
| onPromote, |
| historyButton, |
| storageKey = SEMANTIC_QUERY_HISTORY_KEY, |
| openDropdownOnFocusInput = true, |
| filterHistoryByInput = true, |
| clickOutsideRoot = null, |
| fillInputOnSelect = true, |
| closeOnSelect = true, |
| getHistoryItems, |
| getHistoryEntries, |
| refreshHistoryItems, |
| applyHistoryOnHover = false |
| } = options; |
| const dropdown = document.getElementById(dropdownId); |
| if (!dropdown) return; |
| if (!input && !historyButton) return; |
| if (!input && openDropdownOnFocusInput) return; |
|
|
| const wrapper = |
| input?.closest('.semantic-search-input-wrapper') ?? |
| historyButton?.closest('.semantic-search-input-wrapper') ?? |
| null; |
| const outsideRoot = clickOutsideRoot ?? wrapper; |
|
|
| const hideDropdown = () => dropdown.classList.remove('is-visible'); |
|
|
| const buildDropdown = () => { |
| |
| const pointerFineHover = |
| applyHistoryOnHover && |
| typeof window !== 'undefined' && |
| window.matchMedia('(hover: hover) and (pointer: fine)').matches; |
|
|
| |
| const filter = |
| filterHistoryByInput && input ? (input.value ?? '').toLowerCase() : ''; |
| const useEntries = getHistoryEntries != null; |
| const entryRows = useEntries ? getHistoryEntries!() : null; |
| const list = !useEntries ? (getHistoryItems ? getHistoryItems() : load(storageKey)) : null; |
| const filteredEntries = entryRows |
| ? entryRows.filter((e) => !filter || e.label.toLowerCase().includes(filter)) |
| : null; |
| const filteredStrings = list |
| ? list.filter((s) => !filter || s.toLowerCase().includes(filter)) |
| : null; |
| dropdown.innerHTML = ''; |
| const filtered = filteredEntries ?? filteredStrings ?? []; |
| if (filtered.length === 0) { |
| hideDropdown(); |
| return; |
| } |
| dropdown.classList.add('is-visible'); |
| if (filteredEntries) { |
| for (const row of filteredEntries) { |
| const q = row.id; |
| const display = row.label; |
| const li = document.createElement('li'); |
| const span = document.createElement('span'); |
| span.className = 'history-text'; |
| span.textContent = display; |
| if (!pointerFineHover) span.title = display; |
| let promoteBtn: HTMLButtonElement | null = null; |
| if (onPromote) { |
| promoteBtn = document.createElement('button'); |
| promoteBtn.className = 'demo-history-promote-btn'; |
| promoteBtn.type = 'button'; |
| promoteBtn.textContent = '↑'; |
| promoteBtn.title = tr('Move to top'); |
| promoteBtn.onclick = (e) => { |
| e.stopPropagation(); |
| void Promise.resolve(onPromote?.(q)).then(() => render()); |
| }; |
| } |
| const selectItem = (fromHover: boolean) => { |
| if (shouldHideDropdownAfterSelect(closeOnSelect, applyHistoryOnHover, fromHover)) { |
| hideDropdown(); |
| } |
| if (fillInputOnSelect && input) { |
| input.value = display; |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| onHistorySelect?.(q, shouldTouchLinkedMru(applyHistoryOnHover, fromHover)); |
| }; |
| span.onclick = () => selectItem(false); |
| if (pointerFineHover) { |
| span.addEventListener('pointerenter', () => selectItem(true)); |
| } |
| li.appendChild(span); |
| if (promoteBtn) li.appendChild(promoteBtn); |
| if (onRemove) { |
| const btn = document.createElement('button'); |
| btn.className = 'demo-delete-btn'; |
| btn.type = 'button'; |
| btn.textContent = '×'; |
| btn.title = tr('Remove'); |
| btn.onclick = (e) => { |
| e.stopPropagation(); |
| void Promise.resolve(onRemove(q)).then(() => render()); |
| }; |
| li.appendChild(btn); |
| } |
| dropdown.appendChild(li); |
| } |
| return; |
| } |
| for (const q of filteredStrings!) { |
| const li = document.createElement('li'); |
| const span = document.createElement('span'); |
| span.className = 'history-text'; |
| span.textContent = q; |
| if (!pointerFineHover) span.title = q; |
| let promoteBtn: HTMLButtonElement | null = null; |
| if (onPromote) { |
| promoteBtn = document.createElement('button'); |
| promoteBtn.className = 'demo-history-promote-btn'; |
| promoteBtn.type = 'button'; |
| promoteBtn.textContent = '↑'; |
| promoteBtn.title = tr('Move to top'); |
| promoteBtn.onclick = (e) => { |
| e.stopPropagation(); |
| void Promise.resolve(onPromote?.(q)).then(() => render()); |
| }; |
| } |
| const btn = document.createElement('button'); |
| btn.className = 'demo-delete-btn'; |
| btn.type = 'button'; |
| btn.textContent = '×'; |
| btn.title = tr('Remove'); |
| const selectItem = (fromHover: boolean) => { |
| if (shouldHideDropdownAfterSelect(closeOnSelect, applyHistoryOnHover, fromHover)) { |
| hideDropdown(); |
| } |
| if (fillInputOnSelect && input) { |
| input.value = q; |
| |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| } |
| onHistorySelect?.(q, shouldTouchLinkedMru(applyHistoryOnHover, fromHover)); |
| }; |
| span.onclick = () => selectItem(false); |
| if (pointerFineHover) { |
| span.addEventListener('pointerenter', () => selectItem(true)); |
| } |
| btn.onclick = (e) => { |
| e.stopPropagation(); |
| if (!getHistoryItems && !getHistoryEntries) { |
| remove(storageKey, q); |
| } |
| void Promise.resolve(onRemove?.(q)).then(() => render()); |
| }; |
| li.appendChild(span); |
| if (promoteBtn) li.appendChild(promoteBtn); |
| li.appendChild(btn); |
| dropdown.appendChild(li); |
| } |
| }; |
|
|
| const render = () => { |
| if (refreshHistoryItems) { |
| void Promise.resolve(refreshHistoryItems()) |
| .then(buildDropdown) |
| .catch(() => buildDropdown()); |
| } else { |
| buildDropdown(); |
| } |
| }; |
|
|
| const clearBtn = input ? wrapper?.querySelector('.semantic-search-clear') : null; |
| const syncClear = () => |
| clearBtn?.classList.toggle('is-visible', (input?.value ?? '').length > 0); |
|
|
| if (input) { |
| if (openDropdownOnFocusInput) { |
| input.addEventListener('focus', render); |
| } |
| input.addEventListener('input', () => { |
| onSelect(); |
| if (openDropdownOnFocusInput) { |
| if (input === document.activeElement) render(); |
| } else if (filterHistoryByInput && dropdown.classList.contains('is-visible')) { |
| render(); |
| } |
| syncClear(); |
| }); |
| } |
|
|
| historyButton?.addEventListener('click', (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| input?.focus(); |
| render(); |
| }); |
| document.addEventListener('click', (e) => { |
| const t = e.target as Node; |
| if (historyButton?.contains(t)) return; |
| if (outsideRoot && !outsideRoot.contains(t)) hideDropdown(); |
| }); |
|
|
| if (clearBtn && input) { |
| syncClear(); |
| clearBtn.addEventListener('click', () => { |
| input.value = ''; |
| input.focus(); |
| input.dispatchEvent(new Event('input', { bubbles: true })); |
| }); |
| } |
| } |
|
|