diff --git a/web/static/css/style.css b/web/static/css/style.css index 850960b0..19f2adc2 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -2257,18 +2257,80 @@ header { left: 0; right: 0; z-index: 200; - max-height: 280px; - overflow-x: hidden; - overflow-y: auto; - padding: 4px; + max-height: 320px; + overflow: hidden; + padding: 0; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; box-shadow: var(--shadow-lg); + flex-direction: column; } .conversation-project-filter-ui.open .conversation-project-filter-dropdown { - display: block; + display: flex; +} + +.conversation-project-filter-search { + flex-shrink: 0; + padding: 8px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); +} + +.conversation-project-filter-search-input { + width: 100%; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.8125rem; + background: var(--bg-primary); + color: var(--text-primary); + font-family: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.conversation-project-filter-search-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); +} + +.conversation-project-filter-search-input::placeholder { + color: var(--text-muted); +} + +.conversation-project-filter-options { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 4px; +} + +.conversation-project-filter-empty { + padding: 10px 12px; + text-align: center; + color: var(--text-muted); + font-size: 0.8125rem; + line-height: 1.4; +} + +.conversation-project-filter-hint, +.conversation-project-filter-status { + padding: 8px 12px; + text-align: center; + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.4; +} + +.conversation-project-filter-status { + color: var(--text-secondary); +} + +.conversation-project-filter-option[hidden] { + display: none !important; } .conversation-project-filter-option { @@ -26976,6 +27038,37 @@ body.app-modal-open { min-height: 0; width: 100%; } +.chat-project-panel-search { + flex-shrink: 0; + padding: 0 0 8px; + width: 100%; +} +.chat-project-panel-search-input { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.8125rem; + background: var(--bg-primary); + color: var(--text-primary); + font-family: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} +.chat-project-panel-search-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); +} +.chat-project-panel-search-input::placeholder { + color: var(--text-muted); +} +.chat-project-panel-hint { + padding: 10px 4px 4px; + font-size: 0.75rem; + color: var(--text-muted); + text-align: center; + line-height: 1.4; +} .chat-project-panel .role-selection-list-main { flex: 1; min-height: 0; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index a02c2bb4..0d6910de 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -504,6 +504,12 @@ "filterByProject": "Filter by project", "filterAllProjects": "All projects", "filterUnboundProjects": "Unbound", + "filterProjectSearch": "Search projects…", + "filterProjectSearchEmpty": "No matching projects", + "filterProjectSearchHint": "Type to search projects", + "filterProjectSearchMore": "Type to find more projects", + "filterProjectSearchLoading": "Searching…", + "filterProjectSearchFailed": "Failed to load projects. Try again.", "projectConversationsTitle": "{{name}} · Conversations", "unboundConversationsTitle": "Unbound conversations", "noProjectConversations": "No conversations in this project", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 3b6e5b7a..f5c68e64 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -492,6 +492,12 @@ "filterByProject": "按项目筛选", "filterAllProjects": "全部项目", "filterUnboundProjects": "未绑定项目", + "filterProjectSearch": "搜索项目…", + "filterProjectSearchEmpty": "没有匹配的项目", + "filterProjectSearchHint": "输入关键字搜索项目", + "filterProjectSearchMore": "更多项目请输入关键字搜索", + "filterProjectSearchLoading": "搜索中…", + "filterProjectSearchFailed": "加载项目失败,请重试", "projectConversationsTitle": "{{name}} · 对话", "unboundConversationsTitle": "未绑定项目", "noProjectConversations": "该项目暂无对话", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index b44d03d0..779b0bc8 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -6166,52 +6166,222 @@ const CONVERSATION_PROJECT_FILTER_NONE = '__none__'; const CONVERSATION_PROJECT_FILTER_SELECT_ID = 'conversation-project-filter'; const CONVERSATION_PROJECT_FILTER_CARET = ''; const BATCH_PROJECT_FILTER_SELECT_ID = 'batch-project-filter'; +const PROJECT_FILTER_REMOTE_SEARCH_LIMIT = 50; +const PROJECT_FILTER_REMOTE_INITIAL_LIMIT = 20; +const PROJECT_FILTER_REMOTE_DEBOUNCE_MS = 300; const projectFilterCustomSelectRegistry = {}; let projectFilterCustomSelectDocBound = false; +function projectFilterT(key, fallback) { + if (typeof window.t === 'function') { + const value = window.t(key); + if (value && value !== key) return value; + } + return fallback; +} + function closeProjectFilterCustomSelect(selectId) { const reg = projectFilterCustomSelectRegistry[selectId]; if (!reg || !reg.wrapper) return; reg.wrapper.classList.remove('open'); if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false'); + if (reg.remoteSearchTimer) { + clearTimeout(reg.remoteSearchTimer); + reg.remoteSearchTimer = null; + } + reg.remoteSearchSeq = (reg.remoteSearchSeq || 0) + 1; + if (reg.searchInput) reg.searchInput.value = ''; } function closeAllProjectFilterCustomSelects() { Object.keys(projectFilterCustomSelectRegistry).forEach(closeProjectFilterCustomSelect); } +function ensureProjectFilterSearchUi(reg) { + if (reg.searchInput && reg.optionsList) return; + const { dropdown } = reg; + dropdown.innerHTML = ''; + + const searchWrap = document.createElement('div'); + searchWrap.className = 'conversation-project-filter-search'; + const searchInput = document.createElement('input'); + searchInput.type = 'search'; + searchInput.className = 'conversation-project-filter-search-input'; + searchInput.setAttribute('autocomplete', 'off'); + searchInput.setAttribute('data-i18n', 'chat.filterProjectSearch'); + searchInput.setAttribute('data-i18n-attr', 'placeholder'); + searchInput.placeholder = projectFilterT('chat.filterProjectSearch', '搜索项目…'); + searchWrap.appendChild(searchInput); + dropdown.appendChild(searchWrap); + reg.searchInput = searchInput; + + const optionsList = document.createElement('div'); + optionsList.className = 'conversation-project-filter-options'; + dropdown.appendChild(optionsList); + reg.optionsList = optionsList; + reg.remoteSearchSeq = 0; + reg.remoteSearchTimer = null; + + searchInput.addEventListener('input', () => scheduleProjectFilterRemoteSearch(reg.select.id)); + searchInput.addEventListener('click', (e) => e.stopPropagation()); + searchInput.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Escape') closeProjectFilterCustomSelect(reg.select.id); + }); +} + +function createProjectFilterOptionButton(value, label, selectedValue) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'conversation-project-filter-option'; + item.setAttribute('role', 'option'); + item.setAttribute('data-value', value); + item.title = label; + if (value === selectedValue) { + item.classList.add('is-selected'); + item.setAttribute('aria-selected', 'true'); + } else { + item.setAttribute('aria-selected', 'false'); + } + const check = document.createElement('span'); + check.className = 'conversation-project-filter-check'; + check.setAttribute('aria-hidden', 'true'); + check.textContent = '✓'; + const labelEl = document.createElement('span'); + labelEl.className = 'conversation-project-filter-option-label'; + labelEl.textContent = label; + labelEl.title = label; + item.appendChild(check); + item.appendChild(labelEl); + return item; +} + +function appendProjectFilterStatusMessage(optionsList, className, text) { + const el = document.createElement('div'); + el.className = className; + el.textContent = text; + optionsList.appendChild(el); + return el; +} + +function renderProjectFilterPinnedOptions(reg) { + const { select, optionsList } = reg; + optionsList.innerHTML = ''; + Array.prototype.forEach.call(select.options, (opt) => { + if (opt.value === '' || opt.value === CONVERSATION_PROJECT_FILTER_NONE) { + optionsList.appendChild(createProjectFilterOptionButton(opt.value, opt.textContent || '', select.value)); + } + }); +} + +function ensureNativeProjectFilterOption(select, projectId, label) { + if (!projectId || projectId === CONVERSATION_PROJECT_FILTER_NONE) return; + if (Array.prototype.some.call(select.options, (opt) => opt.value === projectId)) return; + const opt = document.createElement('option'); + opt.value = projectId; + opt.textContent = label || projectId; + select.appendChild(opt); +} + +function scheduleProjectFilterRemoteSearch(selectId) { + const reg = projectFilterCustomSelectRegistry[selectId]; + if (!reg) return; + if (reg.remoteSearchTimer) clearTimeout(reg.remoteSearchTimer); + reg.remoteSearchTimer = setTimeout(() => { + reg.remoteSearchTimer = null; + loadProjectFilterRemoteOptions(selectId); + }, PROJECT_FILTER_REMOTE_DEBOUNCE_MS); +} + +async function queryProjectFilterRemote(query, limit) { + if (typeof window.searchActiveProjects === 'function') { + return window.searchActiveProjects(query, { limit }); + } + const params = new URLSearchParams({ status: 'active', limit: String(limit) }); + const q = String(query || '').trim(); + if (q) params.set('search', q); + const res = await apiFetch(`/api/projects?${params}`); + if (!res.ok) throw new Error('search failed'); + const data = await res.json(); + const items = data.projects || data.items || (Array.isArray(data) ? data : []); + if (typeof window.rememberProjectsInNameMap === 'function') { + window.rememberProjectsInNameMap(items); + } + return { + items, + total: typeof data.total === 'number' ? data.total : items.length, + }; +} + +async function loadProjectFilterRemoteOptions(selectId) { + const reg = projectFilterCustomSelectRegistry[selectId]; + if (!reg || !reg.optionsList) return; + const query = (reg.searchInput?.value || '').trim(); + const seq = ++reg.remoteSearchSeq; + + renderProjectFilterPinnedOptions(reg); + const loadingEl = appendProjectFilterStatusMessage( + reg.optionsList, + 'conversation-project-filter-status', + projectFilterT('chat.filterProjectSearchLoading', '搜索中…') + ); + + try { + const parsed = await queryProjectFilterRemote( + query, + query ? PROJECT_FILTER_REMOTE_SEARCH_LIMIT : PROJECT_FILTER_REMOTE_INITIAL_LIMIT + ); + if (seq !== reg.remoteSearchSeq) return; + + renderProjectFilterPinnedOptions(reg); + const selected = reg.select.value; + const pinnedValues = new Set(['', CONVERSATION_PROJECT_FILTER_NONE]); + const projects = (parsed.items || []).filter((p) => p && p.id && p.status !== 'archived'); + projects.forEach((p) => { + if (pinnedValues.has(p.id)) return; + reg.optionsList.appendChild( + createProjectFilterOptionButton(p.id, p.name || p.id, selected) + ); + }); + + if (query && projects.length === 0) { + appendProjectFilterStatusMessage( + reg.optionsList, + 'conversation-project-filter-empty', + projectFilterT('chat.filterProjectSearchEmpty', '没有匹配的项目') + ); + } else if (!query && parsed.total > projects.length) { + appendProjectFilterStatusMessage( + reg.optionsList, + 'conversation-project-filter-hint', + projectFilterT('chat.filterProjectSearchMore', '更多项目请输入关键字搜索') + ); + } else if (!query && projects.length === 0) { + appendProjectFilterStatusMessage( + reg.optionsList, + 'conversation-project-filter-hint', + projectFilterT('chat.filterProjectSearchHint', '输入关键字搜索项目') + ); + } + } catch (e) { + if (seq !== reg.remoteSearchSeq) return; + renderProjectFilterPinnedOptions(reg); + appendProjectFilterStatusMessage( + reg.optionsList, + 'conversation-project-filter-empty', + projectFilterT('chat.filterProjectSearchFailed', '加载项目失败,请重试') + ); + } finally { + if (loadingEl && loadingEl.parentNode) loadingEl.remove(); + } +} + function syncProjectFilterCustomSelect(selectId) { const reg = projectFilterCustomSelectRegistry[selectId]; if (!reg) return; - const { select, dropdown, trigger } = reg; + ensureProjectFilterSearchUi(reg); + const { select, trigger } = reg; const valueSpan = trigger.querySelector('.conversation-project-filter-value'); - dropdown.innerHTML = ''; - Array.prototype.forEach.call(select.options, (opt) => { - const item = document.createElement('button'); - item.type = 'button'; - item.className = 'conversation-project-filter-option'; - item.setAttribute('role', 'option'); - item.setAttribute('data-value', opt.value); - const labelText = opt.textContent || ''; - item.title = labelText; - if (opt.value === select.value) { - item.classList.add('is-selected'); - item.setAttribute('aria-selected', 'true'); - } else { - item.setAttribute('aria-selected', 'false'); - } - const check = document.createElement('span'); - check.className = 'conversation-project-filter-check'; - check.setAttribute('aria-hidden', 'true'); - check.textContent = '✓'; - const label = document.createElement('span'); - label.className = 'conversation-project-filter-option-label'; - label.textContent = labelText; - label.title = labelText; - item.appendChild(check); - item.appendChild(label); - dropdown.appendChild(item); - }); const selectedOpt = select.options[select.selectedIndex]; const selectedText = selectedOpt ? (selectedOpt.textContent || '') : ''; if (valueSpan) { @@ -6264,6 +6434,13 @@ function initProjectFilterCustomSelect(selectId) { if (!open) { wrapper.classList.add('open'); trigger.setAttribute('aria-expanded', 'true'); + ensureProjectFilterSearchUi(projectFilterCustomSelectRegistry[selectId]); + const reg = projectFilterCustomSelectRegistry[selectId]; + if (reg?.searchInput) { + reg.searchInput.value = ''; + loadProjectFilterRemoteOptions(selectId); + requestAnimationFrame(() => reg.searchInput.focus()); + } } }); @@ -6273,6 +6450,8 @@ function initProjectFilterCustomSelect(selectId) { e.stopPropagation(); const val = opt.getAttribute('data-value'); if (val === null) return; + const label = opt.querySelector('.conversation-project-filter-option-label')?.textContent || val; + ensureNativeProjectFilterOption(select, val, label); if (select.value !== val) { select.value = val; select.dispatchEvent(new Event('change', { bubbles: true })); @@ -6319,38 +6498,7 @@ function setConversationProjectFilter(projectId) { updateConversationSidebarFilterUI(); } -function isValidConversationProjectFilter(projectId) { - if (!projectId) return true; - if (projectId === CONVERSATION_PROJECT_FILTER_NONE) return true; - const map = window.projectNameById; - if (!map || typeof map !== 'object') return true; - return Object.prototype.hasOwnProperty.call(map, projectId); -} - -async function refreshConversationProjectFilter() { - const sel = document.getElementById('conversation-project-filter'); - if (!sel) return; - const saved = getConversationProjectFilter(); - let projects = []; - if (typeof window.ensureProjectsLoaded === 'function') { - try { - const list = await window.ensureProjectsLoaded(); - projects = (list || []).filter((p) => p && p.id && p.status !== 'archived'); - } catch (e) { /* ignore */ } - } - if (!projects.length) { - try { - const res = await apiFetch('/api/projects?status=active&limit=200'); - if (res.ok) { - const data = await res.json(); - const items = data.projects || data.items || (Array.isArray(data) ? data : []); - projects = items.filter((p) => p && p.id); - if (typeof window.rebuildProjectNameMap === 'function') { - window.rebuildProjectNameMap(items); - } - } - } catch (e) { /* ignore */ } - } +function appendProjectFilterPinnedNativeOptions(sel) { const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目'; const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目'; @@ -6365,16 +6513,44 @@ async function refreshConversationProjectFilter() { unboundOpt.textContent = unboundLabel; unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects'); sel.appendChild(unboundOpt); - projects - .slice() - .sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || '', undefined, { sensitivity: 'base' })) - .forEach((p) => { - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = p.name || p.id; - sel.appendChild(opt); - }); - const normalized = isValidConversationProjectFilter(saved) ? saved : ''; +} + +async function resolveProjectFilterSelection(projectId) { + const saved = (projectId || '').trim(); + if (!saved || saved === CONVERSATION_PROJECT_FILTER_NONE) return saved; + const fetchSummary = typeof window.fetchProjectSummary === 'function' + ? window.fetchProjectSummary + : null; + if (!fetchSummary) return saved; + const project = await fetchSummary(saved); + if (!project || !project.id || project.status === 'archived') return ''; + return project.id; +} + +async function appendSelectedProjectFilterOption(sel, projectId) { + const id = (projectId || '').trim(); + if (!id || id === CONVERSATION_PROJECT_FILTER_NONE) return; + if (Array.prototype.some.call(sel.options, (opt) => opt.value === id)) return; + const fetchSummary = typeof window.fetchProjectSummary === 'function' + ? window.fetchProjectSummary + : null; + const project = fetchSummary ? await fetchSummary(id) : null; + const label = (project && (project.name || project.id)) || (window.projectNameById && window.projectNameById[id]) || id; + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = label; + sel.appendChild(opt); +} + +async function refreshConversationProjectFilter() { + const sel = document.getElementById('conversation-project-filter'); + if (!sel) return; + const saved = getConversationProjectFilter(); + appendProjectFilterPinnedNativeOptions(sel); + const normalized = await resolveProjectFilterSelection(saved); + if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) { + await appendSelectedProjectFilterOption(sel, normalized); + } if (normalized !== saved) setConversationProjectFilter(normalized); sel.value = normalized; syncConversationProjectCustomSelect(); @@ -8256,40 +8432,12 @@ async function refreshBatchProjectFilter() { const sel = document.getElementById('batch-project-filter'); if (!sel) return; const saved = sel.value || ''; - if (typeof window.ensureProjectsLoaded === 'function') { - try { - await window.ensureProjectsLoaded(); - } catch (e) { /* ignore */ } + appendProjectFilterPinnedNativeOptions(sel); + const normalized = await resolveProjectFilterSelection(saved); + if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) { + await appendSelectedProjectFilterOption(sel, normalized); } - const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; - const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目'; - const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目'; - sel.innerHTML = ''; - const allOpt = document.createElement('option'); - allOpt.value = ''; - allOpt.textContent = allLabel; - allOpt.setAttribute('data-i18n', 'chat.filterAllProjects'); - sel.appendChild(allOpt); - const unboundOpt = document.createElement('option'); - unboundOpt.value = CONVERSATION_PROJECT_FILTER_NONE; - unboundOpt.textContent = unboundLabel; - unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects'); - sel.appendChild(unboundOpt); - const source = window.projectNameById ? Object.keys(window.projectNameById) : []; - source - .sort((a, b) => { - const na = (window.projectNameById[a] || a).toLowerCase(); - const nb = (window.projectNameById[b] || b).toLowerCase(); - return na.localeCompare(nb); - }) - .forEach((id) => { - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = window.projectNameById[id] || id; - sel.appendChild(opt); - }); - const valid = !saved || saved === CONVERSATION_PROJECT_FILTER_NONE || (window.projectNameById && window.projectNameById[saved]); - sel.value = valid ? saved : ''; + sel.value = normalized; syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID); } diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 6c9f7916..66c1cb50 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -173,6 +173,39 @@ function rebuildProjectNameMap(list) { }); } +function rememberProjectsInNameMap(list) { + (list || []).forEach((p) => { + if (p && p.id) projectNameById[p.id] = p.name || p.id; + }); +} + +const PROJECT_PICKER_SEARCH_LIMIT = 50; +const PROJECT_PICKER_INITIAL_LIMIT = 20; + +async function searchActiveProjects(query, opts = {}) { + const params = new URLSearchParams(); + params.set('status', opts.status || 'active'); + params.set('limit', String(opts.limit ?? (String(query || '').trim() ? PROJECT_PICKER_SEARCH_LIMIT : PROJECT_PICKER_INITIAL_LIMIT))); + params.set('offset', String(opts.offset ?? 0)); + const q = String(query || '').trim(); + if (q) params.set('search', q); + const res = await apiFetch(`/api/projects?${params}`); + if (!res.ok) throw new Error(tp('projects.loadProjectsFailed')); + const parsed = parseProjectsListResponse(await res.json()); + rememberProjectsInNameMap(parsed.items); + return parsed; +} + +async function fetchProjectSummary(projectId) { + const id = String(projectId || '').trim(); + if (!id) return null; + const res = await apiFetch(`/api/projects/${encodeURIComponent(id)}`); + if (!res.ok) return null; + const project = await res.json(); + if (project && project.id) rememberProjectsInNameMap([project]); + return project; +} + function getProjectsListPageSize() { try { const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10); @@ -309,7 +342,10 @@ async function ensureProjectsLoaded(force) { } function prefetchProjectsForChat() { - ensureProjectsLoaded().catch(() => {}); + const id = (resolveChatProjectSelection() || '').trim(); + if (id && !projectNameById[id]) { + fetchProjectSummary(id).catch(() => {}); + } } /** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */ @@ -2032,27 +2068,20 @@ function getChatProjectSelection() { return getActiveProjectId(); } -function isActiveChatProjectId(id) { - if (!id) return false; - const source = projectsCacheAll.length ? projectsCacheAll : projectsCache; - return source.some((p) => p.id === id && p.status !== 'archived'); -} - -/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */ +/** 用于 UI:返回当前选中的项目 ID(有效性由 normalizeStaleChatProjectSelection 异步校验) */ function resolveChatProjectSelection() { - const raw = getChatProjectSelection(); - if (!raw) return ''; - if (!_projectsListReady) return raw; - return isActiveChatProjectId(raw) ? raw : ''; + return getChatProjectSelection() || ''; } let _normalizingStaleProject = false; -/** 项目列表加载后,清除 localStorage 或对话上残留的失效项目 ID */ +/** 清除 localStorage 或对话上残留的失效项目 ID */ async function normalizeStaleChatProjectSelection() { - if (!_projectsListReady || _normalizingStaleProject) return; - const raw = getChatProjectSelection(); - if (!raw || isActiveChatProjectId(raw)) return; + if (_normalizingStaleProject) return; + const raw = (getChatProjectSelection() || '').trim(); + if (!raw) return; + const project = await fetchProjectSummary(raw); + if (project && project.id && project.status !== 'archived') return; _normalizingStaleProject = true; try { @@ -2079,6 +2108,175 @@ async function normalizeStaleChatProjectSelection() { } } +const PROJECT_PICKER_DEBOUNCE_MS = 300; +const projectPickerPanelState = { + chat: { seq: 0, timer: null }, + webshell: { seq: 0, timer: null }, +}; + +function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) { + const t = tFn || tp; + const isNone = !project.id; + const isSelected = isNone ? !selectedId : selectedId === project.id; + const desc = isNone + ? (project.description || '') + : (project.description || '').trim().slice(0, 80) || t('projects.sharedFactBoard'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : ''); + btn.setAttribute('role', 'option'); + btn.onclick = () => onSelect(project.id || ''); + btn.innerHTML = ` +
${isNone ? '—' : '📁'}
+
+
${escapeHtml(project.name || t('common.untitled'))}
+
${escapeHtml(desc)}
+
+ ${isSelected ? '
' : ''} + `; + list.appendChild(btn); +} + +function appendChatProjectPanelMessage(list, className, text) { + const el = document.createElement('div'); + el.className = className; + el.textContent = text; + list.appendChild(el); + return el; +} + +function pickerMessage(t, key, fallback) { + const value = t(key); + if (!value || value === key) return fallback; + return value; +} + +async function renderProjectPickerPanel(panelKey, config) { + const state = projectPickerPanelState[panelKey]; + const list = document.getElementById(config.listId); + if (!list || !state) return; + const query = (document.getElementById(config.searchInputId)?.value || '').trim(); + const seq = ++state.seq; + const selectedId = config.getSelectedId(); + const t = config.t || tp; + + const renderPinned = () => { + appendChatProjectPanelItem( + list, + { + id: '', + name: t('projects.noProject'), + description: t('projects.noProjectDescription'), + }, + selectedId, + config.onSelect, + t + ); + }; + + list.innerHTML = ''; + renderPinned(); + const loadingEl = appendChatProjectPanelMessage( + list, + 'chat-project-panel-loading', + pickerMessage(t, 'common.loading', '加载中…') + ); + + try { + const parsed = await searchActiveProjects(query, { + limit: query ? PROJECT_PICKER_SEARCH_LIMIT : PROJECT_PICKER_INITIAL_LIMIT, + }); + if (seq !== state.seq) return; + + list.innerHTML = ''; + renderPinned(); + const projects = (parsed.items || []).filter((p) => p && p.id && p.status !== 'archived'); + projects.forEach((p) => { + appendChatProjectPanelItem(list, p, selectedId, config.onSelect, t); + }); + + if (query && projects.length === 0) { + appendChatProjectPanelMessage( + list, + 'chat-project-panel-empty', + pickerMessage(t, 'chat.filterProjectSearchEmpty', '没有匹配的项目') + ); + } else if (!query && parsed.total > projects.length) { + appendChatProjectPanelMessage( + list, + 'chat-project-panel-hint', + pickerMessage(t, 'chat.filterProjectSearchMore', '更多项目请输入关键字搜索') + ); + } + } catch (e) { + if (seq !== state.seq) return; + list.innerHTML = ''; + renderPinned(); + appendChatProjectPanelMessage( + list, + 'chat-project-panel-empty', + pickerMessage(t, 'chat.filterProjectSearchFailed', '加载项目失败,请重试') + ); + } finally { + if (loadingEl.parentNode) loadingEl.remove(); + } +} + +function initProjectPickerPanelSearch(panelKey, searchInputId, onSearch) { + const input = document.getElementById(searchInputId); + if (!input || input.dataset.pickerBound === panelKey) return; + input.dataset.pickerBound = panelKey; + input.addEventListener('input', onSearch); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (panelKey === 'chat' && typeof closeChatProjectPanel === 'function') { + closeChatProjectPanel(); + } else if (panelKey === 'webshell' && typeof wsCloseProjectPanel === 'function') { + wsCloseProjectPanel(); + } + } + }); +} + +function clearProjectPickerPanelSearch(panelKey, searchInputId) { + const state = projectPickerPanelState[panelKey]; + if (!state) return; + state.seq += 1; + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + const input = document.getElementById(searchInputId); + if (input) input.value = ''; +} + +function scheduleProjectPickerPanelSearch(panelKey, loadFn) { + const state = projectPickerPanelState[panelKey]; + if (!state) return; + if (state.timer) clearTimeout(state.timer); + state.timer = setTimeout(() => { + state.timer = null; + loadFn(); + }, PROJECT_PICKER_DEBOUNCE_MS); +} + +async function loadChatProjectPanelList() { + await renderProjectPickerPanel('chat', { + listId: 'chat-project-list', + searchInputId: 'chat-project-search', + getSelectedId: resolveChatProjectSelection, + onSelect: (projectId) => selectChatProject(projectId), + }); +} + +async function ensureChatProjectButtonLabel() { + const id = (resolveChatProjectSelection() || '').trim(); + if (id && !projectNameById[id]) { + await fetchProjectSummary(id); + } + updateChatProjectButtonLabel(); +} + function updateChatProjectButtonLabel() { const textEl = document.getElementById('chat-project-text'); if (!textEl) return; @@ -2086,56 +2284,13 @@ function updateChatProjectButtonLabel() { textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject'); } -function renderChatProjectPanelList() { - const list = document.getElementById('chat-project-list'); - if (!list) return; - const selected = resolveChatProjectSelection(); - const source = projectsCacheAll.length ? projectsCacheAll : projectsCache; - const activeProjects = source.filter((p) => p.status !== 'archived'); - const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects]; - if (!items.length) { - list.innerHTML = `
${escapeHtml(tp('projects.noProjectsClickCreate'))}
`; - return; - } - list.innerHTML = ''; - items.forEach((p) => { - const isNone = !p.id; - const isSelected = isNone ? !selected : selected === p.id; - const desc = isNone - ? (p.description || '') - : (p.description || '').trim().slice(0, 80) || tp('projects.sharedFactBoard'); - const projectId = p.id || ''; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : ''); - btn.setAttribute('role', 'option'); - btn.onclick = () => { - selectChatProject(projectId); - }; - btn.innerHTML = ` -
${isNone ? '—' : '📁'}
-
-
${escapeHtml(p.name || tp('common.untitled'))}
-
${escapeHtml(desc)}
-
- ${isSelected ? '
' : ''} - `; - list.appendChild(btn); - }); -} - async function renderChatProjectPanel() { - const list = document.getElementById('chat-project-list'); - if (!list) return; - list.innerHTML = `
${escapeHtml(tp('common.loading'))}
`; - try { - await ensureProjectsLoaded(); - } catch (e) { - console.warn(e); - list.innerHTML = `
${escapeHtml(tp('projects.loadFailedRetry'))}
`; - return; - } - renderChatProjectPanelList(); + initProjectPickerPanelSearch('chat', 'chat-project-search', () => { + scheduleProjectPickerPanelSearch('chat', () => loadChatProjectPanelList()); + }); + clearProjectPickerPanelSearch('chat', 'chat-project-search'); + await loadChatProjectPanelList(); + requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus()); } function closeChatProjectPanel() { @@ -2146,6 +2301,7 @@ function closeChatProjectPanel() { btn.classList.remove('active'); btn.setAttribute('aria-expanded', 'false'); } + clearProjectPickerPanelSearch('chat', 'chat-project-search'); } async function toggleChatProjectPanel() { @@ -2213,15 +2369,14 @@ async function applyChatProjectSelection(projectId) { async function refreshChatProjectSelector() { if (!document.getElementById('chat-project-btn')) return; try { - await ensureProjectsLoaded(); await normalizeStaleChatProjectSelection(); + await ensureChatProjectButtonLabel(); } catch (e) { console.warn(e); } - updateChatProjectButtonLabel(); const panel = document.getElementById('chat-project-panel'); if (panel && panel.style.display === 'flex') { - renderChatProjectPanelList(); + await loadChatProjectPanelList(); } } @@ -2240,7 +2395,7 @@ function initChatProjectSelector() { renderProjectsPagination(); updateChatProjectButtonLabel(); const panel = document.getElementById('chat-project-panel'); - if (panel && panel.style.display === 'flex') renderChatProjectPanelList(); + if (panel && panel.style.display === 'flex') loadChatProjectPanelList(); if (currentProjectId) { refreshProjectDetailMetaI18n(); const source = projectsCacheAll.length ? projectsCacheAll : projectsCache; @@ -2298,6 +2453,11 @@ window.onChatProjectChange = onChatProjectChange; window.toggleChatProjectPanel = toggleChatProjectPanel; window.closeChatProjectPanel = closeChatProjectPanel; window.selectChatProject = selectChatProject; +window.renderProjectPickerPanel = renderProjectPickerPanel; +window.initProjectPickerPanelSearch = initProjectPickerPanelSearch; +window.clearProjectPickerPanelSearch = clearProjectPickerPanelSearch; +window.scheduleProjectPickerPanelSearch = scheduleProjectPickerPanelSearch; +window.loadChatProjectPanelList = loadChatProjectPanelList; window.prefetchProjectsForChat = prefetchProjectsForChat; window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat; window.getActiveProjectId = getActiveProjectId; @@ -2334,5 +2494,8 @@ window.deleteProjectFactEdge = deleteProjectFactEdge; window.focusProjectFactGraphEdge = focusProjectFactGraphEdge; window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode; window.rebuildProjectNameMap = rebuildProjectNameMap; +window.rememberProjectsInNameMap = rememberProjectsInNameMap; +window.searchActiveProjects = searchActiveProjects; +window.fetchProjectSummary = fetchProjectSummary; window.projectNameById = projectNameById; window.ensureProjectsLoaded = ensureProjectsLoaded; diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index d75f4cf1..c05f5fdc 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -362,6 +362,20 @@ function wsProjectT(key, fallback) { return fallback; } +function wsProjectPickerT(key) { + var fallbacks = { + 'projects.noProject': '无项目', + 'projects.noProjectDescription': '不绑定项目黑板', + 'projects.sharedFactBoard': '共享事实黑板', + 'common.untitled': '未命名', + 'common.loading': '加载中…', + 'chat.filterProjectSearchEmpty': '没有匹配的项目', + 'chat.filterProjectSearchMore': '更多项目请输入关键字搜索', + 'chat.filterProjectSearchFailed': '加载项目失败,请重试', + }; + return wsProjectT(key, fallbacks[key]); +} + function getWebshellAiConvId(conn) { if (!conn || !conn.id) return ''; return webshellAiConvMap[conn.id] || ''; @@ -409,51 +423,32 @@ function wsUpdateProjectButtonLabel() { textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目'); } -async function wsRenderProjectPanelList() { - var list = document.getElementById('ws-project-list'); - if (!list || !webshellCurrentConn) return; - var conn = webshellCurrentConn; - var selected = wsResolveWebshellAiProjectSelection(conn); - var projects = []; - try { - if (typeof window.fetchAllProjects === 'function') { - projects = await window.fetchAllProjects(false); - } - } catch (e) { - list.innerHTML = '
' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '
'; - return; - } - if (typeof window.rebuildProjectNameMap === 'function') { - window.rebuildProjectNameMap(projects); - } - var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; }); - var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects); - list.innerHTML = ''; - items.forEach(function (p) { - var isNone = !p.id; - var isSelected = isNone ? !selected : selected === p.id; - var desc = isNone - ? (p.description || '') - : ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板')); - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : ''); - btn.setAttribute('role', 'option'); - btn.onclick = function () { wsSelectProject(p.id || ''); }; - btn.innerHTML = '
' + (isNone ? '—' : '📁') + '
' + - '
' + - '
' + escapeHtml(p.name || '未命名') + '
' + - '
' + escapeHtml(desc) + '
' + - (isSelected ? '
' : ''); - list.appendChild(btn); +async function wsLoadProjectPanelList() { + if (typeof window.renderProjectPickerPanel !== 'function') return; + await window.renderProjectPickerPanel('webshell', { + listId: 'ws-project-list', + searchInputId: 'ws-project-search', + getSelectedId: function () { + return webshellCurrentConn ? wsResolveWebshellAiProjectSelection(webshellCurrentConn) : ''; + }, + onSelect: function (projectId) { wsSelectProject(projectId); }, + t: wsProjectPickerT, }); } async function wsRenderProjectPanel() { - var list = document.getElementById('ws-project-list'); - if (!list) return; - list.innerHTML = '
' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '
'; - await wsRenderProjectPanelList(); + if (typeof window.initProjectPickerPanelSearch === 'function') { + window.initProjectPickerPanelSearch('webshell', 'ws-project-search', function () { + if (typeof window.scheduleProjectPickerPanelSearch === 'function') { + window.scheduleProjectPickerPanelSearch('webshell', function () { wsLoadProjectPanelList(); }); + } + }); + } + if (typeof window.clearProjectPickerPanelSearch === 'function') { + window.clearProjectPickerPanelSearch('webshell', 'ws-project-search'); + } + await wsLoadProjectPanelList(); + requestAnimationFrame(function () { document.getElementById('ws-project-search')?.focus(); }); } function wsCloseProjectPanel() { @@ -464,6 +459,9 @@ function wsCloseProjectPanel() { btn.classList.remove('active'); btn.setAttribute('aria-expanded', 'false'); } + if (typeof window.clearProjectPickerPanelSearch === 'function') { + window.clearProjectPickerPanelSearch('webshell', 'ws-project-search'); + } } async function wsToggleProjectPanel() { @@ -2230,6 +2228,9 @@ function selectWebshell(id, stateReady) { '' + '' + '
' + + '' + '
' + '
+