2025-03-28 03:17:09 +08:00
|
|
|
|
<template>
|
2026-03-16 21:39:49 +08:00
|
|
|
|
<div class="input-box" :class="customClasses" @click="focusInput">
|
2025-11-12 14:04:34 +08:00
|
|
|
|
<div class="top-slot">
|
|
|
|
|
|
<slot name="top"></slot>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
<div class="expand-options" v-if="hasOptionsLeft">
|
2026-01-24 15:24:25 +08:00
|
|
|
|
<a-popover
|
|
|
|
|
|
v-model:open="optionsExpanded"
|
|
|
|
|
|
placement="bottomLeft"
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
:overlay-inner-style="{ padding: '4px' }"
|
|
|
|
|
|
>
|
2025-08-09 23:18:19 +08:00
|
|
|
|
<template #content>
|
2025-11-12 03:41:22 +08:00
|
|
|
|
<slot name="options-left">
|
|
|
|
|
|
<div class="no-options">没有配置 options</div>
|
|
|
|
|
|
</slot>
|
2025-08-09 23:18:19 +08:00
|
|
|
|
</template>
|
2026-01-15 06:01:34 +08:00
|
|
|
|
<a-button type="text" class="expand-btn">
|
2025-08-09 23:18:19 +08:00
|
|
|
|
<template #icon>
|
2026-04-19 23:44:46 +08:00
|
|
|
|
<Paperclip :size="16" :class="{ rotated: optionsExpanded }" />
|
2025-08-09 23:18:19 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-popover>
|
2025-11-12 03:41:22 +08:00
|
|
|
|
<slot name="actions-left"></slot>
|
2025-03-28 03:17:09 +08:00
|
|
|
|
</div>
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<div
|
2025-08-09 23:18:19 +08:00
|
|
|
|
ref="inputRef"
|
2026-05-30 17:54:36 +08:00
|
|
|
|
class="user-input mention-editor"
|
|
|
|
|
|
role="textbox"
|
|
|
|
|
|
aria-multiline="true"
|
|
|
|
|
|
:aria-label="placeholder"
|
|
|
|
|
|
:contenteditable="disabled ? 'false' : 'true'"
|
|
|
|
|
|
:data-placeholder="placeholder"
|
2025-08-09 23:18:19 +08:00
|
|
|
|
@keydown="handleKeyPress"
|
2026-02-04 17:05:26 +08:00
|
|
|
|
@keyup="handleKeyUp"
|
2025-08-09 23:18:19 +08:00
|
|
|
|
@input="handleInput"
|
|
|
|
|
|
@focus="focusInput"
|
2026-05-30 17:54:36 +08:00
|
|
|
|
@click="handleEditorClick"
|
|
|
|
|
|
@paste="handlePaste"
|
|
|
|
|
|
@compositionstart="handleCompositionStart"
|
|
|
|
|
|
@compositionend="handleCompositionEnd"
|
|
|
|
|
|
></div>
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
<!-- @ 提及选择弹窗 -->
|
2026-05-22 13:15:46 +08:00
|
|
|
|
<div v-if="mentionPopupVisible" ref="mentionDropdownRef" class="mention-dropdown-wrapper">
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<div class="mention-popup" @mousedown.prevent>
|
2026-02-04 17:05:26 +08:00
|
|
|
|
<!-- 文件列表 -->
|
2026-04-02 13:18:42 +08:00
|
|
|
|
<div v-if="mentionItems.files.length > 0 || showFileSearchPrompt" class="mention-group">
|
2026-02-04 17:05:26 +08:00
|
|
|
|
<div class="mention-group-title">文件</div>
|
2026-04-02 13:18:42 +08:00
|
|
|
|
<div v-if="showFileSearchPrompt" class="mention-search-placeholder">
|
|
|
|
|
|
输入相关内容以搜索文件
|
2026-02-04 17:05:26 +08:00
|
|
|
|
</div>
|
2026-04-02 13:18:42 +08:00
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, index) in mentionItems.files"
|
|
|
|
|
|
:key="'file-' + item.value"
|
2026-05-21 23:06:33 +08:00
|
|
|
|
:class="['mention-item', 'file-item', { active: isItemSelected('file', index) }]"
|
2026-04-02 13:18:42 +08:00
|
|
|
|
@click="insertMention(item)"
|
|
|
|
|
|
>
|
2026-05-21 23:06:33 +08:00
|
|
|
|
<div class="file-info-left">
|
|
|
|
|
|
<component
|
|
|
|
|
|
:is="item.is_dir ? FolderFilled : getFileIcon(item.label)"
|
|
|
|
|
|
:style="{ color: item.is_dir ? '#ffa940' : getFileIconColor(item.label) }"
|
|
|
|
|
|
class="file-type-icon"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="file-name" :title="item.label">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="formatMentionPath(item.description)"
|
|
|
|
|
|
class="file-parent-dir"
|
|
|
|
|
|
:title="formatMentionPath(item.description)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(
|
|
|
|
|
|
formatMentionPath(item.description),
|
|
|
|
|
|
mentionQuery
|
|
|
|
|
|
)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</span>
|
2026-04-02 13:18:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-02-04 17:05:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 知识库列表 -->
|
|
|
|
|
|
<div v-if="mentionItems.knowledgeBases.length > 0" class="mention-group">
|
|
|
|
|
|
<div class="mention-group-title">知识库</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, index) in mentionItems.knowledgeBases"
|
|
|
|
|
|
:key="'kb-' + item.value"
|
2026-05-30 17:54:36 +08:00
|
|
|
|
:class="['mention-item', 'resource-item', { active: isItemSelected('knowledge', index) }]"
|
2026-02-04 17:05:26 +08:00
|
|
|
|
@click="insertMention(item)"
|
|
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<div class="resource-name">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="getMentionDescription(item.description)"
|
|
|
|
|
|
class="resource-description"
|
|
|
|
|
|
:title="getMentionDescription(item.description)"
|
2026-05-21 23:06:33 +08:00
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(
|
|
|
|
|
|
getMentionDescription(item.description),
|
|
|
|
|
|
mentionQuery
|
|
|
|
|
|
)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
2026-02-04 17:05:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- MCP 列表 -->
|
|
|
|
|
|
<div v-if="mentionItems.mcps.length > 0" class="mention-group">
|
|
|
|
|
|
<div class="mention-group-title">MCP</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, index) in mentionItems.mcps"
|
|
|
|
|
|
:key="'mcp-' + item.value"
|
2026-05-30 17:54:36 +08:00
|
|
|
|
:class="['mention-item', 'resource-item', { active: isItemSelected('mcp', index) }]"
|
2026-02-04 17:05:26 +08:00
|
|
|
|
@click="insertMention(item)"
|
|
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<div class="resource-name">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="getMentionDescription(item.description)"
|
|
|
|
|
|
class="resource-description"
|
|
|
|
|
|
:title="getMentionDescription(item.description)"
|
2026-05-21 23:06:33 +08:00
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(
|
|
|
|
|
|
getMentionDescription(item.description),
|
|
|
|
|
|
mentionQuery
|
|
|
|
|
|
)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
2026-02-04 17:05:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-16 19:18:45 +08:00
|
|
|
|
<!-- Skills 列表 -->
|
|
|
|
|
|
<div v-if="mentionItems.skills.length > 0" class="mention-group">
|
|
|
|
|
|
<div class="mention-group-title">Skills</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, index) in mentionItems.skills"
|
|
|
|
|
|
:key="'skill-' + item.value"
|
2026-05-30 17:54:36 +08:00
|
|
|
|
:class="['mention-item', 'resource-item', { active: isItemSelected('skill', index) }]"
|
2026-03-16 19:18:45 +08:00
|
|
|
|
@click="insertMention(item)"
|
|
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<div class="resource-name">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="getMentionDescription(item.description)"
|
|
|
|
|
|
class="resource-description"
|
|
|
|
|
|
:title="getMentionDescription(item.description)"
|
2026-05-21 23:06:33 +08:00
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(
|
|
|
|
|
|
getMentionDescription(item.description),
|
|
|
|
|
|
mentionQuery
|
|
|
|
|
|
)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
2026-03-16 19:18:45 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-20 17:51:12 +08:00
|
|
|
|
<!-- Subagents 列表 -->
|
|
|
|
|
|
<div v-if="mentionItems.subagents.length > 0" class="mention-group">
|
|
|
|
|
|
<div class="mention-group-title">Subagents</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="(item, index) in mentionItems.subagents"
|
|
|
|
|
|
:key="'subagent-' + item.value"
|
2026-05-30 17:54:36 +08:00
|
|
|
|
:class="['mention-item', 'resource-item', { active: isItemSelected('subagent', index) }]"
|
2026-03-20 17:51:12 +08:00
|
|
|
|
@click="insertMention(item)"
|
|
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<div class="resource-name">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(item.label, mentionQuery)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="getMentionDescription(item.description)"
|
|
|
|
|
|
class="resource-description"
|
|
|
|
|
|
:title="getMentionDescription(item.description)"
|
2026-05-21 23:06:33 +08:00
|
|
|
|
>
|
2026-05-30 17:54:36 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-for="(part, pIdx) in splitTextByQuery(
|
|
|
|
|
|
getMentionDescription(item.description),
|
|
|
|
|
|
mentionQuery
|
|
|
|
|
|
)"
|
|
|
|
|
|
:key="pIdx"
|
|
|
|
|
|
:class="{ 'query-match': part.isMatch }"
|
|
|
|
|
|
>{{ part.text }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
2026-03-20 17:51:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
<!-- 无结果 -->
|
|
|
|
|
|
<div v-if="!hasAnyItems" class="mention-empty">暂无可引用的项</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
<div class="send-button-container">
|
2025-11-12 03:41:22 +08:00
|
|
|
|
<slot name="actions-right"></slot>
|
2025-08-09 23:18:19 +08:00
|
|
|
|
<a-tooltip :title="isLoading ? '停止回答' : ''">
|
|
|
|
|
|
<a-button
|
|
|
|
|
|
@click="handleSendOrStop"
|
|
|
|
|
|
:disabled="sendButtonDisabled"
|
|
|
|
|
|
type="link"
|
|
|
|
|
|
class="send-button"
|
|
|
|
|
|
>
|
2025-03-28 03:17:09 +08:00
|
|
|
|
<template #icon>
|
2026-01-15 06:01:34 +08:00
|
|
|
|
<component :is="getIcon" class="send-btn" />
|
2025-03-28 03:17:09 +08:00
|
|
|
|
</template>
|
2025-08-09 23:18:19 +08:00
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-tooltip>
|
2025-03-28 03:17:09 +08:00
|
|
|
|
</div>
|
2025-11-12 14:04:34 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="bottom-slot">
|
|
|
|
|
|
<slot name="bottom"></slot>
|
|
|
|
|
|
</div>
|
2025-03-28 03:17:09 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-03-16 21:39:49 +08:00
|
|
|
|
import { ref, computed, onMounted, nextTick, watch, onBeforeUnmount, useSlots } from 'vue'
|
2026-05-21 23:06:33 +08:00
|
|
|
|
import { SendOutlined, ArrowUpOutlined, PauseOutlined, FolderFilled } from '@ant-design/icons-vue'
|
2026-04-19 23:44:46 +08:00
|
|
|
|
import { Paperclip } from 'lucide-vue-next'
|
2026-05-21 23:06:33 +08:00
|
|
|
|
import { searchMentionFiles } from '@/apis/mention_api'
|
|
|
|
|
|
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
2026-05-30 17:54:36 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildMentionDisplayLabels,
|
|
|
|
|
|
expandMentionDeletionRange,
|
|
|
|
|
|
findActiveMentionQuery,
|
|
|
|
|
|
formatMentionToken,
|
|
|
|
|
|
getMentionDisplayLabel,
|
|
|
|
|
|
mentionTypePrefixMap,
|
|
|
|
|
|
parseMentionText,
|
|
|
|
|
|
replaceRawRange
|
|
|
|
|
|
} from '@/utils/mention_utils'
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// 点击外部关闭下拉框
|
|
|
|
|
|
const mentionDropdownRef = ref(null)
|
|
|
|
|
|
const closeMentionPopup = (e) => {
|
|
|
|
|
|
if (!mentionPopupVisible.value) return
|
|
|
|
|
|
if (inputRef.value?.contains(e.target)) return
|
|
|
|
|
|
if (mentionDropdownRef.value?.contains(e.target)) return
|
|
|
|
|
|
mentionPopupVisible.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
|
const inputRef = ref(null)
|
|
|
|
|
|
const optionsExpanded = ref(false)
|
2025-08-09 23:18:19 +08:00
|
|
|
|
// 用于防抖的定时器
|
2026-01-15 06:01:34 +08:00
|
|
|
|
const debounceTimer = ref(null)
|
2025-03-28 03:17:09 +08:00
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
modelValue: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
placeholder: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: '输入问题...'
|
|
|
|
|
|
},
|
|
|
|
|
|
isLoading: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
|
|
|
|
|
},
|
|
|
|
|
|
disabled: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
|
|
|
|
|
},
|
|
|
|
|
|
sendButtonDisabled: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
|
|
|
|
|
},
|
|
|
|
|
|
autoSize: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({ minRows: 2, maxRows: 6 })
|
|
|
|
|
|
},
|
|
|
|
|
|
sendIcon: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: 'ArrowUpOutlined'
|
|
|
|
|
|
},
|
|
|
|
|
|
customClasses: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({})
|
2026-01-24 15:24:25 +08:00
|
|
|
|
},
|
2026-02-04 17:05:26 +08:00
|
|
|
|
mention: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => null
|
2026-05-21 23:06:33 +08:00
|
|
|
|
},
|
|
|
|
|
|
threadId: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: ''
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
})
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
|
const emit = defineEmits(['update:modelValue', 'send', 'keydown'])
|
|
|
|
|
|
const slots = useSlots()
|
2026-01-24 15:24:25 +08:00
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// @ 提及功能是否启用
|
|
|
|
|
|
const mentionEnabled = computed(() => {
|
2026-05-22 13:15:46 +08:00
|
|
|
|
return !!props.mention
|
2026-02-04 17:05:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const mentionDisplayLabels = computed(() => buildMentionDisplayLabels(props.mention || {}))
|
|
|
|
|
|
|
|
|
|
|
|
let lastRawSelectionRange = null
|
|
|
|
|
|
let lastSyncedEditorValue = props.modelValue || ''
|
|
|
|
|
|
|
|
|
|
|
|
const getStoredRawSelectionRange = () => {
|
|
|
|
|
|
const length = getEditorRawValue().length
|
|
|
|
|
|
if (!lastRawSelectionRange) {
|
|
|
|
|
|
return { start: length, end: length, collapsed: true }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const start = Math.max(0, Math.min(lastRawSelectionRange.start, length))
|
|
|
|
|
|
const end = Math.max(start, Math.min(lastRawSelectionRange.end, length))
|
|
|
|
|
|
return { start, end, collapsed: start === end }
|
2026-03-16 19:18:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const rememberRawSelectionRange = (range) => {
|
|
|
|
|
|
lastRawSelectionRange = { ...range }
|
|
|
|
|
|
return range
|
2026-03-16 19:18:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
const formatMentionPath = (path) => {
|
|
|
|
|
|
if (!path) return ''
|
|
|
|
|
|
let cleanPath = path.replace(/^\/?home\/gem\/user-data\/?/, '')
|
|
|
|
|
|
if (cleanPath.startsWith('/')) {
|
|
|
|
|
|
cleanPath = cleanPath.substring(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 如果以 / 结尾,说明它是一个目录,我们先去掉末尾的 / 之后再算父目录
|
|
|
|
|
|
let isDir = cleanPath.endsWith('/')
|
|
|
|
|
|
let pathForParent = isDir ? cleanPath.substring(0, cleanPath.length - 1) : cleanPath
|
|
|
|
|
|
|
|
|
|
|
|
const lastSlashIndex = pathForParent.lastIndexOf('/')
|
|
|
|
|
|
if (lastSlashIndex === -1) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
return pathForParent.substring(0, lastSlashIndex + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const getMentionDescription = (description) => {
|
|
|
|
|
|
const value = String(description || '').trim()
|
|
|
|
|
|
if (!value || value === '暂无描述') return ''
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
// 高性能且安全的关键字切片高亮解析函数 (100% 防御 XSS,避开危险的 v-html)
|
|
|
|
|
|
const splitTextByQuery = (text, query) => {
|
|
|
|
|
|
if (!text) return []
|
|
|
|
|
|
if (!query) return [{ text, isMatch: false }]
|
|
|
|
|
|
|
2026-05-26 19:39:08 +08:00
|
|
|
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
2026-05-21 23:06:33 +08:00
|
|
|
|
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
|
|
|
|
|
const parts = text.split(regex)
|
|
|
|
|
|
|
|
|
|
|
|
return parts.map((part) => ({
|
|
|
|
|
|
text: part,
|
|
|
|
|
|
isMatch: part.toLowerCase() === query.toLowerCase()
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const isTextNode = (node) => node?.nodeType === Node.TEXT_NODE
|
|
|
|
|
|
const isElementNode = (node) => node?.nodeType === Node.ELEMENT_NODE
|
|
|
|
|
|
const isMentionNode = (node) => isElementNode(node) && node.dataset?.mentionRaw !== undefined
|
|
|
|
|
|
const isLineBreakNode = (node) => isElementNode(node) && node.tagName === 'BR'
|
|
|
|
|
|
const childIndex = (node) => Array.prototype.indexOf.call(node.parentNode?.childNodes || [], node)
|
|
|
|
|
|
|
|
|
|
|
|
const getRawNodeLength = (node) => {
|
|
|
|
|
|
if (!node) return 0
|
|
|
|
|
|
if (isTextNode(node)) return node.textContent?.length || 0
|
|
|
|
|
|
if (isMentionNode(node)) return node.dataset.mentionRaw?.length || 0
|
|
|
|
|
|
if (isLineBreakNode(node)) return 1
|
|
|
|
|
|
return Array.from(node.childNodes || []).reduce((total, child) => total + getRawNodeLength(child), 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const serializeEditorNode = (node) => {
|
|
|
|
|
|
if (!node) return ''
|
|
|
|
|
|
if (isTextNode(node)) return node.textContent || ''
|
|
|
|
|
|
if (isMentionNode(node)) return node.dataset.mentionRaw || ''
|
|
|
|
|
|
if (isLineBreakNode(node)) return '\n'
|
|
|
|
|
|
return Array.from(node.childNodes || [])
|
|
|
|
|
|
.map((child) => serializeEditorNode(child))
|
|
|
|
|
|
.join('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const serializeEditorContent = () => serializeEditorNode(inputRef.value)
|
|
|
|
|
|
const getEditorRawValue = () => (inputRef.value ? serializeEditorContent() : inputValue.value)
|
|
|
|
|
|
|
|
|
|
|
|
const getEditorMentionIconSvg = (type) => {
|
|
|
|
|
|
if (type === 'knowledge') {
|
|
|
|
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'skill') {
|
|
|
|
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'subagent') {
|
|
|
|
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (type === 'mcp') {
|
|
|
|
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a6 6 0 0 1-12 0V8Z"/></svg>'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2.2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const createEditorMentionElement = (segment) => {
|
|
|
|
|
|
const token = document.createElement('span')
|
|
|
|
|
|
token.className = `mention-ref-token mention-ref-${segment.type} mention-ref-editable`
|
|
|
|
|
|
token.contentEditable = 'false'
|
|
|
|
|
|
token.dataset.mentionRaw = segment.raw
|
|
|
|
|
|
token.dataset.mentionType = segment.type
|
|
|
|
|
|
token.dataset.mentionValue = segment.value
|
|
|
|
|
|
token.title = segment.raw
|
|
|
|
|
|
|
|
|
|
|
|
const icon = document.createElement('span')
|
|
|
|
|
|
icon.className = 'mention-ref-icon'
|
|
|
|
|
|
icon.innerHTML = getEditorMentionIconSvg(segment.type)
|
|
|
|
|
|
if (segment.type === 'file') {
|
|
|
|
|
|
icon.style.color = segment.value.endsWith('/') ? '#ffa940' : getFileIconColor(segment.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
token.appendChild(icon)
|
|
|
|
|
|
|
|
|
|
|
|
const label = document.createElement('span')
|
|
|
|
|
|
label.className = 'mention-ref-label'
|
|
|
|
|
|
label.textContent = getMentionDisplayLabel(segment.type, segment.value, mentionDisplayLabels.value)
|
|
|
|
|
|
token.appendChild(label)
|
|
|
|
|
|
|
|
|
|
|
|
return token
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const renderEditorContent = (raw = '') => {
|
|
|
|
|
|
const editor = inputRef.value
|
|
|
|
|
|
if (!editor) return
|
|
|
|
|
|
|
|
|
|
|
|
editor.replaceChildren()
|
|
|
|
|
|
parseMentionText(raw).forEach((segment) => {
|
|
|
|
|
|
editor.appendChild(
|
|
|
|
|
|
segment.kind === 'text' ? document.createTextNode(segment.text) : createEditorMentionElement(segment)
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
lastSyncedEditorValue = String(raw || '')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isNodeInEditor = (node) => {
|
|
|
|
|
|
const editor = inputRef.value
|
|
|
|
|
|
if (!editor || !node) return false
|
|
|
|
|
|
const element = isElementNode(node) ? node : node.parentNode
|
|
|
|
|
|
return element === editor || editor.contains(element)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getRawOffsetFromDomPoint = (container, offset) => {
|
|
|
|
|
|
const editor = inputRef.value
|
|
|
|
|
|
if (!editor || !isNodeInEditor(container)) return getEditorRawValue().length
|
|
|
|
|
|
|
|
|
|
|
|
let rawOffset = 0
|
|
|
|
|
|
let found = false
|
|
|
|
|
|
|
|
|
|
|
|
const visit = (node) => {
|
|
|
|
|
|
if (!node || found) return
|
|
|
|
|
|
|
|
|
|
|
|
if (node === container) {
|
|
|
|
|
|
if (isTextNode(node)) {
|
|
|
|
|
|
rawOffset += Math.min(offset, node.textContent?.length || 0)
|
|
|
|
|
|
} else if (isMentionNode(node)) {
|
|
|
|
|
|
rawOffset += offset > 0 ? getRawNodeLength(node) : 0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const children = Array.from(node.childNodes || [])
|
|
|
|
|
|
for (let index = 0; index < Math.min(offset, children.length); index++) {
|
|
|
|
|
|
rawOffset += getRawNodeLength(children[index])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
found = true
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isTextNode(node) || isMentionNode(node) || isLineBreakNode(node)) {
|
|
|
|
|
|
rawOffset += getRawNodeLength(node)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const child of Array.from(node.childNodes || [])) {
|
|
|
|
|
|
visit(child)
|
|
|
|
|
|
if (found) return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
visit(editor)
|
|
|
|
|
|
return rawOffset
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getRawSelectionRange = () => {
|
|
|
|
|
|
const selection = window.getSelection()
|
|
|
|
|
|
if (!selection || selection.rangeCount === 0 || !isNodeInEditor(selection.anchorNode)) {
|
|
|
|
|
|
return getStoredRawSelectionRange()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const anchor = getRawOffsetFromDomPoint(selection.anchorNode, selection.anchorOffset)
|
|
|
|
|
|
const focus = getRawOffsetFromDomPoint(selection.focusNode, selection.focusOffset)
|
|
|
|
|
|
return rememberRawSelectionRange({
|
|
|
|
|
|
start: Math.min(anchor, focus),
|
|
|
|
|
|
end: Math.max(anchor, focus),
|
|
|
|
|
|
collapsed: anchor === focus
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getDomPointForRawOffset = (rawOffset) => {
|
|
|
|
|
|
const editor = inputRef.value
|
|
|
|
|
|
const offset = Math.max(0, Math.min(rawOffset, getEditorRawValue().length))
|
|
|
|
|
|
let remaining = offset
|
|
|
|
|
|
|
|
|
|
|
|
const pointBeforeNode = (node) => ({ node: node.parentNode || editor, offset: childIndex(node) })
|
|
|
|
|
|
const pointAfterNode = (node) => ({ node: node.parentNode || editor, offset: childIndex(node) + 1 })
|
|
|
|
|
|
|
|
|
|
|
|
const visit = (node) => {
|
|
|
|
|
|
if (!node) return null
|
|
|
|
|
|
|
|
|
|
|
|
if (isTextNode(node)) {
|
|
|
|
|
|
const length = node.textContent?.length || 0
|
|
|
|
|
|
if (remaining <= length) {
|
|
|
|
|
|
return { node, offset: remaining }
|
|
|
|
|
|
}
|
|
|
|
|
|
remaining -= length
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isMentionNode(node) || isLineBreakNode(node)) {
|
|
|
|
|
|
const length = getRawNodeLength(node)
|
|
|
|
|
|
if (remaining === 0) return pointBeforeNode(node)
|
|
|
|
|
|
if (remaining <= length) return pointAfterNode(node)
|
|
|
|
|
|
remaining -= length
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const child of Array.from(node.childNodes || [])) {
|
|
|
|
|
|
const point = visit(child)
|
|
|
|
|
|
if (point) return point
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return node === editor ? { node: editor, offset: editor.childNodes.length } : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return visit(editor) || { node: editor, offset: editor?.childNodes.length || 0 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const restoreEditorSelection = (start, end = start) => {
|
|
|
|
|
|
const editor = inputRef.value
|
|
|
|
|
|
const selection = window.getSelection()
|
|
|
|
|
|
if (!editor || !selection) return
|
|
|
|
|
|
|
|
|
|
|
|
const startPoint = getDomPointForRawOffset(start)
|
|
|
|
|
|
const endPoint = getDomPointForRawOffset(end)
|
|
|
|
|
|
const range = document.createRange()
|
|
|
|
|
|
range.setStart(startPoint.node, startPoint.offset)
|
|
|
|
|
|
range.setEnd(endPoint.node, endPoint.offset)
|
|
|
|
|
|
selection.removeAllRanges()
|
|
|
|
|
|
selection.addRange(range)
|
|
|
|
|
|
rememberRawSelectionRange({ start, end, collapsed: start === end })
|
|
|
|
|
|
editor.focus()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateRawValue = (value, caretStart, caretEnd = caretStart) => {
|
|
|
|
|
|
renderEditorContent(value)
|
|
|
|
|
|
emit('update:modelValue', value)
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
restoreEditorSelection(caretStart, caretEnd)
|
|
|
|
|
|
adjustTextareaHeight()
|
|
|
|
|
|
if (mentionEnabled.value) {
|
|
|
|
|
|
checkMentionTrigger()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const replaceCurrentRawSelection = (replacement) => {
|
|
|
|
|
|
const currentValue = getEditorRawValue()
|
|
|
|
|
|
const range = getRawSelectionRange()
|
|
|
|
|
|
const nextValue = replaceRawRange(currentValue, range.start, range.end, replacement)
|
|
|
|
|
|
const nextOffset = range.start + replacement.length
|
|
|
|
|
|
updateRawValue(nextValue, nextOffset)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// 检测是否在 @ 触发位置
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const checkMentionTrigger = () => {
|
|
|
|
|
|
if (!inputRef.value || !mentionEnabled.value) return false
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const selectionRange = getRawSelectionRange()
|
|
|
|
|
|
if (!selectionRange.collapsed) {
|
|
|
|
|
|
mentionPopupVisible.value = false
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const activeMention = findActiveMentionQuery(getEditorRawValue(), selectionRange.end)
|
|
|
|
|
|
if (activeMention) {
|
|
|
|
|
|
const sameQuery = mentionPopupVisible.value && mentionQuery.value === activeMention.query
|
|
|
|
|
|
mentionQuery.value = activeMention.query
|
2026-02-04 17:05:26 +08:00
|
|
|
|
mentionPopupVisible.value = true
|
2026-05-30 17:54:36 +08:00
|
|
|
|
if (!sameQuery) {
|
|
|
|
|
|
mentionSelectedIndex.value = 0
|
|
|
|
|
|
updateMentionItems(mentionQuery.value)
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
mentionPopupVisible.value = false
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新提及候选项
|
|
|
|
|
|
const updateMentionItems = (query = '') => {
|
2026-05-31 13:36:42 +08:00
|
|
|
|
const normalizedQuery = String(query || '')
|
|
|
|
|
|
if (!normalizedQuery) {
|
|
|
|
|
|
clearTimeout(mentionSearchTimer)
|
|
|
|
|
|
if (activeAbortController) {
|
|
|
|
|
|
activeAbortController.abort()
|
|
|
|
|
|
activeAbortController = null
|
|
|
|
|
|
}
|
|
|
|
|
|
searchRequestId.value++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
if (!props.mention) {
|
2026-03-20 17:51:12 +08:00
|
|
|
|
mentionItems.value = { files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] }
|
2026-02-04 17:05:26 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
const lowerQuery = normalizedQuery.toLowerCase()
|
2026-03-20 17:51:12 +08:00
|
|
|
|
const { files = [], knowledgeBases = [], mcps = [], skills = [], subagents = [] } = props.mention
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
2026-03-16 19:18:45 +08:00
|
|
|
|
const filterItems = (list) =>
|
2026-02-04 17:05:26 +08:00
|
|
|
|
list.filter((item) => {
|
2026-03-16 19:18:45 +08:00
|
|
|
|
const searchTexts = [
|
|
|
|
|
|
item.label,
|
|
|
|
|
|
item.value,
|
|
|
|
|
|
item.description,
|
2026-05-30 17:54:36 +08:00
|
|
|
|
item.resourceId,
|
2026-03-16 19:18:45 +08:00
|
|
|
|
item.tokenLabel,
|
|
|
|
|
|
item.type,
|
|
|
|
|
|
mentionTypePrefixMap[item.type]
|
|
|
|
|
|
]
|
2026-03-16 21:39:49 +08:00
|
|
|
|
return searchTexts.some((text) =>
|
|
|
|
|
|
String(text || '')
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.includes(lowerQuery)
|
|
|
|
|
|
)
|
2026-02-04 17:05:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
// 本地临时文件/附件候选项过滤
|
|
|
|
|
|
const localFileItems = files.map((f) => {
|
2026-03-16 19:18:45 +08:00
|
|
|
|
const path = f.path || ''
|
|
|
|
|
|
const fileName = path.split('/').pop() || path
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: path,
|
|
|
|
|
|
label: fileName,
|
2026-02-04 17:05:26 +08:00
|
|
|
|
type: 'file',
|
2026-03-16 19:18:45 +08:00
|
|
|
|
insertValue: path || fileName,
|
|
|
|
|
|
tokenLabel: formatMentionToken('file', fileName),
|
|
|
|
|
|
description: path
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
const filteredLocalFiles = normalizedQuery ? filterItems(localFileItems) : []
|
2026-05-21 23:06:33 +08:00
|
|
|
|
|
2026-03-16 19:18:45 +08:00
|
|
|
|
const knowledgeItems = knowledgeBases.map((kb) => {
|
|
|
|
|
|
const kbName = kb.name || ''
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: kbName,
|
|
|
|
|
|
label: kbName,
|
2026-02-04 17:05:26 +08:00
|
|
|
|
type: 'knowledge',
|
2026-03-16 19:18:45 +08:00
|
|
|
|
insertValue: kbName,
|
|
|
|
|
|
tokenLabel: formatMentionToken('knowledge', kbName),
|
2026-05-30 17:54:36 +08:00
|
|
|
|
description: kb.description || '',
|
|
|
|
|
|
resourceId: kb.kb_id
|
2026-03-16 19:18:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const mcpItems = mcps.map((m) => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const mcpValue = m.slug || m.value || m.id || m.name || ''
|
|
|
|
|
|
const mcpLabel = m.name || m.label || mcpValue
|
2026-03-16 19:18:45 +08:00
|
|
|
|
return {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
value: mcpValue,
|
|
|
|
|
|
label: mcpLabel,
|
2026-02-04 17:05:26 +08:00
|
|
|
|
type: 'mcp',
|
2026-05-30 17:54:36 +08:00
|
|
|
|
insertValue: mcpValue,
|
|
|
|
|
|
tokenLabel: formatMentionToken('mcp', mcpLabel),
|
2026-02-04 17:05:26 +08:00
|
|
|
|
description: m.description || ''
|
2026-03-16 19:18:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const skillItems = skills.map((skill) => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const skillValue = skill.slug || skill.value || skill.id || skill.name || ''
|
|
|
|
|
|
const skillLabel = skill.name || skill.label || skillValue
|
2026-03-16 19:18:45 +08:00
|
|
|
|
return {
|
|
|
|
|
|
value: skillValue,
|
2026-05-30 17:54:36 +08:00
|
|
|
|
label: skillLabel,
|
2026-03-16 19:18:45 +08:00
|
|
|
|
type: 'skill',
|
|
|
|
|
|
insertValue: skillValue,
|
2026-05-30 17:54:36 +08:00
|
|
|
|
tokenLabel: formatMentionToken('skill', skillLabel),
|
2026-03-16 19:18:45 +08:00
|
|
|
|
description: skill.description || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-20 17:51:12 +08:00
|
|
|
|
const subagentItems = subagents.map((subagent) => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const subagentValue = subagent.id || subagent.value || subagent.slug || subagent.name || ''
|
2026-03-20 17:51:12 +08:00
|
|
|
|
const subagentLabel = subagent.name || subagent.label || subagentValue
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: subagentValue,
|
|
|
|
|
|
label: subagentLabel,
|
|
|
|
|
|
type: 'subagent',
|
|
|
|
|
|
insertValue: subagentValue,
|
2026-05-30 17:54:36 +08:00
|
|
|
|
tokenLabel: formatMentionToken('subagent', subagentLabel),
|
2026-03-20 17:51:12 +08:00
|
|
|
|
description: subagent.description || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
// 初始化设置 mentionItems 状态(使用前端已有的本地过滤结果,瞬间更新,达到零卡顿)
|
2026-03-16 19:18:45 +08:00
|
|
|
|
mentionItems.value = {
|
2026-05-21 23:06:33 +08:00
|
|
|
|
files: filteredLocalFiles,
|
2026-03-16 19:18:45 +08:00
|
|
|
|
knowledgeBases: filterItems(knowledgeItems),
|
|
|
|
|
|
mcps: filterItems(mcpItems),
|
2026-03-20 17:51:12 +08:00
|
|
|
|
skills: filterItems(skillItems),
|
|
|
|
|
|
subagents: filterItems(subagentItems)
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
2026-05-21 23:06:33 +08:00
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
if (normalizedQuery) {
|
2026-05-30 16:04:39 +08:00
|
|
|
|
const activeThreadId = props.threadId || ''
|
2026-05-21 23:06:33 +08:00
|
|
|
|
clearTimeout(mentionSearchTimer)
|
2026-05-31 13:36:42 +08:00
|
|
|
|
if (activeAbortController) {
|
|
|
|
|
|
activeAbortController.abort()
|
|
|
|
|
|
activeAbortController = null
|
|
|
|
|
|
}
|
|
|
|
|
|
searchRequestId.value++
|
|
|
|
|
|
const currentId = searchRequestId.value
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
mentionSearchTimer = setTimeout(async () => {
|
|
|
|
|
|
activeAbortController = new AbortController()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const responseData = await searchMentionFiles(
|
2026-05-22 13:28:53 +08:00
|
|
|
|
activeThreadId,
|
2026-05-31 13:36:42 +08:00
|
|
|
|
normalizedQuery,
|
2026-05-21 23:06:33 +08:00
|
|
|
|
activeAbortController.signal
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 竞态校验锁,确保是当前最新响应
|
|
|
|
|
|
if (currentId === searchRequestId.value && Array.isArray(responseData)) {
|
|
|
|
|
|
const remoteFileItems = responseData.map((f) => {
|
|
|
|
|
|
const path = f.path || ''
|
|
|
|
|
|
const fileName = f.name || path.split('/').pop() || path
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: path,
|
|
|
|
|
|
label: fileName,
|
|
|
|
|
|
type: 'file',
|
|
|
|
|
|
insertValue: path || fileName,
|
|
|
|
|
|
tokenLabel: formatMentionToken('file', fileName),
|
|
|
|
|
|
description: path,
|
2026-05-30 16:04:39 +08:00
|
|
|
|
is_dir: f.is_dir,
|
|
|
|
|
|
source: f.source
|
2026-05-21 23:06:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 合并本地临时文件与后端高匹配度文件(使用 Set 进行去重,防止重复展示)
|
|
|
|
|
|
const seenValues = new Set(filteredLocalFiles.map((x) => x.value))
|
|
|
|
|
|
const mergedFiles = [...filteredLocalFiles]
|
|
|
|
|
|
|
|
|
|
|
|
remoteFileItems.forEach((item) => {
|
|
|
|
|
|
if (!seenValues.has(item.value)) {
|
|
|
|
|
|
seenValues.add(item.value)
|
|
|
|
|
|
mergedFiles.push(item)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
mentionItems.value.files = mergedFiles
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// 主动取消的请求我们不作为错误抛出
|
|
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
|
|
console.error('Mention search error:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (currentId === searchRequestId.value) {
|
|
|
|
|
|
activeAbortController = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 250) // 250ms 经典防抖时间
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查项是否被选中
|
|
|
|
|
|
const isItemSelected = (type, index) => {
|
|
|
|
|
|
if (mentionSelectedIndex.value < 0) return false
|
|
|
|
|
|
|
|
|
|
|
|
const filesLen = mentionItems.value.files.length
|
|
|
|
|
|
const kbLen = mentionItems.value.knowledgeBases.length
|
2026-03-16 19:18:45 +08:00
|
|
|
|
const mcpLen = mentionItems.value.mcps.length
|
2026-03-20 17:51:12 +08:00
|
|
|
|
const skillsLen = mentionItems.value.skills.length
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
|
|
|
|
|
if (type === 'file') {
|
|
|
|
|
|
return mentionSelectedIndex.value === index
|
|
|
|
|
|
} else if (type === 'knowledge') {
|
|
|
|
|
|
return mentionSelectedIndex.value === filesLen + index
|
2026-03-16 19:18:45 +08:00
|
|
|
|
} else if (type === 'mcp') {
|
2026-02-04 17:05:26 +08:00
|
|
|
|
return mentionSelectedIndex.value === filesLen + kbLen + index
|
2026-03-20 17:51:12 +08:00
|
|
|
|
} else if (type === 'skill') {
|
2026-03-16 19:18:45 +08:00
|
|
|
|
return mentionSelectedIndex.value === filesLen + kbLen + mcpLen + index
|
2026-03-20 17:51:12 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
return mentionSelectedIndex.value === filesLen + kbLen + mcpLen + skillsLen + index
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 是否有任何候选项
|
2026-04-02 13:18:42 +08:00
|
|
|
|
const showFileSearchPrompt = computed(() => {
|
|
|
|
|
|
return Boolean(props.mention?.files?.length) && !mentionQuery.value
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
const hasAnyItems = computed(() => {
|
|
|
|
|
|
const items = mentionItems.value
|
2026-03-16 19:18:45 +08:00
|
|
|
|
return (
|
2026-04-02 13:18:42 +08:00
|
|
|
|
showFileSearchPrompt.value ||
|
2026-03-16 19:18:45 +08:00
|
|
|
|
items.files.length > 0 ||
|
|
|
|
|
|
items.knowledgeBases.length > 0 ||
|
|
|
|
|
|
items.mcps.length > 0 ||
|
2026-03-20 17:51:12 +08:00
|
|
|
|
items.skills.length > 0 ||
|
|
|
|
|
|
items.subagents.length > 0
|
2026-03-16 19:18:45 +08:00
|
|
|
|
)
|
2026-02-04 17:05:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const insertMention = (item) => {
|
|
|
|
|
|
if (!inputRef.value) return
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const currentValue = getEditorRawValue()
|
|
|
|
|
|
const selectionRange = getRawSelectionRange()
|
|
|
|
|
|
const activeMention = findActiveMentionQuery(currentValue, selectionRange.end)
|
|
|
|
|
|
if (!activeMention) return
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const mentionValue = item.insertValue || item.value
|
|
|
|
|
|
const mentionText = `${formatMentionToken(item.type, mentionValue)} `
|
|
|
|
|
|
const newValue = replaceRawRange(currentValue, activeMention.start, activeMention.end, mentionText)
|
|
|
|
|
|
const newCursorPos = activeMention.start + mentionText.length
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
|
|
|
|
|
mentionPopupVisible.value = false
|
|
|
|
|
|
mentionQuery.value = ''
|
2026-05-30 17:54:36 +08:00
|
|
|
|
updateRawValue(newValue, newCursorPos)
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 滚动到选中项
|
|
|
|
|
|
const scrollToItem = (index) => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const popup = mentionDropdownRef.value?.querySelector('.mention-popup')
|
|
|
|
|
|
if (!popup) return
|
|
|
|
|
|
|
|
|
|
|
|
// 查找所有 mention-item 元素
|
|
|
|
|
|
const items = popup.querySelectorAll('.mention-item')
|
|
|
|
|
|
const selectedItem = items[index]
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedItem) {
|
|
|
|
|
|
// 检查元素是否在可视区域内
|
|
|
|
|
|
const popupRect = popup.getBoundingClientRect()
|
|
|
|
|
|
const itemRect = selectedItem.getBoundingClientRect()
|
|
|
|
|
|
|
|
|
|
|
|
if (itemRect.bottom > popupRect.bottom) {
|
|
|
|
|
|
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
|
} else if (itemRect.top < popupRect.top) {
|
|
|
|
|
|
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理键盘导航
|
|
|
|
|
|
const handleMentionNavigation = (e) => {
|
|
|
|
|
|
if (!mentionPopupVisible.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const allItems = [
|
|
|
|
|
|
...mentionItems.value.files,
|
|
|
|
|
|
...mentionItems.value.knowledgeBases,
|
2026-03-16 19:18:45 +08:00
|
|
|
|
...mentionItems.value.mcps,
|
2026-03-20 17:51:12 +08:00
|
|
|
|
...mentionItems.value.skills,
|
|
|
|
|
|
...mentionItems.value.subagents
|
2026-02-04 17:05:26 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const total = allItems.length
|
|
|
|
|
|
if (total === 0) return
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
mentionSelectedIndex.value = (mentionSelectedIndex.value + 1) % total
|
|
|
|
|
|
scrollToItem(mentionSelectedIndex.value)
|
|
|
|
|
|
} else if (e.key === 'ArrowUp') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
mentionSelectedIndex.value = (mentionSelectedIndex.value - 1 + total) % total
|
|
|
|
|
|
scrollToItem(mentionSelectedIndex.value)
|
|
|
|
|
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
|
|
|
|
if (mentionSelectedIndex.value >= 0 && mentionSelectedIndex.value < total) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
insertMention(allItems[mentionSelectedIndex.value])
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
mentionPopupVisible.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 13:29:33 +08:00
|
|
|
|
const hasOptionsLeft = computed(() => {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
const slot = slots['options-left']
|
2025-10-15 13:29:33 +08:00
|
|
|
|
if (!slot) {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
return false
|
2025-10-15 13:29:33 +08:00
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
const renderedNodes = slot()
|
|
|
|
|
|
return Boolean(renderedNodes && renderedNodes.length)
|
|
|
|
|
|
})
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 图标映射
|
|
|
|
|
|
const iconComponents = {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
SendOutlined: SendOutlined,
|
|
|
|
|
|
ArrowUpOutlined: ArrowUpOutlined,
|
|
|
|
|
|
PauseOutlined: PauseOutlined
|
|
|
|
|
|
}
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 根据传入的图标名动态获取组件
|
|
|
|
|
|
const getIcon = computed(() => {
|
|
|
|
|
|
if (props.isLoading) {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
return PauseOutlined
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
return iconComponents[props.sendIcon] || ArrowUpOutlined
|
|
|
|
|
|
})
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
|
|
|
|
|
// 创建本地引用以进行双向绑定
|
|
|
|
|
|
const inputValue = computed({
|
|
|
|
|
|
get: () => props.modelValue,
|
|
|
|
|
|
set: (val) => emit('update:modelValue', val)
|
2026-01-15 06:01:34 +08:00
|
|
|
|
})
|
2025-11-12 14:04:34 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const handleMentionDeletion = (e) => {
|
|
|
|
|
|
if (e.key !== 'Backspace' && e.key !== 'Delete') return false
|
|
|
|
|
|
|
|
|
|
|
|
const currentValue = getEditorRawValue()
|
|
|
|
|
|
const selectionRange = getRawSelectionRange()
|
|
|
|
|
|
const expandedRange = expandMentionDeletionRange(
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
selectionRange.start,
|
|
|
|
|
|
selectionRange.end,
|
|
|
|
|
|
e.key === 'Delete' ? 'forward' : 'backward'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (!expandedRange) return false
|
|
|
|
|
|
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
const nextValue = replaceRawRange(currentValue, expandedRange.start, expandedRange.end, '')
|
|
|
|
|
|
mentionPopupVisible.value = false
|
|
|
|
|
|
updateRawValue(nextValue, expandedRange.start)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-28 03:17:09 +08:00
|
|
|
|
// 处理键盘事件
|
|
|
|
|
|
const handleKeyPress = (e) => {
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// @ 提及键盘导航
|
|
|
|
|
|
if (mentionPopupVisible.value) {
|
|
|
|
|
|
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) {
|
|
|
|
|
|
handleMentionNavigation(e)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
if (handleMentionDeletion(e)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === 'Enter' && e.shiftKey) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
replaceCurrentRawSelection('\n')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
|
emit('keydown', e)
|
|
|
|
|
|
}
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const shouldCheckMentionOnKeyUp = (e) => {
|
|
|
|
|
|
if (!e) return false
|
|
|
|
|
|
if (e.key.length === 1) return true
|
|
|
|
|
|
return e.key === 'Backspace' || e.key === 'Delete'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// 检测 @ 触发
|
|
|
|
|
|
const handleKeyUp = (e) => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
if (!mentionEnabled.value || isComposing.value || !shouldCheckMentionOnKeyUp(e)) return
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
checkMentionTrigger()
|
|
|
|
|
|
})
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
// 处理输入事件
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const handleInput = () => {
|
|
|
|
|
|
if (isComposing.value) return
|
|
|
|
|
|
|
|
|
|
|
|
if (inputRef.value && !inputRef.value.querySelector('.mention-ref-token')) {
|
|
|
|
|
|
const text = inputRef.value.textContent || ''
|
|
|
|
|
|
if (!text.trim()) {
|
|
|
|
|
|
inputRef.value.replaceChildren()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const value = serializeEditorContent()
|
|
|
|
|
|
lastSyncedEditorValue = value
|
2026-01-15 06:01:34 +08:00
|
|
|
|
emit('update:modelValue', value)
|
2026-05-30 17:54:36 +08:00
|
|
|
|
adjustTextareaHeight()
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
if (mentionEnabled.value) {
|
2026-02-04 17:05:26 +08:00
|
|
|
|
nextTick(() => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
checkMentionTrigger()
|
2026-02-04 17:05:26 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
}
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const handlePaste = (e) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
const text = e.clipboardData?.getData('text/plain') || ''
|
|
|
|
|
|
replaceCurrentRawSelection(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCompositionStart = () => {
|
|
|
|
|
|
isComposing.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCompositionEnd = () => {
|
|
|
|
|
|
isComposing.value = false
|
|
|
|
|
|
handleInput()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-03-28 03:17:09 +08:00
|
|
|
|
// 处理发送按钮点击
|
2025-03-30 20:46:26 +08:00
|
|
|
|
const handleSendOrStop = () => {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
emit('send')
|
|
|
|
|
|
}
|
2025-05-18 17:39:11 +08:00
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// @ 提及功能状态
|
|
|
|
|
|
const mentionPopupVisible = ref(false)
|
|
|
|
|
|
const mentionQuery = ref('')
|
2026-03-20 17:51:12 +08:00
|
|
|
|
const mentionItems = ref({ files: [], knowledgeBases: [], mcps: [], skills: [], subagents: [] })
|
2026-02-04 17:05:26 +08:00
|
|
|
|
const mentionSelectedIndex = ref(0)
|
2026-05-21 23:06:33 +08:00
|
|
|
|
const searchRequestId = ref(0)
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const isComposing = ref(false)
|
2026-05-21 23:06:33 +08:00
|
|
|
|
let activeAbortController = null
|
|
|
|
|
|
let mentionSearchTimer = null
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
2026-03-16 21:39:49 +08:00
|
|
|
|
const adjustTextareaHeight = () => {
|
|
|
|
|
|
if (!inputRef.value) {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
return
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 21:39:49 +08:00
|
|
|
|
const textarea = inputRef.value
|
|
|
|
|
|
textarea.style.height = 'auto'
|
|
|
|
|
|
textarea.style.height = `${textarea.scrollHeight}px`
|
2026-01-15 06:01:34 +08:00
|
|
|
|
}
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2025-05-20 20:49:50 +08:00
|
|
|
|
// 聚焦输入框
|
|
|
|
|
|
const focusInput = () => {
|
|
|
|
|
|
if (inputRef.value && !props.disabled) {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
inputRef.value.focus()
|
2026-05-21 23:06:33 +08:00
|
|
|
|
// 聚焦回来时,如果开启了提及,自动检测当前光标位置是否处于 @提及 范围,是则重新升起弹框
|
|
|
|
|
|
if (mentionEnabled.value) {
|
|
|
|
|
|
nextTick(() => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
checkMentionTrigger()
|
2026-05-21 23:06:33 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理输入框点击事件,自适应检测光标是否落入 @提及 范围内以唤醒或更新弹窗
|
2026-05-30 17:54:36 +08:00
|
|
|
|
const handleEditorClick = () => {
|
2026-05-21 23:06:33 +08:00
|
|
|
|
if (mentionEnabled.value) {
|
|
|
|
|
|
nextTick(() => {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
checkMentionTrigger()
|
2026-05-21 23:06:33 +08:00
|
|
|
|
})
|
2025-05-20 20:49:50 +08:00
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
}
|
2025-05-18 17:39:11 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
// 监听输入值变化
|
2026-05-30 17:54:36 +08:00
|
|
|
|
watch(inputValue, (value) => {
|
|
|
|
|
|
if (value !== lastSyncedEditorValue) {
|
|
|
|
|
|
renderEditorContent(value || '')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 21:39:49 +08:00
|
|
|
|
if (debounceTimer.value) {
|
|
|
|
|
|
clearTimeout(debounceTimer.value)
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
2026-03-16 21:39:49 +08:00
|
|
|
|
debounceTimer.value = setTimeout(() => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
adjustTextareaHeight()
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 100)
|
|
|
|
|
|
})
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2025-05-18 17:39:11 +08:00
|
|
|
|
onMounted(() => {
|
2026-02-04 17:05:26 +08:00
|
|
|
|
document.addEventListener('click', closeMentionPopup)
|
2025-08-09 23:18:19 +08:00
|
|
|
|
nextTick(() => {
|
2025-05-18 17:39:11 +08:00
|
|
|
|
if (inputRef.value) {
|
2026-05-30 17:54:36 +08:00
|
|
|
|
renderEditorContent(inputValue.value || '')
|
2026-03-16 21:39:49 +08:00
|
|
|
|
adjustTextareaHeight()
|
2026-01-15 06:01:34 +08:00
|
|
|
|
inputRef.value.focus()
|
2025-05-18 17:39:11 +08:00
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
})
|
|
|
|
|
|
})
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
// 组件卸载时清除定时器和事件监听器
|
2025-08-09 23:18:19 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
if (debounceTimer.value) {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
clearTimeout(debounceTimer.value)
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
2026-05-21 23:06:33 +08:00
|
|
|
|
if (mentionSearchTimer) {
|
|
|
|
|
|
clearTimeout(mentionSearchTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (activeAbortController) {
|
|
|
|
|
|
activeAbortController.abort()
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
document.removeEventListener('click', closeMentionPopup)
|
2026-01-15 06:01:34 +08:00
|
|
|
|
})
|
2025-05-18 17:39:11 +08:00
|
|
|
|
|
2025-11-12 14:04:34 +08:00
|
|
|
|
// 公开方法供父组件调用
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
focus: () => inputRef.value?.focus(),
|
|
|
|
|
|
closeOptions: () => {
|
2026-01-15 06:01:34 +08:00
|
|
|
|
optionsExpanded.value = false
|
2025-11-12 14:04:34 +08:00
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
|
})
|
2025-03-28 03:17:09 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="less" scoped>
|
|
|
|
|
|
.input-box {
|
2025-08-09 23:18:19 +08:00
|
|
|
|
display: grid;
|
2025-03-28 03:17:09 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 0 auto;
|
2025-11-23 01:39:44 +08:00
|
|
|
|
border: 1px solid var(--gray-150);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
border-radius: 0.8rem;
|
2025-11-23 01:39:44 +08:00
|
|
|
|
box-shadow: 0 2px 8px var(--shadow-1);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
transition: all 0.3s ease;
|
2026-03-29 10:55:00 +08:00
|
|
|
|
background: var(--gray-0);
|
2025-11-12 14:04:34 +08:00
|
|
|
|
gap: 0px;
|
2026-02-04 17:05:26 +08:00
|
|
|
|
position: relative;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2025-11-12 14:04:34 +08:00
|
|
|
|
/* Default: Multi-line layout with top/bottom slots */
|
2025-08-09 23:18:19 +08:00
|
|
|
|
padding: 0.8rem 0.75rem 0.6rem 0.75rem;
|
|
|
|
|
|
grid-template-columns: auto 1fr;
|
2025-11-12 14:04:34 +08:00
|
|
|
|
grid-template-rows: auto auto auto;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
grid-template-areas:
|
2026-01-15 06:01:34 +08:00
|
|
|
|
'top top'
|
|
|
|
|
|
'input input'
|
|
|
|
|
|
'options send';
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
2025-11-12 14:04:34 +08:00
|
|
|
|
.top-slot {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
grid-area: top;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
.expand-options {
|
2025-11-12 14:04:34 +08:00
|
|
|
|
grid-area: options;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
justify-self: start;
|
2025-11-12 03:41:22 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
2025-11-12 14:04:34 +08:00
|
|
|
|
|
|
|
|
|
|
.user-input {
|
|
|
|
|
|
grid-area: input;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
.send-button-container {
|
2025-11-12 14:04:34 +08:00
|
|
|
|
grid-area: send;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
justify-self: end;
|
|
|
|
|
|
}
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-11-12 14:04:34 +08:00
|
|
|
|
.bottom-slot {
|
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-25 13:57:47 +08:00
|
|
|
|
// &:focus-within {
|
|
|
|
|
|
// border-color: var(--main-500);
|
2025-11-23 01:39:44 +08:00
|
|
|
|
// background: var(--gray-0);
|
2025-08-25 13:57:47 +08:00
|
|
|
|
// box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
|
|
|
|
// }
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-options {
|
|
|
|
|
|
grid-area: options;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-input {
|
|
|
|
|
|
grid-area: input;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
margin: 0;
|
2026-01-24 15:24:25 +08:00
|
|
|
|
margin-bottom: 0.5rem;
|
2025-11-23 01:39:44 +08:00
|
|
|
|
color: var(--gray-1000);
|
2025-08-09 23:18:19 +08:00
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
resize: none;
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
|
min-height: 44px; /* Default min-height for multi-line */
|
|
|
|
|
|
max-height: 200px;
|
2026-05-30 17:54:36 +08:00
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
cursor: text;
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
&:focus {
|
2025-03-28 03:17:09 +08:00
|
|
|
|
outline: none;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
&::placeholder {
|
2025-11-23 01:39:44 +08:00
|
|
|
|
color: var(--gray-400);
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
2026-05-30 17:54:36 +08:00
|
|
|
|
|
|
|
|
|
|
&.mention-editor {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
|
|
|
|
|
|
&:empty::before {
|
|
|
|
|
|
content: attr(data-placeholder);
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
color: var(--gray-400);
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&[contenteditable='false'] {
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.mention-ref-token) {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
max-width: min(100%, 360px);
|
|
|
|
|
|
color: var(--main-700);
|
|
|
|
|
|
line-height: normal;
|
|
|
|
|
|
vertical-align: baseline;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
user-select: all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.mention-ref-icon) {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
top: 2px;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
margin-left: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.mention-ref-icon svg) {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.mention-ref-label) {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
line-height: normal;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
.send-button-container {
|
|
|
|
|
|
grid-area: send;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
.expand-btn {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
border-radius: 8px;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
color: var(--gray-600);
|
|
|
|
|
|
transition: all 0.2s ease;
|
2025-11-12 03:41:22 +08:00
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
background-color: transparent;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
|
|
|
|
|
&:hover {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
color: var(--main-color);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:active {
|
|
|
|
|
|
color: var(--main-color);
|
2026-03-09 01:23:15 +08:00
|
|
|
|
// 移除点击缩小效果
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
.anticon {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
&.rotated {
|
|
|
|
|
|
transform: rotate(45deg);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Popover 选项样式
|
|
|
|
|
|
.popover-options {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
min-width: 160px;
|
|
|
|
|
|
max-width: 200px;
|
|
|
|
|
|
padding: 4px;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
|
|
|
|
|
|
.no-options {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
color: var(--gray-500);
|
2025-08-09 23:18:19 +08:00
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
text-align: center;
|
2025-11-12 03:41:22 +08:00
|
|
|
|
padding: 12px 8px;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.opt-item) {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
padding: 6px 10px;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--gray-700);
|
|
|
|
|
|
transition: all 0.2s ease;
|
2025-11-12 03:41:22 +08:00
|
|
|
|
margin: 2px;
|
2025-08-09 23:18:19 +08:00
|
|
|
|
display: inline-block;
|
2025-03-28 03:17:09 +08:00
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
&:hover {
|
|
|
|
|
|
background-color: var(--main-10);
|
2025-11-12 03:41:22 +08:00
|
|
|
|
color: var(--main-600);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
&.active {
|
2025-11-12 03:41:22 +08:00
|
|
|
|
color: var(--main-600);
|
2025-08-09 23:18:19 +08:00
|
|
|
|
background-color: var(--main-10);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-09 23:18:19 +08:00
|
|
|
|
.send-button.ant-btn-icon-only {
|
2025-03-28 03:17:09 +08:00
|
|
|
|
height: 32px;
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
background-color: var(--main-500);
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
transition: all 0.2s ease;
|
2025-11-23 01:39:44 +08:00
|
|
|
|
box-shadow: 0 2px 6px var(--shadow-2);
|
|
|
|
|
|
color: var(--gray-0);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
2025-07-26 03:36:54 +08:00
|
|
|
|
background-color: var(--main-color);
|
2025-11-23 01:39:44 +08:00
|
|
|
|
box-shadow: 0 4px 8px var(--shadow-3);
|
|
|
|
|
|
color: var(--gray-0);
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:active {
|
2025-11-23 01:39:44 +08:00
|
|
|
|
box-shadow: 0 2px 4px var(--shadow-2);
|
2026-03-09 01:23:15 +08:00
|
|
|
|
// 移除点击动画效果
|
2025-03-28 03:17:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:disabled {
|
2025-11-06 21:59:48 +08:00
|
|
|
|
opacity: 0.5;
|
2025-03-28 03:17:09 +08:00
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
transform: none;
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 520px) {
|
|
|
|
|
|
.input-box {
|
|
|
|
|
|
border-radius: 15px;
|
|
|
|
|
|
padding: 0.625rem 0.875rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
|
|
|
|
|
// @ 提及弹窗样式
|
|
|
|
|
|
.mention-dropdown-wrapper {
|
|
|
|
|
|
position: absolute;
|
2026-05-21 23:06:33 +08:00
|
|
|
|
bottom: 100%;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
margin-bottom: 8px;
|
2026-02-04 17:05:26 +08:00
|
|
|
|
z-index: 1000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mention-popup {
|
2026-05-21 23:06:33 +08:00
|
|
|
|
width: 100%;
|
2026-02-04 17:05:26 +08:00
|
|
|
|
max-height: 280px;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
background: var(--gray-0);
|
|
|
|
|
|
border-radius: 8px;
|
2026-05-22 13:15:46 +08:00
|
|
|
|
box-shadow:
|
|
|
|
|
|
0 -4px 16px rgba(0, 0, 0, 0.08),
|
|
|
|
|
|
0 4px 16px rgba(0, 0, 0, 0.12);
|
2026-02-04 17:05:26 +08:00
|
|
|
|
border: 1px solid var(--gray-200);
|
2026-05-30 17:54:36 +08:00
|
|
|
|
padding: 8px 0;
|
2026-02-04 17:05:26 +08:00
|
|
|
|
|
|
|
|
|
|
.mention-group {
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
|
|
|
|
|
|
&:last-child {
|
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mention-group-title {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--gray-500);
|
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
border-bottom: 1px solid var(--gray-100);
|
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mention-item {
|
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--gray-700);
|
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
|
margin: 1px 4px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
&.resource-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
|
|
|
|
|
|
|
.resource-name {
|
|
|
|
|
|
color: var(--gray-800);
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
flex: 0 1 auto;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.resource-description {
|
|
|
|
|
|
color: var(--gray-500);
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
line-height: 1.35;
|
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
&.file-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
gap: 0;
|
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
|
|
|
|
|
|
|
.file-info-left {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
flex-shrink: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
|
|
|
|
|
|
.file-type-icon {
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-name {
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: var(--gray-800);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-parent-dir {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: var(--gray-400);
|
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
|
flex-shrink: 1;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
transition: color 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
&:hover {
|
2026-02-04 17:05:26 +08:00
|
|
|
|
background-color: var(--main-10);
|
|
|
|
|
|
color: var(--main-600);
|
2026-05-21 23:06:33 +08:00
|
|
|
|
|
2026-05-30 17:54:36 +08:00
|
|
|
|
&.resource-item {
|
|
|
|
|
|
.resource-name {
|
|
|
|
|
|
color: var(--main-600);
|
|
|
|
|
|
}
|
|
|
|
|
|
.resource-description {
|
|
|
|
|
|
color: var(--main-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.file-item {
|
|
|
|
|
|
.file-info-left .file-name {
|
|
|
|
|
|
color: var(--main-600);
|
|
|
|
|
|
}
|
|
|
|
|
|
.file-parent-dir {
|
|
|
|
|
|
color: var(--main-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.active {
|
|
|
|
|
|
background-color: var(--gray-50);
|
|
|
|
|
|
color: var(--main-600);
|
|
|
|
|
|
|
|
|
|
|
|
&.resource-item {
|
|
|
|
|
|
.resource-name {
|
|
|
|
|
|
color: var(--main-600);
|
|
|
|
|
|
}
|
|
|
|
|
|
.resource-description {
|
|
|
|
|
|
color: var(--main-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
&.file-item {
|
|
|
|
|
|
.file-info-left .file-name {
|
|
|
|
|
|
color: var(--main-600);
|
|
|
|
|
|
}
|
|
|
|
|
|
.file-parent-dir {
|
|
|
|
|
|
color: var(--main-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:06:33 +08:00
|
|
|
|
.query-match {
|
|
|
|
|
|
color: #fa8c16; /* 明亮温润的金橘色 */
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 17:05:26 +08:00
|
|
|
|
.mention-empty {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 12px 8px;
|
|
|
|
|
|
color: var(--gray-400);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
2026-04-02 13:18:42 +08:00
|
|
|
|
|
|
|
|
|
|
.mention-search-placeholder {
|
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
color: var(--gray-400);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
2026-02-04 17:05:26 +08:00
|
|
|
|
}
|
2025-10-15 13:29:33 +08:00
|
|
|
|
</style>
|