File size: 16,012 Bytes
494c9e4 c911b05 494c9e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | /**
* 查询输入框历史(仅保存字符串);不同入口通过 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 }));
});
}
}
|