feat(ai): unify conversation interactions

This commit is contained in:
digua committed 2026-08-22 20:00:03 +08:00
1 parent 7d32e1d7ed
commit a201ef8c11
13 files changed
+892 -932

No files matched your search

+384 -40
View File
@@ -1,5 +1,33 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import UEditor from '@nuxt/ui/components/Editor.vue'
import AIChatMentionMenu from './AIChatMentionMenu.vue'
interface EditorNode {
type?: string
text?: string
attrs?: Record<string, unknown>
content?: EditorNode[]
}
interface MentionMenuItem {
id: string
label: string
avatar?: string | null
searchText?: string
}
type MentionTab = 'contact' | 'group'
interface EditorInstance {
editor?: {
commands: {
focus: (position?: 'start' | 'end') => boolean
insertContent: (content: string) => boolean
}
setEditable: (editable: boolean, emitUpdate?: boolean) => void
}
}
const props = withDefaults(
defineProps<{
@@ -9,70 +37,226 @@ const props = withDefaults(
placeholder: string
sendButtonTitle: string
activeSkillName?: string | null
mentionItems?: MentionMenuItem[]
mentionSearchTerm?: string
asyncMentionSearch?: boolean
showMentionTabs?: boolean
mentionTab?: MentionTab
contactTabLabel?: string
groupTabLabel?: string
embedded?: boolean
}>(),
{
disabled: false,
status: 'ready',
activeSkillName: null,
mentionItems: () => [],
mentionSearchTerm: '',
asyncMentionSearch: false,
showMentionTabs: false,
mentionTab: 'contact',
contactTabLabel: '',
groupTabLabel: '',
embedded: false,
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:mentionSearchTerm': [value: string]
'update:mentionTab': [value: MentionTab]
mentionsChange: [ids: string[]]
submit: []
stop: []
keydown: [event: KeyboardEvent]
cursorChange: []
compositionStart: []
compositionEnd: []
}>()
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const editorRef = ref<EditorInstance | null>(null)
const editorDocument = ref<EditorNode>(createTextDocument(props.modelValue))
let mentionNodeCount = 0
let mentionMenuHost: HTMLElement | null = null
let hasMentionMenuKeyboardSelection = false
const canSubmit = computed(() => props.modelValue.trim().length > 0 && !props.disabled)
function syncHeight() {
const textarea = textareaRef.value
if (!textarea) return
textarea.style.height = 'auto'
const maxHeight = 192
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
const editorProps = {
handleKeyDown: (_view: unknown, event: KeyboardEvent) => {
emit('keydown', event)
return event.defaultPrevented
},
handleDOMEvents: {
compositionstart: () => {
emit('compositionStart')
return false
},
compositionend: () => {
emit('compositionEnd')
return false
},
},
}
function focus() {
textareaRef.value?.focus()
const starterKit = {
blockquote: false as const,
bold: false as const,
bulletList: false as const,
code: false as const,
codeBlock: false as const,
heading: false as const,
italic: false as const,
link: false as const,
listItem: false as const,
orderedList: false as const,
strike: false as const,
underline: false as const,
}
function getSelection() {
const fallback = props.modelValue.length
const mentionOptions = {
HTMLAttributes: {
class: 'ai-chat-mention',
},
deleteTriggerWithBackspace: true,
}
function createTextDocument(value: string): EditorNode {
const lines = value.split('\n')
return {
start: textareaRef.value?.selectionStart ?? fallback,
end: textareaRef.value?.selectionEnd ?? fallback,
type: 'doc',
content: lines.map((line) => ({
type: 'paragraph',
content: line ? [{ type: 'text', text: line }] : undefined,
})),
}
}
function setSelectionRange(start: number, end: number) {
textareaRef.value?.setSelectionRange(start, end)
function getMentionText(node: EditorNode): string {
const id = typeof node.attrs?.id === 'string' ? node.attrs.id : ''
const label = typeof node.attrs?.label === 'string' ? node.attrs.label : id
return `@${label}`
}
function updateValue(event: Event) {
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
function nodeToText(node: EditorNode): string {
if (node.type === 'text') return node.text ?? ''
if (node.type === 'mention') return getMentionText(node)
if (node.type === 'hardBreak') return '\n'
const content = node.content ?? []
const separator = node.type === 'doc' ? '\n' : ''
return content.map(nodeToText).join(separator)
}
function collectMentionIds(node: EditorNode, result: string[] = []): string[] {
if (node.type === 'mention' && typeof node.attrs?.id === 'string') {
result.push(node.attrs.id)
}
node.content?.forEach((child) => collectMentionIds(child, result))
return result
}
function handleDocumentUpdate(document: EditorNode) {
editorDocument.value = document
const mentionIds = collectMentionIds(document)
const insertedMention = mentionIds.length > mentionNodeCount
mentionNodeCount = mentionIds.length
emit('update:modelValue', nodeToText(document))
emit('mentionsChange', [...new Set(mentionIds)])
if (insertedMention) {
nextTick(() => editorRef.value?.editor?.commands.insertContent(' '))
}
}
function focus(position: 'start' | 'end' = 'end') {
editorRef.value?.editor?.commands.focus(position)
}
function setText(value: string) {
mentionNodeCount = 0
editorDocument.value = createTextDocument(value)
}
function clear() {
setText('')
}
function syncHeight() {
// The contenteditable surface grows through CSS and scrolls after reaching its max height.
}
function preventHoverSelection(event: Event) {
if (event.target instanceof Element && event.target.closest('[role="option"]')) {
event.stopPropagation()
}
}
function activateMentionMenuKeyboardSelection() {
hasMentionMenuKeyboardSelection = true
mentionMenuHost?.classList.add('has-keyboard-selection')
}
function handleMentionMenuKeydown(event: KeyboardEvent) {
if (!mentionMenuHost?.firstElementChild) return
if (!hasMentionMenuKeyboardSelection && event.key === 'ArrowDown') {
activateMentionMenuKeyboardSelection()
event.preventDefault()
event.stopImmediatePropagation()
return
}
if (!hasMentionMenuKeyboardSelection && event.key === 'ArrowUp') {
activateMentionMenuKeyboardSelection()
return
}
if (!hasMentionMenuKeyboardSelection && (event.key === 'Enter' || event.key === 'Tab')) {
event.preventDefault()
event.stopImmediatePropagation()
}
}
function getMentionMenuContainer() {
hasMentionMenuKeyboardSelection = false
mentionMenuHost?.classList.remove('has-keyboard-selection')
if (!mentionMenuHost) {
mentionMenuHost = document.createElement('div')
mentionMenuHost.className = 'ai-chat-mention-menu-host'
mentionMenuHost.addEventListener('mouseenter', preventHoverSelection, true)
document.body.appendChild(mentionMenuHost)
}
return mentionMenuHost
}
onMounted(() => {
document.addEventListener('keydown', handleMentionMenuKeydown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleMentionMenuKeydown, true)
mentionMenuHost?.remove()
mentionMenuHost = null
})
watch(
() => props.modelValue,
async () => {
await nextTick()
syncHeight()
(value) => {
if (nodeToText(editorDocument.value) !== value) {
setText(value)
}
}
)
onMounted(() => void nextTick(syncHeight))
watch(
() => props.disabled,
(disabled) => {
// Nuxt UI Editor 4.9 does not forward later editable prop changes to the existing Tiptap instance.
editorRef.value?.editor?.setEditable(!disabled, false)
}
)
defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
defineExpose({ focus, setText, clear, syncHeight })
</script>
<template>
@@ -97,21 +281,37 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
<span class="truncate">/{{ activeSkillName }}</span>
</div>
<textarea
ref="textareaRef"
:value="modelValue"
rows="2"
class="min-h-[48px] min-w-0 flex-1 resize-none border-0 bg-transparent px-0 py-0 text-sm leading-6 text-gray-900 outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:text-gray-400 dark:text-gray-100 dark:placeholder:text-gray-500 dark:disabled:text-gray-500"
:disabled="disabled"
<UEditor
ref="editorRef"
:model-value="editorDocument"
content-type="json"
class="ai-chat-editor min-w-0 flex-1"
:editable="!disabled"
:placeholder="placeholder"
@input="updateValue"
@keydown="emit('keydown', $event)"
@click="emit('cursorChange')"
@keyup="emit('cursorChange')"
@select="emit('cursorChange')"
@compositionstart="emit('compositionStart')"
@compositionend="emit('compositionEnd')"
/>
:starter-kit="starterKit"
:mention="mentionOptions"
:image="false"
:editor-props="editorProps"
@update:model-value="handleDocumentUpdate"
>
<template #default="{ editor }">
<AIChatMentionMenu
:search-term="props.mentionSearchTerm"
:editor="editor"
:items="mentionItems"
:filter-fields="['label', 'searchText']"
:ignore-filter="asyncMentionSearch"
:limit="100"
:append-to="getMentionMenuContainer"
:show-tabs="showMentionTabs"
:active-tab="mentionTab"
:contact-tab-label="contactTabLabel"
:group-tab-label="groupTabLabel"
@update:search-term="emit('update:mentionSearchTerm', $event)"
@update:active-tab="emit('update:mentionTab', $event)"
/>
</template>
</UEditor>
</div>
<button
@@ -141,3 +341,147 @@ defineExpose({ focus, getSelection, setSelectionRange, syncHeight })
</div>
</div>
</template>
<style scoped>
.ai-chat-editor :deep(.tiptap) {
min-height: 48px;
max-height: 192px;
overflow-y: auto;
border: 0;
padding: 0;
color: var(--color-gray-900);
font-size: 0.875rem;
line-height: 1.5rem;
outline: none;
}
.ai-chat-editor :deep(.tiptap p) {
margin: 0;
}
.ai-chat-editor :deep(.tiptap .ai-chat-mention) {
display: inline-flex;
align-items: center;
border-radius: 0.375rem;
background: color-mix(in srgb, var(--color-primary-500) 12%, transparent);
padding: 0 0.25rem;
color: var(--color-primary-700);
line-height: 1.25rem;
white-space: nowrap;
}
.ai-chat-editor :deep(.tiptap p.is-editor-empty:first-child::before) {
color: var(--color-gray-400);
}
.dark .ai-chat-editor :deep(.tiptap) {
color: var(--color-gray-100);
}
.dark .ai-chat-editor :deep(.tiptap .ai-chat-mention) {
background: color-mix(in srgb, var(--color-primary-500) 16%, transparent);
color: var(--color-primary-400);
}
.dark .ai-chat-editor :deep(.tiptap p.is-editor-empty:first-child::before) {
color: var(--color-gray-500);
}
</style>
<style>
.ai-chat-mention-menu-host > div {
width: 16rem !important;
max-width: calc(100vw - 2rem);
opacity: 0;
visibility: hidden;
}
.ai-chat-mention-menu-host > div[style*='transform: translate'] {
visibility: visible;
animation: ai-chat-mention-menu-reveal 0s 40ms forwards;
}
@keyframes ai-chat-mention-menu-reveal {
to {
opacity: 1;
}
}
.ai-chat-mention-menu-host [role='listbox'] {
box-sizing: border-box;
width: 16rem;
min-width: 0;
max-width: calc(100vw - 2rem);
max-height: 16rem;
padding: 0.25rem;
overflow: hidden;
background: var(--ui-bg);
border: 1px solid var(--ui-border);
border-radius: 0.75rem;
box-shadow: 0 12px 32px rgb(0 0 0 / 16%);
}
.ai-chat-mention-menu-host [role='presentation'] {
overflow-y: auto;
scrollbar-width: thin;
}
.ai-chat-mention-menu-host [role='group'] {
padding: 0;
}
.ai-chat-mention-menu-host [role='option'] {
min-height: 2.25rem;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
}
.ai-chat-mention-menu-host [role='option']::before {
inset: 0;
border-radius: 0.5rem;
}
.ai-chat-mention-menu-host.has-keyboard-selection [role='option'][data-highlighted]::before {
background: color-mix(in srgb, var(--ui-primary) 10%, transparent);
}
.ai-chat-mention-menu-host [role='option']:hover::before {
background: color-mix(in srgb, var(--ui-primary) 6%, transparent);
}
.ai-chat-mention-tabs {
padding: 0.25rem 0.25rem 0.375rem;
background: var(--ui-bg);
}
.ai-chat-mention-tabs__inner {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.125rem;
padding: 0.125rem;
background: var(--ui-bg-muted);
border-radius: 0.5rem;
}
.ai-chat-mention-tabs__button {
height: 1.625rem;
padding: 0 0.5rem;
color: var(--ui-text-muted);
font-size: 0.75rem;
line-height: 1rem;
cursor: pointer;
border-radius: 0.375rem;
}
.ai-chat-mention-tabs__button:hover {
color: var(--ui-text-highlighted);
}
.ai-chat-mention-tabs__button.is-active {
color: var(--ui-text-highlighted);
background: var(--ui-bg);
box-shadow: 0 1px 2px rgb(0 0 0 / 8%);
}
</style>
+282 -386
View File
@@ -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<HTMLElement | null>(null)
const composerRef = ref<InstanceType<typeof AIChatComposer> | null>(null)
const inputValue = ref('')
const mentionMembers = ref<MemberWithStats[]>([])
const selectedMentions = ref<MentionedMemberContext[]>([])
const isLoadingMentionMembers = ref(false)
const globalMentionCandidates = ref<MentionCandidate[]>([])
const mentionRegistry = new Map<string, MentionCandidate>()
const selectedMentionIds = ref<string[]>([])
const mentionSearchTerm = ref('')
const mentionTab = ref<MentionTab>('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<MentionRange | null>(null)
const isComposing = ref(false)
const dismissedSlashValue = ref<string | null>(null)
const dismissedMentionToken = ref<string | null>(null)
const suspendInputParsing = ref(false)
let globalMentionSearchTimer: ReturnType<typeof setTimeout> | null = null
let globalMentionPollTimer: ReturnType<typeof setTimeout> | 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<MentionCandidate[]>(() => {
const sessionMentionCandidates = computed<MentionCandidate[]>(() => {
const nameCount = new Map<string, number>()
mentionMembers.value.forEach((member) => {
@@ -91,20 +110,19 @@ const mentionCandidates = computed<MentionCandidate[]>(() => {
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<MentionCandidate[]>(() => {
})
})
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<MentionMenuItem[]>(() => {
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<string, number>()
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<string, ContactListItem>()
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<MentionCandidate, 'memberId' | 'platformId' | 'displayName' | 'insertName' | 'aliases'>
) {
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({
<div class="shrink-0" :class="[props.embedded ? '' : 'pt-2 pb-2']">
<div ref="rootRef" class="w-full max-w-4xl mx-auto" :class="{ relative: !props.embedded }">
<SlashCommandMenu
v-if="props.skillsEnabled"
:visible="showSlashMenu"
:skills="filteredSkills"
:highlight-index="slashHighlightIndex"
@@ -664,16 +561,6 @@ defineExpose({
@highlight="slashHighlightIndex = $event"
/>
<MemberMentionMenu
:visible="showMentionMenu"
:members="filteredMentionCandidates"
:highlight-index="mentionHighlightIndex"
:loading="isLoadingMentionMembers"
@select="handleSelectMention"
@close="dismissMentionMenu"
@highlight="mentionHighlightIndex = $event"
/>
<AIChatComposer
ref="composerRef"
v-model="inputValue"
@@ -681,12 +568,21 @@ defineExpose({
:status="props.status"
:placeholder="inputPlaceholder"
:send-button-title="sendButtonTitle"
:active-skill-name="activeSkill?.name"
:active-skill-name="props.skillsEnabled ? activeSkill?.name : undefined"
:mention-items="mentionMenuItems"
:mention-search-term="mentionSearchTerm"
:async-mention-search="props.mentionScope === 'global'"
:show-mention-tabs="props.mentionScope === 'global'"
:mention-tab="mentionTab"
:contact-tab-label="t('ai.global.entityPicker.contacts')"
:group-tab-label="t('ai.global.entityPicker.groups')"
:embedded="props.embedded"
@update:mention-search-term="handleMentionSearchTerm"
@update:mention-tab="mentionTab = $event"
@mentions-change="handleMentionsChange"
@submit="handleSubmit"
@stop="emit('stop')"
@keydown="handleKeydown"
@cursor-change="handleCursorChange"
@composition-start="isComposing = true"
@composition-end="isComposing = false"
/>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, h, nextTick, onBeforeUnmount, onMounted, toRef } from 'vue'
import type { Editor } from '@tiptap/vue-3'
import { useEditorMenu } from '@nuxt/ui/composables'
import LazyAvatar from '@/components/common/avatar/LazyAvatar.vue'
export interface AIChatMentionMenuItem {
id: string
label: string
avatar?: string | null
searchText?: string
}
type MentionTab = 'contact' | 'group'
const props = withDefaults(
defineProps<{
editor?: Editor
items?: AIChatMentionMenuItem[]
searchTerm?: string
filterFields?: string[]
ignoreFilter?: boolean
limit?: number
appendTo?: HTMLElement | (() => HTMLElement)
showTabs?: boolean
activeTab?: MentionTab
contactTabLabel?: string
groupTabLabel?: string
}>(),
{
items: () => [],
searchTerm: '',
filterFields: () => ['label'],
ignoreFilter: false,
limit: 42,
showTabs: false,
activeTab: 'contact',
contactTabLabel: '',
groupTabLabel: '',
}
)
const emit = defineEmits<{
'update:searchTerm': [value: string]
'update:activeTab': [value: MentionTab]
}>()
const searchTerm = computed({
get: () => props.searchTerm,
set: (value) => emit('update:searchTerm', value),
})
const tabItem: AIChatMentionMenuItem & { type: 'label' } = {
id: '__mention-tabs__',
label: '',
type: 'label',
}
const menuItems = computed(() => (props.showTabs ? [tabItem, ...props.items] : props.items))
const menuUi = computed(() => ({
content: () => 'flex flex-col',
viewport: () => 'relative flex-1 overflow-y-auto',
group: () => 'isolate',
label: () => 'ai-chat-mention-tabs sticky top-0 z-10',
separator: () => 'h-px bg-border',
item: () =>
'group relative flex w-full items-center select-none outline-none before:absolute before:z-[-1] data-disabled:cursor-not-allowed data-disabled:opacity-75',
}))
let menu: ReturnType<typeof useEditorMenu<AIChatMentionMenuItem>> | null = null
function renderTabs() {
return h('div', { class: 'ai-chat-mention-tabs__inner' }, [
h(
'button',
{
type: 'button',
class: ['ai-chat-mention-tabs__button', props.activeTab === 'contact' && 'is-active'],
'aria-pressed': props.activeTab === 'contact',
onMousedown: (event: MouseEvent) => event.preventDefault(),
onClick: () => emit('update:activeTab', 'contact'),
},
props.contactTabLabel
),
h(
'button',
{
type: 'button',
class: ['ai-chat-mention-tabs__button', props.activeTab === 'group' && 'is-active'],
'aria-pressed': props.activeTab === 'group',
onMousedown: (event: MouseEvent) => event.preventDefault(),
onClick: () => emit('update:activeTab', 'group'),
},
props.groupTabLabel
),
])
}
onMounted(async () => {
await nextTick()
if (!props.editor || props.editor.isDestroyed) return
menu = useEditorMenu<AIChatMentionMenuItem>({
editor: props.editor,
char: '@',
pluginKey: 'mentionMenu',
items: toRef(() => menuItems.value),
filterFields: props.filterFields,
ignoreFilter: props.ignoreFilter,
limit: props.limit,
options: { placement: 'top-start' },
appendTo: props.appendTo,
searchTerm,
ui: menuUi,
onSelect: (editor, range, item) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: 'mention',
attrs: {
id: item.id,
label: item.label,
mentionSuggestionChar: '@',
},
})
.run()
},
renderItem: (item) => {
if ('type' in item && item.type === 'label') return renderTabs()
const leading = h(LazyAvatar, {
src: item.avatar,
alt: item.label,
text: item.label.slice(0, 1),
rootClass: 'h-5 w-5 shrink-0',
imageClass: 'h-5 w-5 rounded-md object-cover',
fallbackClass:
'flex h-5 w-5 items-center justify-center rounded-md bg-gray-100 text-[9px] text-gray-500 dark:bg-gray-800 dark:text-gray-400',
rootMargin: '0px',
})
return [leading, h('span', { class: 'min-w-0 flex-1 truncate text-sm leading-[18px]' }, item.label)]
},
})
props.editor.registerPlugin(menu.plugin)
})
onBeforeUnmount(() => {
menu?.destroy()
menu = null
if (props.editor && !props.editor.isDestroyed) {
props.editor.unregisterPlugin('mentionMenu')
}
})
</script>
<template>
<div />
</template>
@@ -1,121 +0,0 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
interface MentionCandidate {
memberId: number
platformId: string
displayName: string
insertName: string
aliases: string[]
subtitle: string
}
const { t } = useI18n()
const props = defineProps<{
visible: boolean
members: MentionCandidate[]
highlightIndex: number
loading?: boolean
}>()
const emit = defineEmits<{
select: [member: MentionCandidate]
close: []
highlight: [index: number]
}>()
const listRef = ref<HTMLElement | null>(null)
watch(
() => [props.visible, props.highlightIndex, props.members.length] as const,
async ([visible, highlightIndex]) => {
if (!visible || !listRef.value) return
await nextTick()
const items = listRef.value.querySelectorAll<HTMLElement>('[data-mention-item]')
const target = items[highlightIndex]
// 键盘切换高亮时,保持当前项始终处于可见区域内。
target?.scrollIntoView({ block: 'nearest' })
}
)
</script>
<template>
<Transition name="mention-menu">
<div v-if="visible" class="absolute bottom-full left-0 z-20 mb-1.5 w-[240px] max-w-[calc(100vw-2rem)]">
<div
class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-overlay dark:border-gray-700 dark:bg-page-dark"
>
<div class="flex items-center justify-between border-b border-gray-100 px-2.5 py-1.5 dark:border-gray-800">
<div class="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span>{{ t('ai.chat.input.mentionHint') }}</span>
</div>
<button
type="button"
class="rounded-md p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
:title="t('common.close')"
@click="emit('close')"
>
<UIcon name="i-heroicons-x-mark" class="h-3.5 w-3.5" />
</button>
</div>
<div v-if="props.loading" class="px-3 py-4 text-center text-xs text-gray-500 dark:text-gray-400">
{{ t('common.loading') }}
</div>
<div v-else-if="props.members.length > 0" ref="listRef" class="max-h-72 overflow-y-auto p-1.5">
<button
v-for="(member, index) in props.members"
:key="`${member.memberId}-${member.insertName}`"
type="button"
data-mention-item
class="flex w-full items-start justify-between gap-1.5 rounded-lg px-2.5 py-1.5 text-left transition-colors"
:class="
index === props.highlightIndex
? 'bg-primary-50 text-gray-900 dark:bg-primary-950/30 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800/80'
"
@mouseenter="emit('highlight', index)"
@click="emit('select', member)"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-xs font-medium">@{{ member.insertName }}</span>
</div>
<p class="mt-0.5 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{{ member.subtitle }}
</p>
</div>
</button>
</div>
<div v-else class="px-3 py-4 text-center">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('ai.chat.input.mentionEmpty') }}
</p>
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ t('ai.chat.input.mentionEmptyHint') }}
</p>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.mention-menu-enter-active,
.mention-menu-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.mention-menu-enter-from,
.mention-menu-leave-to {
transform: translateY(8px);
opacity: 0;
}
</style>
@@ -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')
})
}
+8 -8
View File
@@ -519,6 +519,14 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
@click="handleImport"
/>
<SidebarButton
v-if="props.backendFeatures"
icon="i-heroicons-chat-bubble-left-ellipsis"
:title="t('analysis.tabs.aiChat')"
:active="isGlobalAIPage"
@click="openGlobalAI"
/>
<template v-if="showInsightNavigation">
<SidebarButton
v-for="item in resolvedPrimaryNavigation"
@@ -542,14 +550,6 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
:active="isPeoplePage"
@click="openContacts"
/>
<SidebarButton
v-if="props.backendFeatures"
icon="i-heroicons-chat-bubble-left-ellipsis"
:title="t('analysis.tabs.aiChat')"
:active="isGlobalAIPage"
@click="openGlobalAI"
/>
</div>
</div>
+4 -2
View File
@@ -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"
/>
<div v-else :class="fallbackClass">
{{ text }}
{{ fallbackText }}
</div>
</div>
</template>
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -270,14 +270,14 @@
}
},
"global": {
"title": "AI 分析",
"title": "AI チャット",
"subtitle": "連絡先やグループを横断してローカルのチャットを検索",
"empty": {
"title": "関係を選んで分析を始める",
"description": "連絡先やグループを選んで質問できます。誰と話したか忘れた話題は全体から検索できます。現在の質問に必要なローカルデータだけを読み取ります。"
},
"input": {
"placeholder": "質問を入力。先に連絡先やグループを選択できます..."
"placeholder": "質問を入力、または {'@'} で連絡先やグループを選択..."
},
"entityPicker": {
"title": "連絡先またはグループを選択",
+2 -2
View File
@@ -270,14 +270,14 @@
}
},
"global": {
"title": "AI 分析",
"title": "AI 对话",
"subtitle": "跨联系人和群聊查询本地聊天记录",
"empty": {
"title": "从一段关系开始分析",
"description": "选择联系人或群聊后提问,也可以直接搜索你忘记和谁聊过的话题。只会按当前问题读取必要的本地数据。"
},
"input": {
"placeholder": "输入问题,可先选择联系人或群聊..."
"placeholder": "输入问题,输入 {'@'} 选择联系人或群聊..."
},
"entityPicker": {
"title": "选择联系人或群聊",
+2 -2
View File
@@ -270,14 +270,14 @@
}
},
"global": {
"title": "AI 分析",
"title": "AI 對話",
"subtitle": "跨聯絡人與群聊查詢本機聊天記錄",
"empty": {
"title": "從一段關係開始分析",
"description": "選擇聯絡人或群聊後提問,也可以直接搜尋你忘記和誰聊過的話題。只會按目前問題讀取必要的本機資料。"
},
"input": {
"placeholder": "輸入問題,可先選擇聯絡人或群聊..."
"placeholder": "輸入問題,輸入 {'@'} 選擇聯絡人或群聊..."
},
"entityPicker": {
"title": "選擇聯絡人或群聊",
@@ -1,324 +0,0 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AIEntityRef, ContactListItem, ContactsResponse } from '@openchatlab/shared-types'
import { useDataService } from '@/services'
import { useSessionStore } from '@/stores/session'
const props = defineProps<{
modelValue: AIEntityRef[]
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: AIEntityRef[]]
}>()
const { t } = useI18n()
const dataService = useDataService()
const sessionStore = useSessionStore()
const open = ref(false)
const activeType = ref<'contacts' | 'groups'>('contacts')
const query = ref('')
const contacts = ref<ContactListItem[]>([])
const contactResponses = ref<ContactsResponse[]>([])
const loadingContacts = ref(false)
const contactError = ref('')
let searchTimer: ReturnType<typeof setTimeout> | null = null
let pollTimer: ReturnType<typeof setTimeout> | null = null
let requestId = 0
const CONTACTS_POLL_INTERVAL_MS = 1500
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase())
const groups = computed(() => {
const list = sessionStore.sessions.filter((session) => session.type === 'group')
if (!normalizedQuery.value) return list
return list.filter((session) => session.name.toLocaleLowerCase().includes(normalizedQuery.value))
})
const contactsUnavailable = computed(
() =>
!loadingContacts.value &&
!contactError.value &&
contacts.value.length === 0 &&
contactResponses.value.some(
(response) => response.cache.status === 'missing' || response.task?.status === 'running'
)
)
function entityKey(entity: AIEntityRef): string {
return entity.type === 'contact' ? `contact:${entity.contactKey}` : `session:${entity.sessionId}`
}
function isSelected(entity: AIEntityRef): boolean {
const key = entityKey(entity)
return props.modelValue.some((item) => entityKey(item) === key)
}
function toggle(entity: AIEntityRef): void {
if (props.disabled) return
const key = entityKey(entity)
const exists = props.modelValue.some((item) => entityKey(item) === key)
emit(
'update:modelValue',
exists ? props.modelValue.filter((item) => entityKey(item) !== key) : [...props.modelValue, entity]
)
}
function remove(entity: AIEntityRef): void {
const key = entityKey(entity)
emit(
'update:modelValue',
props.modelValue.filter((item) => entityKey(item) !== key)
)
}
function stopContactsPolling(): void {
if (!pollTimer) return
clearTimeout(pollTimer)
pollTimer = null
}
function scheduleContactsPolling(responses: ContactsResponse[]): void {
stopContactsPolling()
if (!open.value || activeType.value !== 'contacts') return
if (!responses.some((response) => response.task?.status === 'running')) return
pollTimer = setTimeout(() => {
pollTimer = null
void loadContacts({ silent: true })
}, CONTACTS_POLL_INTERVAL_MS)
}
function stopPendingContactWork(): void {
requestId++
loadingContacts.value = false
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
stopContactsPolling()
}
async function loadContacts(loadOptions?: { silent?: boolean }): Promise<void> {
const currentRequest = ++requestId
if (!loadOptions?.silent) loadingContacts.value = true
contactError.value = ''
try {
const requestOptions = {
acceptStale: true,
timeRangePreset: 'all',
page: 1,
pageSize: 100,
query: query.value.trim() || undefined,
} as const
const responses = await Promise.all([
dataService.getContacts({ ...requestOptions, pool: 'friend' }),
dataService.getContacts({ ...requestOptions, pool: 'non_friend' }),
])
if (currentRequest !== requestId) return
const byKey = new Map<string, ContactListItem>()
responses.flatMap((response) => response.contacts).forEach((contact) => byKey.set(contact.key, contact))
contacts.value = [...byKey.values()]
contactResponses.value = responses
scheduleContactsPolling(responses)
} catch (error) {
if (currentRequest !== requestId) return
stopContactsPolling()
contactError.value = error instanceof Error ? error.message : String(error)
contacts.value = []
contactResponses.value = []
} finally {
if (currentRequest === requestId && !loadOptions?.silent) loadingContacts.value = false
}
}
watch(open, (isOpen) => {
if (isOpen) {
void loadContacts()
} else {
stopPendingContactWork()
}
})
watch(activeType, (type) => {
if (!open.value) return
stopPendingContactWork()
if (type === 'contacts') void loadContacts()
})
watch(query, () => {
if (!open.value || activeType.value !== 'contacts') return
requestId++
if (searchTimer) clearTimeout(searchTimer)
stopContactsPolling()
searchTimer = setTimeout(() => {
searchTimer = null
void loadContacts()
}, 250)
})
onUnmounted(() => {
stopPendingContactWork()
})
</script>
<template>
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
<UPopover v-model:open="open" :ui="{ content: 'z-[80] p-0' }">
<button
type="button"
class="inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
:disabled="disabled"
:title="t('ai.global.entityPicker.title')"
>
<span class="text-base leading-none">@</span>
<span>{{ t('ai.global.entityPicker.add') }}</span>
</button>
<template #content>
<div class="flex h-[390px] w-[340px] flex-col overflow-hidden">
<div class="border-b border-gray-200 p-3 dark:border-gray-800">
<div class="mb-2 flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-800">
<button
v-for="type in ['contacts', 'groups'] as const"
:key="type"
type="button"
class="flex-1 rounded-md px-3 py-1.5 text-xs transition-colors"
:class="
activeType === type
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 dark:text-gray-400'
"
@click="activeType = type"
>
{{ t(`ai.global.entityPicker.${type}`) }}
</button>
</div>
<UInput
v-model="query"
icon="i-heroicons-magnifying-glass"
size="sm"
:placeholder="t('ai.global.entityPicker.search')"
/>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<template v-if="activeType === 'contacts'">
<div v-if="loadingContacts" class="flex h-full items-center justify-center">
<UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin text-gray-400" />
</div>
<div v-else-if="contactError" class="px-3 py-8 text-center text-xs text-red-500">
<p>{{ t('ai.global.entityPicker.loadFailed') }}</p>
<UButton size="xs" variant="link" class="mt-1" @click="loadContacts()">
{{ t('common.retry') }}
</UButton>
</div>
<div v-else-if="contactsUnavailable" class="px-4 py-8 text-center text-xs text-gray-400">
{{ t('ai.global.entityPicker.contactsPreparing') }}
</div>
<div v-else-if="contacts.length === 0" class="px-4 py-8 text-center text-xs text-gray-400">
{{ t('ai.global.entityPicker.empty') }}
</div>
<button
v-for="contact in contacts"
v-else
:key="contact.key"
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
@click="
toggle({
type: 'contact',
contactKey: contact.key,
displayName: contact.displayName,
})
"
>
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-50 text-xs text-primary-600 dark:bg-primary-500/10 dark:text-primary-300"
>
{{ contact.displayName.slice(0, 1) }}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-gray-700 dark:text-gray-200">{{ contact.displayName }}</p>
<p class="truncate text-[11px] text-gray-400">{{ contact.platform }}</p>
</div>
<UIcon
v-if="
isSelected({
type: 'contact',
contactKey: contact.key,
displayName: contact.displayName,
})
"
name="i-heroicons-check"
class="h-4 w-4 shrink-0 text-primary-500"
/>
</button>
</template>
<template v-else>
<div v-if="groups.length === 0" class="px-4 py-8 text-center text-xs text-gray-400">
{{ t('ai.global.entityPicker.empty') }}
</div>
<button
v-for="session in groups"
:key="session.id"
type="button"
class="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
@click="
toggle({
type: 'session',
sessionId: session.id,
displayName: session.name,
sessionType: 'group',
})
"
>
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-300"
>
<UIcon name="i-heroicons-user-group" class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-gray-700 dark:text-gray-200">{{ session.name }}</p>
<p class="text-[11px] text-gray-400">{{ session.platform }}</p>
</div>
<UIcon
v-if="
isSelected({
type: 'session',
sessionId: session.id,
displayName: session.name,
sessionType: 'group',
})
"
name="i-heroicons-check"
class="h-4 w-4 shrink-0 text-primary-500"
/>
</button>
</template>
</div>
</div>
</template>
</UPopover>
<span
v-for="entity in modelValue"
:key="entityKey(entity)"
class="inline-flex h-7 max-w-[180px] items-center gap-1 rounded-full bg-primary-50 px-2 text-xs text-primary-700 dark:bg-primary-500/10 dark:text-primary-300"
>
<UIcon :name="entity.type === 'contact' ? 'i-heroicons-user' : 'i-heroicons-user-group'" class="h-3 w-3" />
<span class="truncate">{{ entity.displayName }}</span>
<button
type="button"
class="ml-0.5 rounded-full text-primary-400 hover:text-primary-700 dark:hover:text-primary-200"
:aria-label="t('common.delete')"
:disabled="disabled"
@click="remove(entity)"
>
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
</button>
</span>
</div>
</template>
+45 -41
View File
@@ -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<AIChat[]>([])
const conversationsLoading = ref(false)
const initializing = ref(true)
const input = ref('')
const selectedEntities = ref<AIEntityRef[]>([])
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<void> {
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<void> {
}
}
async function submit(): Promise<void> {
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<void> {
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"
/>
>
<template #actions>
<ChatHeaderActions
:current-ai-chat-id="currentAIChatId"
:current-messages="messages"
:fallback-title="t('ai.global.title')"
/>
</template>
</PageHeader>
<div class="main-content flex min-h-0 flex-1 overflow-hidden">
<ConversationList
@@ -327,28 +335,24 @@ onUnmounted(() => {
<div class="shrink-0 px-4 pb-4">
<div
class="mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5"
class="relative mx-auto max-w-3xl overflow-visible rounded-2xl bg-white shadow-[0_2px_14px_rgba(0,0,0,0.04)] ring-1 ring-gray-200/60 transition-all focus-within:ring-primary-500/40 focus-within:shadow-[0_4px_20px_rgba(0,0,0,0.08)] dark:bg-page-dark dark:ring-white/5"
>
<div class="border-b border-gray-100 px-3 py-2 dark:border-gray-800/80">
<GlobalEntityPicker v-model="selectedEntities" :disabled="isAIThinking" />
</div>
<AIChatComposer
v-model="input"
<AIChatInput
:key="currentAIChatId || 'global-draft'"
embedded
:disabled="isAIThinking"
:status="isAIThinking ? 'streaming' : 'ready'"
mention-scope="global"
:skills-enabled="false"
:placeholder="t('ai.global.input.placeholder')"
:send-button-title="t('ai.chat.input.send')"
@submit="submit"
@send="handleSend"
@stop="aiChatStore.stopGeneration(chatKey)"
@keydown="handleKeydown"
@composition-start="isComposing = true"
@composition-end="isComposing = false"
/>
<ChatStatusBar
class="px-2 pb-1.5 pt-0.5"
class="pb-1.5 pl-2 pr-[52px] pt-0.5"
:session-token-usage="state.sessionTokenUsage"
:agent-status="state.agentStatus"
:estimated-context-tokens="estimatedContextTokens"
/>
</div>
</div>