mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(ai): unify conversation interactions
This commit is contained in:
1 parent
7d32e1d7ed
commit
a201ef8c11
13 files changed
+892
-932
No files matched your search
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -270,14 +270,14 @@
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"title": "AI 分析",
|
||||
"title": "AI チャット",
|
||||
"subtitle": "連絡先やグループを横断してローカルのチャットを検索",
|
||||
"empty": {
|
||||
"title": "関係を選んで分析を始める",
|
||||
"description": "連絡先やグループを選んで質問できます。誰と話したか忘れた話題は全体から検索できます。現在の質問に必要なローカルデータだけを読み取ります。"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "質問を入力。先に連絡先やグループを選択できます..."
|
||||
"placeholder": "質問を入力、または {'@'} で連絡先やグループを選択..."
|
||||
},
|
||||
"entityPicker": {
|
||||
"title": "連絡先またはグループを選択",
|
||||
|
||||
@@ -270,14 +270,14 @@
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"title": "AI 分析",
|
||||
"title": "AI 对话",
|
||||
"subtitle": "跨联系人和群聊查询本地聊天记录",
|
||||
"empty": {
|
||||
"title": "从一段关系开始分析",
|
||||
"description": "选择联系人或群聊后提问,也可以直接搜索你忘记和谁聊过的话题。只会按当前问题读取必要的本地数据。"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "输入问题,可先选择联系人或群聊..."
|
||||
"placeholder": "输入问题,输入 {'@'} 选择联系人或群聊..."
|
||||
},
|
||||
"entityPicker": {
|
||||
"title": "选择联系人或群聊",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user