InfoLens / client /src /ts /utils /queryHistory.ts
dqy08's picture
DAG增加螺旋模式,支持teacher forcing
c911b05
/**
* 查询输入框历史(仅保存字符串);不同入口通过 storageKey 分库存储。
*/
import { tr } from '../lang/i18n-lite';
/** 首页语义搜索等默认使用 */
export const SEMANTIC_QUERY_HISTORY_KEY = 'info_radar_query_search_history';
/** Raw 输入框 input history(仅填充,不与续写缓存联动) */
export const CHAT_RAW_INPUT_HISTORY_KEY = 'info_radar_chat_raw_input_history';
/** Chat 模板模式下「User」输入框 input history(仅填充,不与 completion 缓存联动) */
export const CHAT_USER_INPUT_HISTORY_KEY = 'info_radar_chat_user_input_history';
/** Chat 模板模式下「System」输入框 input history */
export const CHAT_SYSTEM_INPUT_HISTORY_KEY = 'info_radar_chat_system_input_history';
/** Generate & Attribute 页 Raw 输入框(与 Chat 分库) */
export const GEN_ATTR_RAW_INPUT_HISTORY_KEY = 'info_radar_gen_attr_raw_input_history';
/** Generate & Attribute 页 User 输入框 */
export const GEN_ATTR_USER_INPUT_HISTORY_KEY = 'info_radar_gen_attr_user_input_history';
/** Generate & Attribute 页 System 输入框 */
export const GEN_ATTR_SYSTEM_INPUT_HISTORY_KEY = 'info_radar_gen_attr_system_input_history';
/** Generate & Attribute 页 Teacher forcing 续写框 */
export const GEN_ATTR_TEACHER_FORCING_INPUT_HISTORY_KEY = 'info_radar_gen_attr_teacher_forcing_input_history';
const MAX = 100;
/**
* 选中某条历史后是否收起下拉。
* - `applyHistoryOnHover`:仅「点击」收起,「悬停」不收起(不读 `closeOnSelect`)。
* - 否则:由 `closeOnSelect` 决定(仅点击路径会触发)。
*/
function shouldHideDropdownAfterSelect(
closeOnSelect: boolean,
applyHistoryOnHover: boolean,
fromHover: boolean
): boolean {
if (applyHistoryOnHover) {
return !fromHover;
}
return closeOnSelect;
}
/**
* 是否应对「与历史项关联的缓存」执行 MRU touch(仅 MRU 顺序;表意为「应不应 touch」,不是「是否点击」)。
* 悬停预览:false;点击主文本:true;未开启悬停应用:恒 true。
*/
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 {
/**
* 可为空:仅「历史按钮 + 下拉」、不绑定任何输入框时(如 Cached history)传 null,
* 此时须提供 {@link historyButton},且 {@link openDropdownOnFocusInput} 应为 false。
*/
input: HTMLInputElement | HTMLTextAreaElement | null;
dropdownId: string;
/** 无 input 时不会调用(仅绑定在 input 的 input 事件上) */
onSelect: () => void;
/**
* 选中某条历史时回调;第二项 `shouldTouch` 表示**是否应对关联 MRU 执行 touch**(由本组件按悬停/点击推导),
* 非「事件是否来自点击」的原始标记。
*/
onHistorySelect?: (query: string, shouldTouch?: boolean) => void;
/** 删除某条历史时回调,用于同步清理相关缓存(可返回 Promise) */
onRemove?: (query: string) => void | Promise<void>;
/** 若提供则在叉号左侧显示 ↑,点击后调用(如 completion 缓存 touch 置顶;可返回 Promise) */
onPromote?: (query: string) => void | Promise<void>;
/** 输入框外的按钮:点击后弹出历史下拉(与语义搜索 History 入口一致) */
historyButton?: HTMLElement | null;
/**
* localStorage 键,默认与首页语义搜索共用 {@link SEMANTIC_QUERY_HISTORY_KEY}。
* 若提供 {@link getHistoryItems} 或 {@link getHistoryEntries},则不再从 localStorage 读列表(如续写缓存由 IndexedDB MRU 提供)。
*/
storageKey?: string;
/** 自定义列表数据源;提供时优先于 {@link storageKey} */
getHistoryItems?: () => string[];
/**
* 与 {@link getHistoryItems} 二选一:每项含稳定 id(如续写缓存的 contentKey)与展示 label。
* 选中/删除/置顶回调均传递 id。
*/
getHistoryEntries?: () => Array<{ id: string; label: string }>;
/**
* 每次渲染列表前调用(如打开下拉时从 IndexedDB 刷新内存镜像)。
* 失败时仍会继续渲染,避免下拉空白。
*/
refreshHistoryItems?: () => void | Promise<void>;
/**
* 为 true(默认)时,聚焦/输入会弹出并过滤历史。
* 为 false 时仅通过 historyButton 打开;若下拉已打开且 {@link filterHistoryByInput} 为 true,输入仍会刷新过滤。
*/
openDropdownOnFocusInput?: boolean;
/** 为 true(默认)时按当前输入过滤列表;为 false 时始终展示全部历史(如 Chat) */
filterHistoryByInput?: boolean;
/**
* 点击外部关闭下拉时使用的根节点;不传则用 input 所在 `.semantic-search-input-wrapper`。
* 当下拉与 input 不在同一 wrapper 内时(如左下角独立「Cached history」入口)必须传入,以包含下拉与按钮。
*/
clickOutsideRoot?: HTMLElement | null;
/**
* 为 false 时点击列表仅触发 {@link onHistorySelect},不写入 input(如 Cached history 只刷新右侧)。
* 默认 true。
*/
fillInputOnSelect?: boolean;
/**
* 为 false 时选中条目后不关闭下拉。
* 当 {@link applyHistoryOnHover} 为 true 时**忽略本项**,收起规则由悬停/点击在内部处理(悬停不关、点击关)。默认 true。
*/
closeOnSelect?: boolean;
/**
* 为 true 时,在 `(hover: hover) and (pointer: fine)` 环境下指针进入主文本区域即触发与点击相同的选中逻辑({@link onHistorySelect}、回填 input 等),
* 但第二参 `shouldTouch` 为 false(不应 bump MRU);触控等不满足该 media 时仅点击触发。
* 此模式下不再读取 {@link closeOnSelect}:悬停选中不收起下拉,点击主文本收起。
* 仅应在 Chat / Attribution 等入口显式开启;首页语义搜索等保持默认 false。
*/
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 = () => {
/** 仅精细指针且具备真实 hover 的环境挂 pointerenter 预览;触控避免假悬停与双击路径 */
const pointerFineHover =
applyHistoryOnHover &&
typeof window !== 'undefined' &&
window.matchMedia('(hover: hover) and (pointer: fine)').matches;
// 列表过滤:与输入框一致不 trim;存盘与选中回填均为完整字符串
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,使依赖 input 的统计(如 TextInputController 字数)与 input 监听器中的 onSelect/syncClear 一并更新
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 }));
});
}
}