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 }));
        });
    }
}