diff --git a/src/components/AIChat/input/AIChatComposer.vue b/src/components/AIChat/input/AIChatComposer.vue index 2051ff7f..29f390ac 100644 --- a/src/components/AIChat/input/AIChatComposer.vue +++ b/src/components/AIChat/input/AIChatComposer.vue @@ -1,5 +1,33 @@ @@ -97,21 +281,37 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight }) /{{ activeSkillName }} - + :starter-kit="starterKit" + :mention="mentionOptions" + :image="false" + :editor-props="editorProps" + @update:model-value="handleDocumentUpdate" + > + + + + + + + + diff --git a/src/components/AIChat/input/AIChatInput.vue b/src/components/AIChat/input/AIChatInput.vue index 3c705c49..8b489939 100644 --- a/src/components/AIChat/input/AIChatInput.vue +++ b/src/components/AIChat/input/AIChatInput.vue @@ -3,84 +3,103 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import AIChatComposer from './AIChatComposer.vue' -import MemberMentionMenu from './MemberMentionMenu.vue' import SlashCommandMenu from './SlashCommandMenu.vue' import { useSkillStore, type SkillSummary } from '@/stores/skill' +import { useSessionStore } from '@/stores/session' import type { MentionedMemberContext } from '@/composables/useAIChat' import type { MemberWithStats } from '@/types/analysis' import { useDataService } from '@/services' +import type { AIEntityRef, ContactListItem, ContactsResponse } from '@openchatlab/shared-types' const { t } = useI18n() -const props = defineProps<{ - sessionId: string - disabled?: boolean - status?: 'ready' | 'submitted' | 'streaming' | 'error' - chatType: 'group' | 'private' - embedded?: boolean -}>() +const props = withDefaults( + defineProps<{ + sessionId?: string + disabled?: boolean + status?: 'ready' | 'submitted' | 'streaming' | 'error' + chatType?: 'group' | 'private' + mentionScope?: 'session' | 'global' + skillsEnabled?: boolean + placeholder?: string + embedded?: boolean + }>(), + { + sessionId: '', + chatType: 'group', + mentionScope: 'session', + skillsEnabled: true, + placeholder: '', + embedded: false, + } +) const emit = defineEmits<{ - send: [payload: { content: string; mentionedMembers: MentionedMemberContext[] }] + send: [payload: { content: string; mentionedMembers: MentionedMemberContext[]; entityRefs: AIEntityRef[] }] stop: [] manageSkills: [] skillActivated: [skill: SkillSummary] }>() -interface MentionCandidate extends MentionedMemberContext { +interface MentionCandidate { + key: string + displayName: string insertName: string - subtitle: string + avatar: string | null + searchText: string + mentionText: string + mentionedMember?: MentionedMemberContext + entityRef?: AIEntityRef +} + +interface MentionMenuItem { + id: string + label: string + avatar: string | null searchText: string } -interface MentionRange { - start: number - end: number - rawToken: string -} +type MentionTab = 'contact' | 'group' const skillStore = useSkillStore() +const sessionStore = useSessionStore() const { compatibleSkills, activeSkill, activeSkillId, isLoaded } = storeToRefs(skillStore) const rootRef = ref(null) const composerRef = ref | null>(null) const inputValue = ref('') const mentionMembers = ref([]) -const selectedMentions = ref([]) -const isLoadingMentionMembers = ref(false) +const globalMentionCandidates = ref([]) +const mentionRegistry = new Map() +const selectedMentionIds = ref([]) +const mentionSearchTerm = ref('') +const mentionTab = ref('contact') const showSlashMenu = ref(false) const slashFilter = ref('') const slashHighlightIndex = ref(0) -const showMentionMenu = ref(false) -const mentionFilter = ref('') -const mentionHighlightIndex = ref(0) -const mentionRange = ref(null) const isComposing = ref(false) const dismissedSlashValue = ref(null) -const dismissedMentionToken = ref(null) -const suspendInputParsing = ref(false) +let globalMentionSearchTimer: ReturnType | null = null +let globalMentionPollTimer: ReturnType | null = null +let globalMentionRequestId = 0 +const GLOBAL_MENTION_POLL_INTERVAL_MS = 1500 const canSubmit = computed(() => inputValue.value.trim().length > 0 && !props.disabled) const inputPlaceholder = computed(() => { - if (activeSkill.value && inputValue.value.trim().length === 0) { + if (props.placeholder) return props.placeholder + if (props.skillsEnabled && activeSkill.value && inputValue.value.trim().length === 0) { return t('ai.chat.input.placeholderWithActiveSkill', { name: activeSkill.value.name }) } return t('ai.chat.input.placeholderWithSlash') }) const sendButtonTitle = computed(() => { - if (props.status === 'streaming') { - return '' - } - if (canSubmit.value) { - return t('ai.chat.input.send') - } - if (activeSkill.value) { - return t('ai.chat.input.needMoreThanSkill') - } + if (props.status === 'streaming') return '' + if (canSubmit.value) return t('ai.chat.input.send') + if (props.skillsEnabled && activeSkill.value) return t('ai.chat.input.needMoreThanSkill') return t('ai.chat.input.needQuestion') }) -const mentionCandidates = computed(() => { +const sessionMentionCandidates = computed(() => { const nameCount = new Map() mentionMembers.value.forEach((member) => { @@ -91,20 +110,19 @@ const mentionCandidates = computed(() => { return mentionMembers.value.map((member) => { const displayName = member.groupNickname || member.accountName || member.platformId const insertName = (nameCount.get(displayName) ?? 0) > 1 ? `${displayName}·${member.platformId}` : displayName - const subtitleParts = [member.platformId] - - if (member.aliases.length > 0) { - subtitleParts.push(member.aliases.join(' / ')) - } - return { - memberId: member.id, - platformId: member.platformId, + key: `member:${props.sessionId}:${member.id}`, displayName, - aliases: [...member.aliases], + avatar: member.avatar, mentionText: `@${insertName}`, insertName, - subtitle: subtitleParts.join(' · '), + mentionedMember: { + memberId: member.id, + platformId: member.platformId, + displayName, + aliases: [...member.aliases], + mentionText: `@${insertName}`, + }, searchText: [ displayName, member.groupNickname || '', @@ -119,7 +137,30 @@ const mentionCandidates = computed(() => { }) }) +const mentionCandidates = computed(() => { + if (props.mentionScope !== 'global') return sessionMentionCandidates.value + return globalMentionCandidates.value.filter((candidate) => + mentionTab.value === 'contact' ? candidate.entityRef?.type === 'contact' : candidate.entityRef?.type === 'session' + ) +}) + +const mentionMenuItems = computed(() => { + const keyword = mentionSearchTerm.value.trim().toLocaleLowerCase() + const candidates = + props.mentionScope === 'global' && keyword + ? mentionCandidates.value.filter((candidate) => candidate.searchText.includes(keyword)) + : mentionCandidates.value + + return candidates.map((candidate) => ({ + id: candidate.key, + label: candidate.insertName, + avatar: candidate.avatar, + searchText: candidate.searchText, + })) +}) + const filteredSkills = computed(() => { + if (!props.skillsEnabled) return [] const keyword = slashFilter.value.trim().toLocaleLowerCase() if (!keyword) return compatibleSkills.value @@ -129,51 +170,147 @@ const filteredSkills = computed(() => { }) }) -const filteredMentionCandidates = computed(() => { - const keyword = mentionFilter.value.trim().toLocaleLowerCase() - if (!keyword) return mentionCandidates.value - return mentionCandidates.value.filter((member) => member.searchText.includes(keyword)) -}) - -function syncTextareaHeight() { - composerRef.value?.syncHeight() -} - -function focusTextarea() { - composerRef.value?.focus() +function focusEditor() { + composerRef.value?.focus('end') } async function loadMentionMembers() { - if (!props.sessionId) { + if (props.mentionScope !== 'session' || !props.sessionId) { mentionMembers.value = [] return } - isLoadingMentionMembers.value = true try { const members = await useDataService().getMembers(props.sessionId) mentionMembers.value = [...members].sort((a, b) => b.messageCount - a.messageCount) } catch (error) { - console.error('加载 AI @ 成员列表失败:', error) + console.error('Failed to load AI mention members:', error) mentionMembers.value = [] - } finally { - isLoadingMentionMembers.value = false } } +function buildGlobalMentionCandidates(contacts: ContactListItem[], query: string): MentionCandidate[] { + const groupLabel = t('ai.global.entityPicker.groups') + const rawCandidates = [ + ...contacts.map((contact) => ({ + key: `contact:${contact.key}`, + displayName: contact.displayName, + avatar: contact.avatar, + duplicateSuffix: contact.platformId, + searchText: [contact.displayName, contact.platformId, contact.aliases.join(' ')].join(' ').toLocaleLowerCase(), + entityRef: { + type: 'contact' as const, + contactKey: contact.key, + displayName: contact.displayName, + }, + })), + ...sessionStore.sessions + .filter((session) => session.type === 'group') + .map((session) => ({ + key: `session:${session.id}`, + displayName: session.name, + avatar: session.groupAvatar, + duplicateSuffix: groupLabel, + searchText: [session.name, session.platform].join(' ').toLocaleLowerCase(), + entityRef: { + type: 'session' as const, + sessionId: session.id, + displayName: session.name, + sessionType: 'group' as const, + }, + })), + ] + + const keyword = query.trim().toLocaleLowerCase() + const filteredCandidates = keyword + ? rawCandidates.filter((candidate) => candidate.searchText.includes(keyword)) + : rawCandidates + const nameCount = new Map() + filteredCandidates.forEach((candidate) => { + nameCount.set(candidate.displayName, (nameCount.get(candidate.displayName) ?? 0) + 1) + }) + + return filteredCandidates.map((candidate) => { + const insertName = + (nameCount.get(candidate.displayName) ?? 0) > 1 + ? `${candidate.displayName}·${candidate.duplicateSuffix}` + : candidate.displayName + return { + key: candidate.key, + displayName: candidate.displayName, + insertName, + avatar: candidate.avatar, + searchText: candidate.searchText, + mentionText: `@${insertName}`, + entityRef: candidate.entityRef, + } + }) +} + +async function loadGlobalMentionCandidates(query: string) { + const requestId = ++globalMentionRequestId + try { + const options = { + acceptStale: true, + timeRangePreset: 'all', + page: 1, + pageSize: 100, + query: query.trim() || undefined, + } as const + const responses = await Promise.all([ + useDataService().getContacts({ ...options, pool: 'friend' }), + useDataService().getContacts({ ...options, pool: 'non_friend' }), + ]) + if (requestId !== globalMentionRequestId) return + + const contactsByKey = new Map() + responses.flatMap((response) => response.contacts).forEach((contact) => contactsByKey.set(contact.key, contact)) + globalMentionCandidates.value = buildGlobalMentionCandidates([...contactsByKey.values()], query) + scheduleGlobalMentionPolling(responses, query) + } catch (error) { + if (requestId !== globalMentionRequestId) return + stopGlobalMentionPolling() + console.error('Failed to load global AI mention candidates:', error) + globalMentionCandidates.value = buildGlobalMentionCandidates([], query) + } +} + +function stopGlobalMentionPolling() { + if (!globalMentionPollTimer) return + clearTimeout(globalMentionPollTimer) + globalMentionPollTimer = null +} + +function scheduleGlobalMentionPolling(responses: ContactsResponse[], query: string) { + stopGlobalMentionPolling() + if (props.mentionScope !== 'global') return + if (!responses.some((response) => response.task?.status === 'running')) return + + globalMentionPollTimer = setTimeout(() => { + globalMentionPollTimer = null + void loadGlobalMentionCandidates(query) + }, GLOBAL_MENTION_POLL_INTERVAL_MS) +} + +function scheduleGlobalMentionSearch(query: string) { + if (globalMentionSearchTimer) clearTimeout(globalMentionSearchTimer) + stopGlobalMentionPolling() + globalMentionRequestId++ + globalMentionSearchTimer = setTimeout( + () => { + globalMentionSearchTimer = null + void loadGlobalMentionCandidates(query) + }, + query ? 200 : 0 + ) +} + function resetSlashState() { showSlashMenu.value = false slashFilter.value = '' slashHighlightIndex.value = 0 } -function resetMentionState() { - showMentionMenu.value = false - mentionFilter.value = '' - mentionHighlightIndex.value = 0 - mentionRange.value = null -} - function dismissSlashMenu() { if (/^\s*\/([^\n]*)$/.test(inputValue.value)) { dismissedSlashValue.value = inputValue.value @@ -181,15 +318,8 @@ function dismissSlashMenu() { resetSlashState() } -function dismissMentionMenu() { - if (mentionRange.value) { - dismissedMentionToken.value = mentionRange.value.rawToken - } - resetMentionState() -} - function updateSlashState(value: string) { - if (props.disabled) { + if (props.disabled || !props.skillsEnabled) { resetSlashState() return } @@ -198,7 +328,6 @@ function updateSlashState(value: string) { dismissedSlashValue.value = null } - // 只在输入开头检测 slash,避免普通文本中的 / 误触发技能菜单。 const match = value.match(/^\s*\/([^\n]*)$/) if (!match) { resetSlashState() @@ -214,225 +343,64 @@ function updateSlashState(value: string) { } showSlashMenu.value = true - if (shouldResetHighlight) { - slashHighlightIndex.value = 0 - } -} - -function getTextareaSelection() { - return composerRef.value?.getSelection() ?? { start: inputValue.value.length, end: inputValue.value.length } -} - -function extractMentionRange(value: string): (MentionRange & { query: string }) | null { - const { start, end } = getTextareaSelection() - if (start !== end) return null - - const beforeCursor = value.slice(0, start) - // 允许在正文任意位置通过 @ 触发成员选择,不强制要求前面必须是空白。 - const match = beforeCursor.match(/@([^\s@]*)$/) - if (!match) return null - - const query = match[1] - const tokenStart = beforeCursor.length - query.length - 1 - - return { - start: tokenStart, - end: start, - rawToken: beforeCursor.slice(tokenStart), - query, - } -} - -function updateMentionState(value: string) { - if (props.disabled || showSlashMenu.value) { - resetMentionState() - return - } - - const nextMention = extractMentionRange(value) - - if (dismissedMentionToken.value && dismissedMentionToken.value !== nextMention?.rawToken) { - dismissedMentionToken.value = null - } - - if (!nextMention) { - resetMentionState() - return - } - - const shouldResetHighlight = - !showMentionMenu.value || - mentionFilter.value !== nextMention.query || - mentionRange.value?.rawToken !== nextMention.rawToken - - mentionFilter.value = nextMention.query - mentionRange.value = { - start: nextMention.start, - end: nextMention.end, - rawToken: nextMention.rawToken, - } - - if (dismissedMentionToken.value === nextMention.rawToken) { - showMentionMenu.value = false - return - } - - showMentionMenu.value = true - if (shouldResetHighlight) { - mentionHighlightIndex.value = 0 - } -} - -function updateInputMenus(value: string) { - updateSlashState(value) - - if (showSlashMenu.value) { - resetMentionState() - return - } - - updateMentionState(value) -} - -function syncSelectedMentions(value: string) { - selectedMentions.value = selectedMentions.value.filter((member) => value.includes(member.mentionText)) -} - -function deleteMentionAtCursor(): boolean { - const { start, end } = getTextareaSelection() - if (start !== end || start === 0) return false - - const mentionTexts = [...new Set(selectedMentions.value.map((member) => member.mentionText))].sort( - (a, b) => b.length - a.length - ) - - for (const mentionText of mentionTexts) { - let searchIndex = inputValue.value.indexOf(mentionText) - - while (searchIndex !== -1) { - const mentionStart = searchIndex - const mentionEnd = searchIndex + mentionText.length - const deleteEnd = inputValue.value[mentionEnd] === ' ' ? mentionEnd + 1 : mentionEnd - - // 光标落在 mention 内部、末尾或其后面的补位空格时,统一整段删除。 - if (start > mentionStart && start <= deleteEnd) { - suspendInputParsing.value = true - inputValue.value = inputValue.value.slice(0, mentionStart) + inputValue.value.slice(deleteEnd) - dismissedMentionToken.value = null - - nextTick(() => { - syncTextareaHeight() - focusTextarea() - composerRef.value?.setSelectionRange(mentionStart, mentionStart) - suspendInputParsing.value = false - updateInputMenus(inputValue.value) - }) - - return true - } - - searchIndex = inputValue.value.indexOf(mentionText, searchIndex + mentionText.length) - } - } - - return false + if (shouldResetHighlight) slashHighlightIndex.value = 0 } function clearActiveSkill() { skillStore.activateSkill(null) - nextTick(focusTextarea) + nextTick(focusEditor) } function openSkillSelector() { - if (props.disabled) return + if (props.disabled || !props.skillsEnabled) return + if (activeSkillId.value) skillStore.activateSkill(null) - // 从外部快捷入口进入技能选择时,统一回到 slash 模式并清掉旧技能上下文。 - if (activeSkillId.value) { - skillStore.activateSkill(null) - } - - suspendInputParsing.value = true - inputValue.value = '/' dismissedSlashValue.value = null - - nextTick(() => { - syncTextareaHeight() - focusTextarea() - composerRef.value?.setSelectionRange(1, 1) - suspendInputParsing.value = false - updateInputMenus(inputValue.value) - }) + inputValue.value = '/' + nextTick(focusEditor) } function fillInput(content: string) { if (props.disabled) return - // 预设问题只回填到输入框,保留用户二次编辑的机会。 - suspendInputParsing.value = true - inputValue.value = content dismissedSlashValue.value = null - - nextTick(() => { - syncTextareaHeight() - focusTextarea() - const cursor = inputValue.value.length - composerRef.value?.setSelectionRange(cursor, cursor) - suspendInputParsing.value = false - updateInputMenus(inputValue.value) - }) + inputValue.value = content + nextTick(focusEditor) } -function handleSelectMention( - member: Pick -) { - if (props.disabled || !mentionRange.value) return +function handleMentionsChange(ids: string[]) { + selectedMentionIds.value = ids +} - const prefix = inputValue.value.slice(0, mentionRange.value.start) - const suffix = inputValue.value.slice(mentionRange.value.end) - const mentionText = `@${member.insertName}` - const nextValue = `${prefix}${mentionText} ${suffix}` - - selectedMentions.value = [ - ...selectedMentions.value.filter((item) => item.memberId !== member.memberId), - { - memberId: member.memberId, - platformId: member.platformId, - displayName: member.displayName, - aliases: [...member.aliases], - mentionText, - }, - ] - - suspendInputParsing.value = true - inputValue.value = nextValue - dismissedMentionToken.value = null - resetMentionState() - - nextTick(() => { - const cursor = prefix.length + mentionText.length + 1 - syncTextareaHeight() - focusTextarea() - composerRef.value?.setSelectionRange(cursor, cursor) - suspendInputParsing.value = false - updateInputMenus(inputValue.value) - }) +function handleMentionSearchTerm(value: string) { + mentionSearchTerm.value = value + if (props.mentionScope === 'global') scheduleGlobalMentionSearch(value) } function handleSubmit() { if (!canSubmit.value) return + const candidates = selectedMentionIds.value.flatMap((id) => { + const candidate = mentionRegistry.get(id) + return candidate ? [candidate] : [] + }) + emit('send', { content: inputValue.value.trim(), - mentionedMembers: [...selectedMentions.value], + mentionedMembers: candidates.flatMap((candidate) => + candidate.mentionedMember + ? [{ ...candidate.mentionedMember, aliases: [...candidate.mentionedMember.aliases] }] + : [] + ), + entityRefs: candidates.flatMap((candidate) => (candidate.entityRef ? [{ ...candidate.entityRef }] : [])), }) - inputValue.value = '' - dismissedSlashValue.value = null - dismissedMentionToken.value = null - selectedMentions.value = [] - resetMentionState() - // 技能为单次消息生效:发送后立即清空,下一次提问需重新选择。 - if (activeSkillId.value) { + inputValue.value = '' + selectedMentionIds.value = [] + mentionSearchTerm.value = '' + dismissedSlashValue.value = null + + if (props.skillsEnabled && activeSkillId.value) { skillStore.activateSkill(null) } } @@ -442,15 +410,10 @@ function handleSelectSkill(skill: SkillSummary) { skillStore.activateSkill(skill.id) emit('skillActivated', skill) - - // slash 选择技能只改变当前上下文,不应替用户生成一条消息。 inputValue.value = '' dismissedSlashValue.value = null resetSlashState() - - nextTick(() => { - focusTextarea() - }) + nextTick(focusEditor) } function handleManageSkills() { @@ -460,135 +423,69 @@ function handleManageSkills() { function moveSlashHighlight(step: 1 | -1) { if (!filteredSkills.value.length) return - const total = filteredSkills.value.length slashHighlightIndex.value = (slashHighlightIndex.value + step + total) % total } -function moveMentionHighlight(step: 1 | -1) { - if (!filteredMentionCandidates.value.length) return - - const total = filteredMentionCandidates.value.length - mentionHighlightIndex.value = (mentionHighlightIndex.value + step + total) % total -} - function handleKeydown(event: KeyboardEvent) { - if (event.key === 'Backspace' && inputValue.value.length === 0 && activeSkillId.value) { + if (props.skillsEnabled && event.key === 'Backspace' && inputValue.value.length === 0 && activeSkillId.value) { event.preventDefault() clearActiveSkill() return } - if (event.key === 'Backspace' && deleteMentionAtCursor()) { - event.preventDefault() - return - } - - if (showMentionMenu.value) { - if (event.key === 'ArrowDown') { - event.preventDefault() - moveMentionHighlight(1) - return - } - - if (event.key === 'ArrowUp') { - event.preventDefault() - moveMentionHighlight(-1) - return - } - - if (event.key === 'Escape') { - event.preventDefault() - dismissMentionMenu() - return - } - - if ((event.key === 'Enter' && !event.shiftKey && !isComposing.value) || event.key === 'Tab') { - event.preventDefault() - const member = filteredMentionCandidates.value[mentionHighlightIndex.value] - if (member) { - handleSelectMention(member) - } - return - } - } - if (showSlashMenu.value) { if (event.key === 'ArrowDown') { event.preventDefault() moveSlashHighlight(1) return } - if (event.key === 'ArrowUp') { event.preventDefault() moveSlashHighlight(-1) return } - if (event.key === 'Escape') { event.preventDefault() dismissSlashMenu() return } - if ((event.key === 'Enter' && !event.shiftKey && !isComposing.value) || event.key === 'Tab') { event.preventDefault() const skill = filteredSkills.value[slashHighlightIndex.value] - if (skill) { - handleSelectSkill(skill) - } + if (skill) handleSelectSkill(skill) return } } - if (event.key === 'Enter' && !event.shiftKey && !isComposing.value) { + if (event.key === 'Enter' && !event.shiftKey && !isComposing.value && !event.isComposing) { event.preventDefault() handleSubmit() } } function handleDocumentMouseDown(event: MouseEvent) { - if ((!showSlashMenu.value && !showMentionMenu.value) || !rootRef.value) return - + if (!showSlashMenu.value || !rootRef.value) return const target = event.target - if (target instanceof Node && !rootRef.value.contains(target)) { - dismissSlashMenu() - dismissMentionMenu() - } + if (target instanceof Node && !rootRef.value.contains(target)) dismissSlashMenu() } -function handleCursorChange() { - if (suspendInputParsing.value) return - updateInputMenus(inputValue.value) -} +watch(inputValue, updateSlashState) watch( - () => props.chatType, - (chatType) => { - skillStore.setFilterContext(chatType) + mentionCandidates, + (candidates) => { + candidates.forEach((candidate) => mentionRegistry.set(candidate.key, candidate)) }, { immediate: true } ) -watch(inputValue, async (value) => { - syncSelectedMentions(value) - if (!suspendInputParsing.value) { - updateInputMenus(value) - } - await nextTick() - syncTextareaHeight() -}) - watch( filteredSkills, (skills) => { if (skills.length === 0) { slashHighlightIndex.value = 0 - return - } - - if (slashHighlightIndex.value >= skills.length) { + } else if (slashHighlightIndex.value >= skills.length) { slashHighlightIndex.value = skills.length - 1 } }, @@ -596,16 +493,9 @@ watch( ) watch( - filteredMentionCandidates, - (members) => { - if (members.length === 0) { - mentionHighlightIndex.value = 0 - return - } - - if (mentionHighlightIndex.value >= members.length) { - mentionHighlightIndex.value = members.length - 1 - } + () => [props.chatType, props.skillsEnabled] as const, + ([chatType, skillsEnabled]) => { + if (skillsEnabled) skillStore.setFilterContext(chatType) }, { immediate: true } ) @@ -613,34 +503,40 @@ watch( watch( () => props.disabled, (disabled) => { - if (disabled) { - dismissSlashMenu() - dismissMentionMenu() - } + if (disabled) dismissSlashMenu() } ) watch( - () => props.sessionId, + () => [props.sessionId, props.mentionScope] as const, () => { - selectedMentions.value = [] - resetMentionState() - loadMentionMembers() + selectedMentionIds.value = [] + mentionSearchTerm.value = '' + void loadMentionMembers() + stopGlobalMentionPolling() + globalMentionRequestId++ + + if (props.mentionScope === 'global') { + globalMentionCandidates.value = buildGlobalMentionCandidates([], '') + void loadGlobalMentionCandidates('') + } else { + globalMentionCandidates.value = [] + } }, { immediate: true } ) onMounted(async () => { - if (!isLoaded.value) { + if (props.skillsEnabled && !isLoaded.value) { await skillStore.loadSkills() } - - await nextTick() - syncTextareaHeight() document.addEventListener('mousedown', handleDocumentMouseDown) }) onBeforeUnmount(() => { + if (globalMentionSearchTimer) clearTimeout(globalMentionSearchTimer) + stopGlobalMentionPolling() + globalMentionRequestId += 1 document.removeEventListener('mousedown', handleDocumentMouseDown) }) @@ -654,6 +550,7 @@ defineExpose({ - - diff --git a/src/components/AIChat/input/AIChatMentionMenu.vue b/src/components/AIChat/input/AIChatMentionMenu.vue new file mode 100644 index 00000000..6e6c26b7 --- /dev/null +++ b/src/components/AIChat/input/AIChatMentionMenu.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/src/components/AIChat/input/MemberMentionMenu.vue b/src/components/AIChat/input/MemberMentionMenu.vue deleted file mode 100644 index ff2d88ba..00000000 --- a/src/components/AIChat/input/MemberMentionMenu.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - - - - {{ t('ai.chat.input.mentionHint') }} - - - - - - - - {{ t('common.loading') }} - - - - - - - @{{ member.insertName }} - - - {{ member.subtitle }} - - - - - - - - {{ t('ai.chat.input.mentionEmpty') }} - - - {{ t('ai.chat.input.mentionEmptyHint') }} - - - - - - - - diff --git a/src/components/AIChat/input/SimpleAIChatInput.vue b/src/components/AIChat/input/SimpleAIChatInput.vue index cfc49256..bea69ffb 100644 --- a/src/components/AIChat/input/SimpleAIChatInput.vue +++ b/src/components/AIChat/input/SimpleAIChatInput.vue @@ -42,8 +42,7 @@ function fillInput(content: string) { if (props.disabled) return inputValue.value = content nextTick(() => { - composerRef.value?.focus() - composerRef.value?.setSelectionRange(content.length, content.length) + composerRef.value?.focus('end') }) } diff --git a/src/components/common/Sidebar.vue b/src/components/common/Sidebar.vue index c0668984..4707102d 100644 --- a/src/components/common/Sidebar.vue +++ b/src/components/common/Sidebar.vue @@ -519,6 +519,14 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) { @click="handleImport" /> + + - - diff --git a/src/components/common/avatar/LazyAvatar.vue b/src/components/common/avatar/LazyAvatar.vue index e9ba1463..09500ae8 100644 --- a/src/components/common/avatar/LazyAvatar.vue +++ b/src/components/common/avatar/LazyAvatar.vue @@ -8,7 +8,7 @@ const props = withDefaults( defineProps<{ src?: string | null alt: string - text: string + text?: string rootClass?: AvatarClass imageClass?: AvatarClass fallbackClass?: AvatarClass @@ -16,6 +16,7 @@ const props = withDefaults( }>(), { src: null, + text: '', rootClass: 'h-8 w-8 shrink-0', imageClass: 'h-8 w-8 rounded-full object-cover', fallbackClass: 'flex h-8 w-8 items-center justify-center rounded-full text-[10px] font-bold', @@ -28,6 +29,7 @@ const shouldLoadImage = ref(false) let stopObserving: (() => void) | null = null const hasAvatar = computed(() => Boolean(props.src)) +const fallbackText = computed(() => props.text || props.alt.trim().slice(0, 1)) function cleanupObserver() { stopObserving?.() @@ -74,7 +76,7 @@ onBeforeUnmount(cleanupObserver) decoding="async" /> - {{ text }} + {{ fallbackText }} diff --git a/src/i18n/locales/en-US/ai.json b/src/i18n/locales/en-US/ai.json index 0413963a..2a006255 100644 --- a/src/i18n/locales/en-US/ai.json +++ b/src/i18n/locales/en-US/ai.json @@ -270,14 +270,14 @@ } }, "global": { - "title": "AI Analysis", + "title": "AI Chat", "subtitle": "Query local chats across contacts and groups", "empty": { "title": "Start with a relationship", "description": "Select contacts or groups before asking, or search globally for a topic when you no longer remember who you discussed it with. Only the local data needed for this question is read." }, "input": { - "placeholder": "Ask a question, optionally selecting contacts or groups first..." + "placeholder": "Ask a question, or type {'@'} to select contacts or groups..." }, "entityPicker": { "title": "Select contacts or groups", diff --git a/src/i18n/locales/ja-JP/ai.json b/src/i18n/locales/ja-JP/ai.json index 9a178c68..dfad22bd 100644 --- a/src/i18n/locales/ja-JP/ai.json +++ b/src/i18n/locales/ja-JP/ai.json @@ -270,14 +270,14 @@ } }, "global": { - "title": "AI 分析", + "title": "AI チャット", "subtitle": "連絡先やグループを横断してローカルのチャットを検索", "empty": { "title": "関係を選んで分析を始める", "description": "連絡先やグループを選んで質問できます。誰と話したか忘れた話題は全体から検索できます。現在の質問に必要なローカルデータだけを読み取ります。" }, "input": { - "placeholder": "質問を入力。先に連絡先やグループを選択できます..." + "placeholder": "質問を入力、または {'@'} で連絡先やグループを選択..." }, "entityPicker": { "title": "連絡先またはグループを選択", diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json index 6e107978..79633c4f 100644 --- a/src/i18n/locales/zh-CN/ai.json +++ b/src/i18n/locales/zh-CN/ai.json @@ -270,14 +270,14 @@ } }, "global": { - "title": "AI 分析", + "title": "AI 对话", "subtitle": "跨联系人和群聊查询本地聊天记录", "empty": { "title": "从一段关系开始分析", "description": "选择联系人或群聊后提问,也可以直接搜索你忘记和谁聊过的话题。只会按当前问题读取必要的本地数据。" }, "input": { - "placeholder": "输入问题,可先选择联系人或群聊..." + "placeholder": "输入问题,输入 {'@'} 选择联系人或群聊..." }, "entityPicker": { "title": "选择联系人或群聊", diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json index d0b8bde6..cb0eefe9 100644 --- a/src/i18n/locales/zh-TW/ai.json +++ b/src/i18n/locales/zh-TW/ai.json @@ -270,14 +270,14 @@ } }, "global": { - "title": "AI 分析", + "title": "AI 對話", "subtitle": "跨聯絡人與群聊查詢本機聊天記錄", "empty": { "title": "從一段關係開始分析", "description": "選擇聯絡人或群聊後提問,也可以直接搜尋你忘記和誰聊過的話題。只會按目前問題讀取必要的本機資料。" }, "input": { - "placeholder": "輸入問題,可先選擇聯絡人或群聊..." + "placeholder": "輸入問題,輸入 {'@'} 選擇聯絡人或群聊..." }, "entityPicker": { "title": "選擇聯絡人或群聊", diff --git a/src/pages/global-ai/components/GlobalEntityPicker.vue b/src/pages/global-ai/components/GlobalEntityPicker.vue deleted file mode 100644 index 7952cb97..00000000 --- a/src/pages/global-ai/components/GlobalEntityPicker.vue +++ /dev/null @@ -1,324 +0,0 @@ - - - - - - - @ - {{ t('ai.global.entityPicker.add') }} - - - - - - - - {{ t(`ai.global.entityPicker.${type}`) }} - - - - - - - - - - - - {{ t('ai.global.entityPicker.loadFailed') }} - - {{ t('common.retry') }} - - - - {{ t('ai.global.entityPicker.contactsPreparing') }} - - - {{ t('ai.global.entityPicker.empty') }} - - - - {{ contact.displayName.slice(0, 1) }} - - - {{ contact.displayName }} - {{ contact.platform }} - - - - - - - - {{ t('ai.global.entityPicker.empty') }} - - - - - - - {{ session.name }} - {{ session.platform }} - - - - - - - - - - - - {{ entity.displayName }} - - - - - - diff --git a/src/pages/global-ai/index.vue b/src/pages/global-ai/index.vue index fede9937..b7ad5f01 100644 --- a/src/pages/global-ai/index.vue +++ b/src/pages/global-ai/index.vue @@ -5,10 +5,11 @@ import { useRoute, useRouter } from 'vue-router' import type { AIEntityRef } from '@openchatlab/shared-types' import { getDefaultGeneralAssistantId } from '@openchatlab/shared-types' import ConversationList from '@/components/AIChat/chat/ConversationList.vue' +import ChatHeaderActions from '@/components/AIChat/chat/ChatHeaderActions.vue' import ChatMessage from '@/components/AIChat/chat/ChatMessage.vue' import AIThinkingIndicator from '@/components/AIChat/chat/AIThinkingIndicator.vue' import ChatStatusBar from '@/components/AIChat/chat/ChatStatusBar.vue' -import AIChatComposer from '@/components/AIChat/input/AIChatComposer.vue' +import AIChatInput from '@/components/AIChat/input/AIChatInput.vue' import { useChatScroll } from '@/components/AIChat/composables/useChatScroll' import { useProgressiveChatHistory } from '@/components/AIChat/composables/useProgressiveChatHistory' import { groupMessagesToQAPairs } from '@/components/AIChat/utils/chatMessages' @@ -18,7 +19,6 @@ import type { AIChat } from '@/services/ai/types' import { useAIChatStore } from '@/stores/aiChat' import { useLayoutStore } from '@/stores/layout' import { useToast } from '@/composables/useToast' -import GlobalEntityPicker from './components/GlobalEntityPicker.vue' const { t, locale } = useI18n() const route = useRoute() @@ -34,15 +34,13 @@ const currentAIChatId = toRef(state, 'currentAIChatId') const conversations = ref([]) const conversationsLoading = ref(false) const initializing = ref(true) -const input = ref('') -const selectedEntities = ref([]) -const isComposing = ref(false) let isUnmounted = false let conversationSelectionRequestId = 0 function isGlobalPageActive(): boolean { return !isUnmounted && route.name === 'global-ai' } +const estimatedContextTokens = ref(0) const pairs = computed(() => groupMessagesToQAPairs(messages.value)) const { messagesContainer, showScrollToBottom, handleScrollToBottom, scrollToBottom } = useChatScroll( @@ -90,7 +88,6 @@ async function selectConversation(aiChatId: string): Promise { return } initializing.value = false - selectedEntities.value = [] await syncAIChatIdToRoute(aiChatId) if (currentRequest !== conversationSelectionRequestId || !isGlobalPageActive()) return await nextTick() @@ -101,8 +98,6 @@ function startNewConversation(): void { conversationSelectionRequestId++ if (!aiChatStore.startNewAIChat(chatKey)) return initializing.value = false - selectedEntities.value = [] - input.value = '' void syncAIChatIdToRoute(null) } @@ -126,34 +121,22 @@ async function deleteConversation(aiChatId: string): Promise { } } -async function submit(): Promise { - const content = input.value.trim() - if (!content || isAIThinking.value) return - - const refs = selectedEntities.value.map((entity) => ({ ...entity })) - input.value = '' - selectedEntities.value = [] - const result = await aiChatStore.sendMessage(chatKey, content, { entityRefs: refs }) - if (result.success || result.reason === 'aborted') return +async function handleSend(payload: { content: string; entityRefs: AIEntityRef[] }): Promise { + const result = await aiChatStore.sendMessage(chatKey, payload.content, { entityRefs: payload.entityRefs }) + if (result.success) { + scrollToBottom(true) + await loadConversations() + return + } + if (result.reason === 'aborted') return if (result.reason === 'no_config') { - input.value = content - selectedEntities.value = refs layoutStore.openSettings('ai', 'defaultModel') return } if (result.reason === 'busy') { toast.warn(t('ai.global.toast.busy')) - return } - input.value = content - selectedEntities.value = refs -} - -function handleKeydown(event: KeyboardEvent): void { - if (event.key !== 'Enter' || event.shiftKey || isComposing.value) return - event.preventDefault() - void submit() } watch(locale, (nextLocale) => { @@ -166,6 +149,23 @@ watch(currentAIChatId, (aiChatId, previousId) => { if (aiChatId) void loadConversations() }) +watch( + currentAIChatId, + async (aiChatId) => { + if (!aiChatId) { + estimatedContextTokens.value = 0 + return + } + try { + const result = await aiService.estimateContextTokens(aiChatId) + estimatedContextTokens.value = result.success ? result.tokens : 0 + } catch { + estimatedContextTokens.value = 0 + } + }, + { immediate: true } +) + watch( () => route.query.aiChatId, (value) => { @@ -222,7 +222,15 @@ onUnmounted(() => { icon="i-heroicons-sparkles" icon-class="bg-primary-600 text-white dark:bg-primary-500 dark:text-white" size="compact" - /> + > + + + + { - - - -
- {{ member.subtitle }} -
- {{ t('ai.chat.input.mentionEmpty') }} -
- {{ t('ai.chat.input.mentionEmptyHint') }} -
{{ t('ai.global.entityPicker.loadFailed') }}
{{ contact.displayName }}
{{ contact.platform }}
{{ session.name }}
{{ session.platform }}