Files
Enterprise-Knowledge-Base/frontend/src/components/Input-field.vue
T

2294 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick, h } from "vue";
import { useRoute, useRouter } from 'vue-router';
import { onBeforeRouteUpdate } from 'vue-router';
import { MessagePlugin } from "tdesign-vue-next";
import { useSettingsStore } from '@/stores/settings';
import { useUIStore } from '@/stores/ui';
import { listKnowledgeBases, searchKnowledge, batchQueryKnowledge } from '@/api/knowledge-base';
import { stopSession } from '@/api/chat';
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue';
import MentionSelector from './MentionSelector.vue';
import { getCaretCoordinates } from '@/utils/caret';
import { listModels, type ModelConfig } from '@/api/model';
import { getTenantWebSearchConfig } from '@/api/web-search';
import { getConversationConfig, updateConversationConfig, type ConversationConfig } from '@/api/system';
import { useI18n } from 'vue-i18n';
const route = useRoute();
const router = useRouter();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
let query = ref("");
const showKbSelector = ref(false);
const atButtonRef = ref<HTMLElement>();
const showAgentModeSelector = ref(false);
const agentModeButtonRef = ref<HTMLElement>();
const agentModeDropdownStyle = ref<Record<string, string>>({});
// Mention related state
const showMention = ref(false);
const mentionQuery = ref("");
const mentionItems = ref<Array<{ id: string; name: string; type: 'kb' | 'file'; kbType?: 'document' | 'faq'; count?: number; kbName?: string }>>([]);
const mentionActiveIndex = ref(0);
const mentionStyle = ref<Record<string, string>>({});
const textareaRef = ref<any>(null); // Ref to t-textarea component
const mentionStartPos = ref(0);
const isComposing = ref(false);
const isMentionTriggeredByButton = ref(false);
const mentionHasMore = ref(false);
const mentionLoading = ref(false);
const mentionOffset = ref(0);
const MENTION_PAGE_SIZE = 20;
const props = defineProps({
isReplying: {
type: Boolean,
required: false
},
sessionId: {
type: String,
required: false
},
assistantMessageId: {
type: String,
required: false
}
});
const isAgentEnabled = computed(() => settingsStore.isAgentEnabled);
const isWebSearchEnabled = computed(() => settingsStore.isWebSearchEnabled);
const selectedKbIds = computed(() => settingsStore.settings.selectedKnowledgeBases || []);
const selectedFileIds = computed(() => settingsStore.settings.selectedFiles || []);
const isWebSearchConfigured = ref(false);
// 获取已选择的知识库信息
const knowledgeBases = ref<Array<{ id: string; name: string; type?: 'document' | 'faq'; knowledge_count?: number; chunk_count?: number }>>([]);
const fileList = ref<Array<{ id: string; name: string }>>([]);
const selectedKbs = computed(() => {
return knowledgeBases.value.filter(kb => selectedKbIds.value.includes(kb.id));
});
const selectedFiles = computed(() => {
// If we have file details in fileList, use them.
// Otherwise we might show ID or Loading...
return selectedFileIds.value.map((id: string) => {
const found = fileList.value.find(f => f.id === id);
return found || { id, name: 'Loading...' };
});
});
// 合并所有选中项(用于输入框内显示)
const allSelectedItems = computed(() => {
const kbs = selectedKbs.value.map(kb => ({
...kb,
type: 'kb' as const,
kbType: kb.type // 保留原始类型用于显示区分
}));
const files = selectedFiles.value.map((f: { id: string; name: string }) => ({
...f,
type: 'file' as const
}));
return [...kbs, ...files];
});
// 移除选中项
const removeSelectedItem = (item: { id: string; type: 'kb' | 'file' }) => {
if (item.type === 'kb') {
settingsStore.removeKnowledgeBase(item.id);
} else {
settingsStore.removeFile(item.id);
}
};
// 模型相关状态
const availableModels = ref<ModelConfig[]>([]);
// 使用 computed 从 store 读取,并通过 setter 同步回 store
const selectedModelId = computed({
get: () => settingsStore.conversationModels.selectedChatModelId || '',
set: (val: string) => settingsStore.updateConversationModels({ selectedChatModelId: val })
});
const conversationConfig = ref<ConversationConfig | null>(null);
const modelsLoading = ref(false);
const showModelSelector = ref(false);
const modelButtonRef = ref<HTMLElement>();
const modelDropdownStyle = ref<Record<string, string>>({});
const { t } = useI18n();
// 显示的知识库标签(最多显示2个)
const displayedKbs = computed(() => selectedKbs.value.slice(0, 2));
const remainingCount = computed(() => Math.max(0, selectedKbs.value.length - 2));
// 根据不同状态组合计算输入框的 placeholder
const inputPlaceholder = computed(() => {
const hasKnowledge = allSelectedItems.value.length > 0;
const hasWebSearch = isWebSearchEnabled.value && isWebSearchConfigured.value;
if (hasKnowledge && hasWebSearch) {
// 有知识库 + 有网络搜索
return t('input.placeholderKbAndWeb');
} else if (hasKnowledge) {
// 有知识库 + 无网络搜索
return t('input.placeholderWithContext');
} else if (hasWebSearch) {
// 无知识库 + 有网络搜索
return t('input.placeholderWebOnly');
} else {
// 无知识库 + 无网络搜索(纯模型对话)
return t('input.placeholder');
}
});
// 加载知识库列表
const loadKnowledgeBases = async () => {
try {
const response: any = await listKnowledgeBases();
if (response.data && Array.isArray(response.data)) {
const validKbs = response.data.filter((kb: any) =>
kb.embedding_model_id && kb.embedding_model_id !== '' &&
kb.summary_model_id && kb.summary_model_id !== ''
);
knowledgeBases.value = validKbs;
// 清理无效的知识库ID(已删除或不存在于有效知识库列表中的)
const validKbIds = new Set(validKbs.map((kb: any) => kb.id));
const currentSelectedIds = settingsStore.settings.selectedKnowledgeBases || [];
const validSelectedIds = currentSelectedIds.filter((id: string) => validKbIds.has(id));
// 如果有无效的ID,更新store
if (validSelectedIds.length !== currentSelectedIds.length) {
settingsStore.selectKnowledgeBases(validSelectedIds);
}
}
} catch (error) {
console.error('Failed to load knowledge bases:', error);
}
};
const loadFiles = async () => {
const ids = selectedFileIds.value;
if (ids.length === 0) return;
// Filter out files we already have info for
const missingIds = ids.filter((id: string) => !fileList.value.find(f => f.id === id));
if (missingIds.length === 0) return;
try {
const query = new URLSearchParams();
missingIds.forEach((id: string) => query.append('ids', id));
const res: any = await batchQueryKnowledge(query.toString());
if (res.data) {
const newFiles = res.data.map((f: any) => ({ id: f.id, name: f.title || f.file_name }));
fileList.value = [...fileList.value, ...newFiles];
}
} catch (e) {
console.error("Failed to load files", e);
}
};
watch(selectedFileIds, () => {
loadFiles();
}, { immediate: true });
const loadWebSearchConfig = async () => {
try {
const response: any = await getTenantWebSearchConfig();
const config = response?.data;
const configured = !!(config && config.provider);
isWebSearchConfigured.value = configured;
if (!configured && settingsStore.isWebSearchEnabled) {
settingsStore.toggleWebSearch(false);
}
} catch (error) {
console.error('Failed to load web search config:', error);
isWebSearchConfigured.value = false;
if (settingsStore.isWebSearchEnabled) {
settingsStore.toggleWebSearch(false);
}
}
};
const loadConversationConfig = async () => {
try {
const response = await getConversationConfig();
conversationConfig.value = response.data;
settingsStore.updateConversationModels({
summaryModelId: response.data?.summary_model_id || '',
rerankModelId: response.data?.rerank_model_id || '',
});
if (!selectedModelId.value) {
selectedModelId.value = response.data?.summary_model_id || '';
}
ensureModelSelection();
} catch (error) {
console.error('Failed to load conversation config:', error);
}
};
const loadChatModels = async () => {
if (modelsLoading.value) return;
modelsLoading.value = true;
try {
const models = await listModels('KnowledgeQA');
availableModels.value = Array.isArray(models) ? models : [];
ensureModelSelection();
} catch (error) {
console.error('Failed to load chat models:', error);
availableModels.value = [];
} finally {
modelsLoading.value = false;
}
};
const ensureModelSelection = () => {
if (selectedModelId.value) {
return;
}
if (conversationConfig.value?.summary_model_id) {
selectedModelId.value = conversationConfig.value.summary_model_id;
return;
}
if (availableModels.value.length > 0) {
selectedModelId.value = availableModels.value[0].id || '';
}
};
const handleGoToConversationModels = () => {
showModelSelector.value = false;
router.push('/platform/settings');
setTimeout(() => {
const event = new CustomEvent('settings-nav', {
detail: { section: 'models', subsection: 'chat' },
});
window.dispatchEvent(event);
}, 100);
};
const handleModelChange = async (value: string | number | Array<string | number> | undefined) => {
const normalized = Array.isArray(value) ? value[0] : value;
const val = normalized !== undefined && normalized !== null ? String(normalized) : '';
if (!val) {
selectedModelId.value = '';
return;
}
if (val === '__add_model__') {
selectedModelId.value = conversationConfig.value?.summary_model_id || '';
handleGoToConversationModels();
return;
}
// 保存到后端
try {
if (conversationConfig.value) {
const updatedConfig = {
...conversationConfig.value,
summary_model_id: val
};
const response = await updateConversationConfig(updatedConfig);
// 更新本地状态
conversationConfig.value = response.data;
selectedModelId.value = val;
showModelSelector.value = false;
// 同步到 store
settingsStore.updateConversationModels({
summaryModelId: val,
rerankModelId: conversationConfig.value?.rerank_model_id || '',
});
MessagePlugin.success(t('conversationSettings.toasts.chatModelSaved'));
}
} catch (error) {
console.error('保存模型配置失败:', error);
MessagePlugin.error(t('conversationSettings.toasts.saveFailed'));
// 恢复到之前的值
selectedModelId.value = conversationConfig.value?.summary_model_id || '';
}
};
const selectedModel = computed(() => {
return availableModels.value.find(model => model.id === selectedModelId.value);
});
const updateModelDropdownPosition = () => {
const anchor = modelButtonRef.value;
if (!anchor) {
modelDropdownStyle.value = {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
};
return;
}
// 获取按钮相对于视口的位置
const rect = anchor.getBoundingClientRect();
console.log('[Model Dropdown] Button rect:', {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
right: rect.right,
width: rect.width,
height: rect.height
});
const dropdownWidth = 280;
const offsetY = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
// 左对齐到触发元素的左边缘
// 使用 Math.floor 而不是 Math.round,避免像素对齐问题
let left = Math.floor(rect.left);
// 边界处理:不超出视口左右(留 16px margin)
const minLeft = 16;
const maxLeft = Math.max(16, vw - dropdownWidth - 16);
left = Math.max(minLeft, Math.min(maxLeft, left));
// 垂直定位:紧贴按钮,使用合理的高度避免空白
const preferredDropdownHeight = 280; // 优选高度(紧凑且够用)
const maxDropdownHeight = 360; // 最大高度
const minDropdownHeight = 200; // 最小高度
const topMargin = 20; // 顶部留白
const spaceBelow = vh - rect.bottom; // 下方剩余空间
const spaceAbove = rect.top; // 上方剩余空间
console.log('[Model Dropdown] Space check:', {
spaceBelow,
spaceAbove,
windowHeight: vh
});
let actualHeight: number;
let shouldOpenBelow: boolean;
// 优先考虑下方空间
if (spaceBelow >= minDropdownHeight + offsetY) {
// 下方有足够空间,向下弹出
actualHeight = Math.min(preferredDropdownHeight, spaceBelow - offsetY - 16);
shouldOpenBelow = true;
console.log('[Model Dropdown] Position: below button', { actualHeight });
} else {
// 向上弹出,优先使用 preferredHeight,必要时才扩展到 maxHeight
const availableHeight = spaceAbove - offsetY - topMargin;
if (availableHeight >= preferredDropdownHeight) {
// 有足够空间显示优选高度
actualHeight = preferredDropdownHeight;
} else {
// 空间不够,使用可用空间(但不小于最小高度)
actualHeight = Math.max(minDropdownHeight, availableHeight);
}
shouldOpenBelow = false;
console.log('[Model Dropdown] Position: above button', { actualHeight });
}
// 根据弹出方向使用不同的定位方式
if (shouldOpenBelow) {
// 向下弹出:使用 top 定位,左对齐
const top = Math.floor(rect.bottom + offsetY);
console.log('[Model Dropdown] Opening below, top:', top);
modelDropdownStyle.value = {
position: 'fixed !important',
width: `${dropdownWidth}px`,
left: `${left}px`,
top: `${top}px`,
maxHeight: `${actualHeight}px`,
transform: 'none !important',
margin: '0 !important',
padding: '0 !important'
};
} else {
// 向上弹出:使用 bottom 定位,左对齐
const bottom = vh - rect.top + offsetY;
console.log('[Model Dropdown] Opening above, bottom:', bottom);
modelDropdownStyle.value = {
position: 'fixed !important',
width: `${dropdownWidth}px`,
left: `${left}px`,
bottom: `${bottom}px`,
maxHeight: `${actualHeight}px`,
transform: 'none !important',
margin: '0 !important',
padding: '0 !important'
};
}
console.log('[Model Dropdown] Applied style:', modelDropdownStyle.value);
};
// Mention Logic
let lastMentionQuery = '';
const loadMentionItems = async (q: string, resetIndex = true, append = false) => {
console.log('[Mention] loadMentionItems called with query:', q, 'append:', append);
if (!append) {
mentionOffset.value = 0;
}
// Fetch KBs (always show, filter by query) - only on first load
let kbItems: any[] = [];
if (!append) {
const kbs = knowledgeBases.value.filter(kb =>
!q || kb.name.toLowerCase().includes(q.toLowerCase())
);
kbItems = kbs.map(kb => ({
id: kb.id,
name: kb.name,
type: 'kb' as const,
kbType: kb.type || 'document',
count: kb.type === 'faq' ? (kb.chunk_count || 0) : (kb.knowledge_count || 0)
}));
}
// Fetch Files from API
let fileItems: any[] = [];
mentionLoading.value = true;
try {
const res: any = await searchKnowledge(q || '', mentionOffset.value, MENTION_PAGE_SIZE);
console.log('[Mention] searchKnowledge response:', res);
if (res.data && Array.isArray(res.data)) {
fileItems = res.data.map((f: any) => ({
id: f.id,
name: f.title || f.file_name,
type: 'file' as const,
kbName: f.knowledge_base_name || ''
}));
}
mentionHasMore.value = res.has_more || false;
mentionOffset.value += fileItems.length;
} catch (e) {
console.error('[Mention] searchKnowledge error:', e);
mentionHasMore.value = false;
} finally {
mentionLoading.value = false;
}
if (append) {
// Append file items to existing list
mentionItems.value = [...mentionItems.value, ...fileItems];
} else {
mentionItems.value = [...kbItems, ...fileItems];
}
console.log('[Mention] Total items:', mentionItems.value.length, { kbItems: kbItems.length, fileItems: fileItems.length });
// Only reset index if query changed or explicitly requested
if (resetIndex || q !== lastMentionQuery) {
mentionActiveIndex.value = 0;
}
// Ensure index is within bounds
if (mentionActiveIndex.value >= mentionItems.value.length) {
mentionActiveIndex.value = Math.max(0, mentionItems.value.length - 1);
}
lastMentionQuery = q;
};
const loadMoreMentionItems = () => {
if (mentionHasMore.value && !mentionLoading.value) {
loadMentionItems(lastMentionQuery, false, true);
}
};
const getTextareaEl = () => {
if (!textareaRef.value) return null;
// If it's a native element
if (textareaRef.value instanceof HTMLTextAreaElement) return textareaRef.value;
// If it's a component wrapper
const el = textareaRef.value.$el || textareaRef.value;
if (!el) return null;
if (el.tagName === 'TEXTAREA') return el as HTMLTextAreaElement;
return el.querySelector('textarea');
};
const onInput = (val: string | InputEvent) => {
// 如果正在输入法组合中,不处理搜索逻辑,等待 compositionend
if (isComposing.value) return;
// TDesign t-textarea passes the value directly, not an event
const inputVal = typeof val === 'string' ? val : query.value;
const textarea = getTextareaEl();
if (!textarea) {
console.warn('[Mention] Could not get textarea element');
return;
}
const cursor = textarea.selectionStart;
const textBeforeCursor = inputVal.slice(0, cursor);
console.log('[Mention] onInput called', { inputVal, cursor, textBeforeCursor, showMention: showMention.value });
if (showMention.value) {
// 如果不是按钮触发的,检查 @ 符号
if (!isMentionTriggeredByButton.value) {
if (!inputVal || inputVal.length <= mentionStartPos.value || inputVal.charAt(mentionStartPos.value) !== '@') {
showMention.value = false;
return;
}
}
// 如果是按钮触发的,mentionStartPos 指向的是光标位置(即虚拟的 @ 位置前),所以实际上不应该往左删
// 但如果用户删除了前面的内容导致长度变短,也需要处理
if (cursor < mentionStartPos.value) {
showMention.value = false;
return;
}
// Get query
// 如果是按钮触发,mentionStartPos 是起始位置,不需要 +1 跳过 @
const start = isMentionTriggeredByButton.value ? mentionStartPos.value : mentionStartPos.value + 1;
const q = inputVal.slice(start, cursor);
if (q.includes(' ')) {
showMention.value = false;
return;
}
// Only reload if query changed
if (q !== mentionQuery.value) {
mentionQuery.value = q;
loadMentionItems(q, true); // Reset index when query changes
}
} else {
if (textBeforeCursor.endsWith('@')) {
console.log('[Mention] @ detected, opening menu');
isMentionTriggeredByButton.value = false;
mentionStartPos.value = cursor - 1;
showMention.value = true;
mentionQuery.value = "";
const coords = getCaretCoordinates(textarea, cursor);
const rect = textarea.getBoundingClientRect();
const scrollTop = textarea.scrollTop;
const menuHeight = 320; // 预估最大高度
let left = rect.left + coords.left;
// Prevent menu from going off-screen horizontally
if (left + 300 > window.innerWidth) {
left = window.innerWidth - 300 - 10;
}
// 光标相对于视口的实际 top 位置
const cursorAbsoluteTop = rect.top + coords.top - scrollTop;
const lineHeight = coords.height; // 光标高度
// Check vertical space below cursor
const spaceBelow = window.innerHeight - (cursorAbsoluteTop + lineHeight);
if (spaceBelow < menuHeight && cursorAbsoluteTop > menuHeight) {
// Show above cursor (using bottom positioning)
// bottom distance = viewport height - cursor top position
const bottom = window.innerHeight - cursorAbsoluteTop;
mentionStyle.value = {
left: `${left}px`,
bottom: `${bottom}px`,
top: 'auto'
};
} else {
// Show below cursor (using top positioning)
const top = cursorAbsoluteTop + lineHeight;
mentionStyle.value = {
left: `${left}px`,
top: `${top}px`,
bottom: 'auto'
};
}
loadMentionItems("");
}
}
};
const onCompositionStart = () => {
isComposing.value = true;
};
const onCompositionEnd = (e: CompositionEvent) => {
isComposing.value = false;
// 手动触发 onInput 逻辑
// 注意:在 compositionend 时,v-model 可能还没更新,或者已经更新但我们需要用最新值
// TDesign textarea 可能需要 nextTick
nextTick(() => {
onInput(query.value);
});
};
const triggerMention = () => {
const textarea = getTextareaEl();
if (!textarea) return;
// 关闭其他选择器
showAgentModeSelector.value = false;
showModelSelector.value = false;
textarea.focus();
// 直接显示菜单,不插入 @
showMention.value = true;
isMentionTriggeredByButton.value = true;
mentionQuery.value = "";
mentionStartPos.value = textarea.selectionStart;
const rect = textarea.getBoundingClientRect();
const menuHeight = 320;
// 判断输入框上方空间
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
// 优先显示在上方,除非上方空间不足且下方空间充足
if (spaceAbove > menuHeight || spaceAbove > spaceBelow) {
// Show above textarea
mentionStyle.value = {
left: `${rect.left}px`,
bottom: `${window.innerHeight - rect.top + 8}px`, // 8px padding
top: 'auto'
};
} else {
// Show below textarea
mentionStyle.value = {
left: `${rect.left}px`,
top: `${rect.bottom + 8}px`,
bottom: 'auto'
};
}
loadMentionItems("");
};
const onMentionSelect = (item: any) => {
if (item.type === 'kb') {
settingsStore.addKnowledgeBase(item.id);
} else if (item.type === 'file') {
settingsStore.addFile(item.id);
// Add to local cache immediately
if (!fileList.value.find(f => f.id === item.id)) {
fileList.value.push(item);
}
}
const textarea = getTextareaEl();
if (textarea) {
// 如果是通过输入 @ 触发的,需要删除 @ 和后面的查询文字
if (!isMentionTriggeredByButton.value) {
const cursor = textarea.selectionStart;
const textBeforeAt = query.value.slice(0, mentionStartPos.value);
const textAfterCursor = query.value.slice(cursor);
query.value = textBeforeAt + textAfterCursor;
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = mentionStartPos.value;
textarea.focus();
});
} else {
// 通过按钮触发的,如果用户输入了查询词,需要删除查询词
const cursor = textarea.selectionStart;
if (cursor > mentionStartPos.value) {
const textBeforeStart = query.value.slice(0, mentionStartPos.value);
const textAfterCursor = query.value.slice(cursor);
query.value = textBeforeStart + textAfterCursor;
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = mentionStartPos.value;
textarea.focus();
});
} else {
// 直接聚焦
textarea.focus();
}
}
}
showMention.value = false;
};
const removeFile = (id: string) => {
settingsStore.removeFile(id);
};
const toggleModelSelector = () => {
// 互斥:关闭其他
showMention.value = false;
showAgentModeSelector.value = false;
showModelSelector.value = !showModelSelector.value;
if (showModelSelector.value) {
if (!availableModels.value.length) {
loadChatModels();
}
// 多次更新位置确保准确
nextTick(() => {
updateModelDropdownPosition();
requestAnimationFrame(() => {
updateModelDropdownPosition();
setTimeout(() => {
updateModelDropdownPosition();
}, 50);
});
});
}
};
const closeModelSelector = () => {
showModelSelector.value = false;
};
// 关闭 Agent 模式选择器(点击外部)
const closeAgentModeSelector = () => {
showAgentModeSelector.value = false;
};
const closeMentionSelector = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 如果点击的是输入框区域,不关闭 Mention 列表(由光标逻辑控制)
if (target.closest('.rich-input-container')) {
return;
}
showMention.value = false;
};
// 窗口事件处理器
let resizeHandler: (() => void) | null = null;
let scrollHandler: (() => void) | null = null;
onMounted(() => {
loadKnowledgeBases();
loadWebSearchConfig();
loadConversationConfig();
loadChatModels();
// 如果从知识库内部进入,自动选中该知识库
const kbId = (route.params as any)?.kbId as string;
if (kbId && !selectedKbIds.value.includes(kbId)) {
settingsStore.addKnowledgeBase(kbId);
}
// 监听点击外部关闭下拉菜单
document.addEventListener('click', closeAgentModeSelector);
document.addEventListener('click', closeModelSelector);
document.addEventListener('click', closeMentionSelector);
// 监听窗口大小变化和滚动,重新计算位置
resizeHandler = () => {
if (showModelSelector.value) {
updateModelDropdownPosition();
}
if (showAgentModeSelector.value) {
updateAgentModeDropdownPosition();
}
};
scrollHandler = () => {
if (showModelSelector.value) {
updateModelDropdownPosition();
}
if (showAgentModeSelector.value) {
updateAgentModeDropdownPosition();
}
};
window.addEventListener('resize', resizeHandler, { passive: true });
window.addEventListener('scroll', scrollHandler, { passive: true, capture: true });
});
onUnmounted(() => {
document.removeEventListener('click', closeAgentModeSelector);
document.removeEventListener('click', closeModelSelector);
document.removeEventListener('click', closeMentionSelector);
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
}
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler, { capture: true });
}
});
// 监听路由变化
watch(() => route.params.kbId, (newKbId) => {
if (newKbId && typeof newKbId === 'string' && !selectedKbIds.value.includes(newKbId)) {
settingsStore.addKnowledgeBase(newKbId);
}
});
watch(() => uiStore.showSettingsModal, (visible, prevVisible) => {
if (prevVisible && !visible) {
loadWebSearchConfig();
}
});
watch([selectedKbIds, selectedFileIds], ([kbIds, fileIds]) => {
if (!kbIds.length && !fileIds.length) {
closeModelSelector();
}
}, { deep: true });
const emit = defineEmits(['send-msg', 'stop-generation']);
const createSession = (val: string) => {
if (!val.trim()) {
MessagePlugin.info(t('input.messages.enterContent'));
return;
}
if (props.isReplying) {
return MessagePlugin.error(t('input.messages.replying'));
}
// 获取@提及的知识库和文件信息
const mentionedItems = allSelectedItems.value.map(item => ({
id: item.id,
name: item.name,
type: item.type,
kb_type: item.type === 'kb' ? (item.kbType || 'document') : undefined
}));
emit('send-msg', val, selectedModelId.value, mentionedItems);
clearvalue();
}
const updateAgentModeDropdownPosition = () => {
const anchor = agentModeButtonRef.value;
if (!anchor) {
agentModeDropdownStyle.value = {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
};
return;
}
const rect = anchor.getBoundingClientRect();
const dropdownWidth = 200;
const offsetY = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
// 水平位置:左对齐
let left = Math.floor(rect.left);
const minLeft = 16;
const maxLeft = Math.max(16, vw - dropdownWidth - 16);
left = Math.max(minLeft, Math.min(maxLeft, left));
// 垂直位置:紧贴按钮,使用合理的高度避免空白
const preferredDropdownHeight = 140; // Agent 模式选择器内容较少,用更小的优选高度
const maxDropdownHeight = 150;
const minDropdownHeight = 100;
const topMargin = 20;
const spaceBelow = vh - rect.bottom;
const spaceAbove = rect.top;
console.log('[Agent Dropdown] Space check:', {
spaceBelow,
spaceAbove,
windowHeight: vh
});
let actualHeight: number;
// 优先考虑下方空间
if (spaceBelow >= minDropdownHeight + offsetY) {
// 下方有足够空间,向下弹出
actualHeight = Math.min(preferredDropdownHeight, spaceBelow - offsetY - 16);
const top = Math.floor(rect.bottom + offsetY);
agentModeDropdownStyle.value = {
position: 'fixed !important',
width: `${dropdownWidth}px`,
left: `${left}px`,
top: `${top}px`,
maxHeight: `${actualHeight}px`,
transform: 'none !important',
margin: '0 !important',
padding: '0 !important',
};
console.log('[Agent Dropdown] Position: below button', { actualHeight });
} else {
// 向上弹出,使用 bottom 定位确保紧贴按钮
const availableHeight = spaceAbove - offsetY - topMargin;
if (availableHeight >= preferredDropdownHeight) {
actualHeight = preferredDropdownHeight;
} else {
actualHeight = Math.max(minDropdownHeight, availableHeight);
}
const bottom = vh - rect.top + offsetY;
agentModeDropdownStyle.value = {
position: 'fixed !important',
width: `${dropdownWidth}px`,
left: `${left}px`,
bottom: `${bottom}px`, // 使用 bottom 定位,确保紧贴按钮
maxHeight: `${actualHeight}px`,
transform: 'none !important',
margin: '0 !important',
padding: '0 !important',
};
console.log('[Agent Dropdown] Position: above button', { actualHeight, bottom });
}
};
const toggleAgentModeSelector = () => {
// 如果 Agent 未就绪,显示提示
if (!settingsStore.isAgentReady && !isAgentEnabled.value) {
toggleAgentMode();
return;
}
// 互斥
showMention.value = false;
showModelSelector.value = false;
showAgentModeSelector.value = !showAgentModeSelector.value;
if (showAgentModeSelector.value) {
// 多次更新位置确保准确
nextTick(() => {
updateAgentModeDropdownPosition();
requestAnimationFrame(() => {
updateAgentModeDropdownPosition();
setTimeout(() => {
updateAgentModeDropdownPosition();
}, 50);
});
});
}
}
const selectAgentMode = (mode: 'normal' | 'agent') => {
if (mode === 'agent' && !settingsStore.isAgentReady) {
toggleAgentMode();
showAgentModeSelector.value = false;
return;
}
const shouldEnableAgent = mode === 'agent';
if (shouldEnableAgent !== isAgentEnabled.value) {
settingsStore.toggleAgent(shouldEnableAgent);
MessagePlugin.success(shouldEnableAgent ? t('input.messages.agentSwitchedOn') : t('input.messages.agentSwitchedOff'));
}
showAgentModeSelector.value = false;
}
const clearvalue = () => {
query.value = "";
}
const onKeydown = (val: string, event: { e: { preventDefault(): unknown; keyCode: number; shiftKey: any; ctrlKey: any; }; }) => {
if (showMention.value) {
if (event.e.keyCode === 38) { // Up
event.e.preventDefault();
mentionActiveIndex.value = Math.max(0, mentionActiveIndex.value - 1);
return;
}
if (event.e.keyCode === 40) { // Down
event.e.preventDefault();
mentionActiveIndex.value = Math.min(mentionItems.value.length - 1, mentionActiveIndex.value + 1);
return;
}
if (event.e.keyCode === 13) { // Enter
event.e.preventDefault();
if (mentionItems.value[mentionActiveIndex.value]) {
onMentionSelect(mentionItems.value[mentionActiveIndex.value]);
}
return;
}
if (event.e.keyCode === 27) { // Esc
showMention.value = false;
return;
}
}
// 退格键:当输入框为空且有选中项时,删除最后一个选中项
if (event.e.keyCode === 8) { // Backspace
const textarea = getTextareaEl();
if (textarea && textarea.selectionStart === 0 && textarea.selectionEnd === 0 && query.value === '') {
const items = allSelectedItems.value;
if (items.length > 0) {
event.e.preventDefault();
const lastItem = items[items.length - 1];
removeSelectedItem(lastItem);
return;
}
}
}
if ((event.e.keyCode == 13 && event.e.shiftKey) || (event.e.keyCode == 13 && event.e.ctrlKey)) {
return;
}
if (event.e.keyCode == 13) {
event.e.preventDefault();
createSession(val)
}
}
const handleGoToWebSearchSettings = () => {
uiStore.openSettings('websearch');
if (route.path !== '/platform/settings') {
router.push('/platform/settings');
}
};
const handleGoToAgentSettings = () => {
// 使用 uiStore 打开设置并跳转到 agent 部分
uiStore.openSettings('agent');
// 如果当前不在设置页面,导航到设置页面
if (route.path !== '/platform/settings') {
router.push('/platform/settings');
}
}
// 获取 Agent 不就绪的原因
const getAgentNotReadyReasons = (): string[] => {
const reasons: string[] = []
const config = settingsStore.agentConfig || { allowedTools: [] }
const models = settingsStore.conversationModels || { summaryModelId: '', rerankModelId: '' }
if (!config.allowedTools || config.allowedTools.length === 0) {
reasons.push(t('input.agentMissingAllowedTools'))
}
if (!models.summaryModelId || models.summaryModelId.trim() === '') {
reasons.push(t('input.agentMissingSummaryModel'))
}
if (!models.rerankModelId || models.rerankModelId.trim() === '') {
reasons.push(t('input.agentMissingRerankModel'))
}
return reasons
}
const toggleAgentMode = () => {
// 如果要启用 Agent,先检查是否就绪
// 注意:isAgentReady 是从 store 中计算的,需要确保 store 中的配置是最新的
if (!isAgentEnabled.value) {
// 尝试启用 Agent,先检查是否就绪
const agentReady = settingsStore.isAgentReady
if (!agentReady) {
const reasons = getAgentNotReadyReasons()
const reasonsText = reasons.join('、')
// 创建带跳转链接的自定义消息
const messageContent = h('div', { style: 'display: flex; flex-direction: column; gap: 8px; max-width: 320px;' }, [
h('span', { style: 'color: #333; line-height: 1.5;' }, t('input.messages.agentNotReadyDetail', { reasons: reasonsText })),
h('a', {
href: '#',
onClick: (e: Event) => {
e.preventDefault();
handleGoToAgentSettings();
},
style: 'color: #07C05F; text-decoration: none; font-weight: 500; cursor: pointer; align-self: flex-start;',
onMouseenter: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'underline';
},
onMouseleave: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'none';
}
}, t('input.goToSettings'))
]);
MessagePlugin.warning({
content: () => messageContent,
duration: 5000
});
return
}
}
// 正常切换 Agent 状态
settingsStore.toggleAgent(!isAgentEnabled.value);
const message = isAgentEnabled.value ? t('input.messages.agentEnabled') : t('input.messages.agentDisabled');
MessagePlugin.success(message);
}
const toggleWebSearch = () => {
// 互斥:虽然不是弹出层,但操作时关闭其他弹出层体验更好
showMention.value = false;
showModelSelector.value = false;
showAgentModeSelector.value = false;
if (!isWebSearchConfigured.value) {
const messageContent = h('div', { style: 'display: flex; flex-direction: column; gap: 6px; max-width: 280px;' }, [
h('span', { style: 'color: #333; line-height: 1.5;' }, t('input.messages.webSearchNotConfigured')),
h('a', {
href: '#',
onClick: (e: Event) => {
e.preventDefault();
handleGoToWebSearchSettings();
},
style: 'color: #07C05F; text-decoration: none; font-weight: 500; cursor: pointer; align-self: flex-start;',
onMouseenter: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'underline';
},
onMouseleave: (e: Event) => {
(e.target as HTMLElement).style.textDecoration = 'none';
}
}, t('input.goToSettings'))
]);
MessagePlugin.warning({
content: () => messageContent,
duration: 5000
});
return;
}
const currentValue = settingsStore.isWebSearchEnabled;
const newValue = !currentValue;
settingsStore.toggleWebSearch(newValue);
MessagePlugin.success(newValue ? t('input.messages.webSearchEnabled') : t('input.messages.webSearchDisabled'));
};
const toggleKbSelector = () => {
showKbSelector.value = !showKbSelector.value;
}
const removeKb = (kbId: string) => {
settingsStore.removeKnowledgeBase(kbId);
}
const handleStop = async () => {
if (!props.sessionId) {
MessagePlugin.warning(t('input.messages.sessionMissing'));
return;
}
if (!props.assistantMessageId) {
console.error('[Stop] Assistant message ID is empty');
MessagePlugin.warning(t('input.messages.messageMissing'));
return;
}
console.log('[Stop] Stopping generation for message:', props.assistantMessageId);
// 发送 stop 事件,通知父组件立即清除 loading 状态
emit('stop-generation');
try {
await stopSession(props.sessionId, props.assistantMessageId);
MessagePlugin.success(t('input.messages.stopSuccess'));
} catch (error) {
console.error('Failed to stop session:', error);
MessagePlugin.error(t('input.messages.stopFailed'));
}
}
onBeforeRouteUpdate((to, from, next) => {
clearvalue()
next()
})
</script>
<template>
<div class="answers-input">
<!-- 富文本输入框容器 -->
<div class="rich-input-container">
<!-- 选中的知识库和文件标签(显示在输入框内顶部) -->
<div v-if="allSelectedItems.length > 0" class="selected-tags-inline">
<span
v-for="item in allSelectedItems"
:key="item.id"
class="inline-tag"
:class="[
item.type === 'kb' ? (item.kbType === 'faq' ? 'faq-tag' : 'kb-tag') : 'file-tag'
]"
>
<span class="tag-icon">
<t-icon v-if="item.type === 'kb'" :name="item.kbType === 'faq' ? 'chat-bubble-help' : 'folder'" />
<t-icon v-else name="file" />
</span>
<span class="tag-name">{{ item.name }}</span>
<span class="tag-remove" @click="removeSelectedItem(item)">×</span>
</span>
</div>
<!-- 实际输入框 -->
<t-textarea
ref="textareaRef"
v-model="query"
:placeholder="inputPlaceholder"
name="description"
:autosize="true"
@keydown="onKeydown"
@input="onInput"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
/>
</div>
<!-- Mention Selector -->
<Teleport to="body">
<MentionSelector
:visible="showMention"
:style="mentionStyle"
:items="mentionItems"
:hasMore="mentionHasMore"
:loading="mentionLoading"
v-model:activeIndex="mentionActiveIndex"
@select="onMentionSelect"
@loadMore="loadMoreMentionItems"
/>
</Teleport>
<!-- 控制栏 -->
<div class="control-bar">
<!-- 左侧控制按钮 -->
<div class="control-left">
<!-- Agent 模式切换按钮 -->
<div
ref="agentModeButtonRef"
class="control-btn agent-mode-btn"
:class="{
'active': isAgentEnabled,
'agent-active': isAgentEnabled
}"
@click.stop="toggleAgentModeSelector"
>
<img
v-if="isAgentEnabled"
:src="getImgSrc('agent-active.svg')"
:alt="$t('input.agentMode')"
class="control-icon agent-icon"
/>
<svg
v-else
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="control-icon normal-mode-icon"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span class="agent-mode-text">
{{ isAgentEnabled ? $t('input.agentMode') : $t('input.normalMode') }}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="currentColor"
class="dropdown-arrow"
:class="{ 'rotate': showAgentModeSelector }"
>
<path d="M2.5 4.5L6 8L9.5 4.5H2.5Z"/>
</svg>
</div>
<!-- Agent 模式选择下拉菜单 -->
<Teleport to="body">
<div v-if="showAgentModeSelector" class="agent-mode-selector-overlay" @click="closeAgentModeSelector">
<div
class="agent-mode-selector-dropdown"
:style="agentModeDropdownStyle"
@click.stop
>
<div
class="agent-mode-option"
:class="{ 'selected': !isAgentEnabled }"
@click="selectAgentMode('normal')"
>
<div class="agent-mode-option-main">
<span class="agent-mode-option-name">{{ $t('input.normalMode') }}</span>
<span class="agent-mode-option-desc">{{ $t('input.normalModeDesc') }}</span>
</div>
<svg
v-if="!isAgentEnabled"
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
class="check-icon"
>
<path d="M13.5 4.5L6 12L2.5 8.5L3.5 7.5L6 10L12.5 3.5L13.5 4.5Z"/>
</svg>
</div>
<div
class="agent-mode-option"
:class="{
'selected': isAgentEnabled,
'disabled': !settingsStore.isAgentReady && !isAgentEnabled
}"
@click="selectAgentMode('agent')"
>
<div class="agent-mode-option-main">
<span class="agent-mode-option-name">{{ $t('input.agentMode') }}</span>
<span class="agent-mode-option-desc">{{ $t('input.agentModeDesc') }}</span>
</div>
<svg
v-if="isAgentEnabled"
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
class="check-icon"
>
<path d="M13.5 4.5L6 12L2.5 8.5L3.5 7.5L6 10L12.5 3.5L13.5 4.5Z"/>
</svg>
<div v-if="!settingsStore.isAgentReady && !isAgentEnabled" class="agent-mode-warning">
<t-tooltip :content="$t('input.agentNotReadyTooltip')" placement="left">
<t-icon name="error-circle" class="warning-icon" />
</t-tooltip>
</div>
</div>
<div v-if="!settingsStore.isAgentReady && !isAgentEnabled" class="agent-mode-footer">
<a
href="#"
@click.prevent="handleGoToAgentSettings"
class="agent-mode-link"
>
{{ $t('input.goToSettings') }}
</a>
</div>
</div>
</div>
</Teleport>
<!-- WebSearch 开关按钮 -->
<t-tooltip placement="top">
<template #content>
<span v-if="isWebSearchConfigured">{{ isWebSearchEnabled ? $t('input.webSearch.toggleOff') : $t('input.webSearch.toggleOn') }}</span>
<div v-else class="websearch-tooltip-disabled">
<span>{{ $t('input.webSearch.notConfigured') }}</span>
<a href="#" @click.prevent="handleGoToWebSearchSettings">{{ $t('input.goToSettings') }}</a>
</div>
</template>
<div
class="control-btn websearch-btn"
:class="{ 'active': isWebSearchEnabled && isWebSearchConfigured, 'disabled': !isWebSearchConfigured }"
@click.stop="toggleWebSearch"
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="control-icon websearch-icon"
:class="{ 'active': isWebSearchEnabled && isWebSearchConfigured }"
>
<circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 0 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 1 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<line x1="2.94" y1="5.5" x2="15.06" y2="5.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<line x1="2.94" y1="12.5" x2="15.06" y2="12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</div>
</t-tooltip>
<!-- @ 知识库/文件选择按钮 -->
<t-tooltip :content="allSelectedItems.length > 0 ? $t('input.knowledgeBaseWithCount', { count: allSelectedItems.length }) : $t('input.knowledgeBase')">
<div
ref="atButtonRef"
class="control-btn kb-btn"
:class="{ 'active': allSelectedItems.length > 0 }"
@click.stop
@mousedown.prevent="triggerMention"
>
<img :src="getImgSrc('at-icon.svg')" alt="@" class="control-icon" />
<span v-if="allSelectedItems.length > 0" class="kb-count">{{ allSelectedItems.length }}</span>
</div>
</t-tooltip>
<!-- 模型显示 -->
<div class="model-display">
<div
ref="modelButtonRef"
class="model-selector-trigger"
@click.stop="toggleModelSelector"
>
<span class="model-selector-name">
{{ selectedModel?.name || $t('input.notConfigured') }}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="currentColor"
class="model-dropdown-arrow"
:class="{ 'rotate': showModelSelector }"
>
<path d="M2.5 4.5L6 8L9.5 4.5H2.5Z"/>
</svg>
</div>
</div>
</div>
<Teleport to="body">
<div v-if="showModelSelector" class="model-selector-overlay" @click="closeModelSelector">
<div class="model-selector-dropdown" :style="modelDropdownStyle" @click.stop>
<div class="model-selector-header">
<span>{{ $t('conversationSettings.models.chatGroupLabel') }}</span>
<button class="model-selector-add" type="button" @click="handleModelChange('__add_model__')">
<span class="add-icon">+</span>
<span class="add-text">{{ $t('input.addModel') }}</span>
</button>
</div>
<div class="model-selector-content">
<div
v-for="model in availableModels"
:key="model.id"
class="model-option"
:class="{ selected: model.id === selectedModelId }"
@click="handleModelChange(model.id || '')"
>
<div class="model-option-main">
<span class="model-option-name">{{ model.name }}</span>
<span v-if="model.source === 'remote'" class="model-badge-remote">{{ $t('input.remote') }}</span>
<span v-else-if="model.parameters?.parameter_size" class="model-badge-local">
{{ model.parameters.parameter_size }}
</span>
</div>
<div v-if="model.description" class="model-option-desc">
{{ model.description }}
</div>
</div>
<div v-if="availableModels.length === 0" class="model-option empty">
{{ $t('input.noModel') }}
</div>
</div>
</div>
</div>
</Teleport>
<!-- 右侧控制按钮组 -->
<div class="control-right">
<!-- 停止按钮(仅在回复中时显示) -->
<t-tooltip
v-if="isReplying"
:content="$t('input.stopGeneration')"
placement="top"
>
<div
@click="handleStop"
class="control-btn stop-btn"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="5" y="5" width="6" height="6" rx="1" />
</svg>
</div>
</t-tooltip>
<!-- 发送按钮 -->
<div
v-if="!isReplying"
@click="createSession(query)"
class="control-btn send-btn"
:class="{ 'disabled': !query.length }"
>
<img src="../assets/img/sending-aircraft.svg" :alt="$t('input.send')" />
</div>
</div>
</div>
<!-- 知识库选择下拉(使用 Teleport 传送到 body,避免父容器定位影响) -->
<Teleport to="body">
<KnowledgeBaseSelector
v-model:visible="showKbSelector"
:anchorEl="atButtonRef"
@close="showKbSelector = false"
/>
</Teleport>
</div>
</template>
<script lang="ts">
const getImgSrc = (url: string) => {
return new URL(`/src/assets/img/${url}`, import.meta.url).href;
}
</script>
<style scoped lang="less">
.answers-input {
position: absolute;
z-index: 99;
bottom: 60px;
left: 50%;
transform: translateX(-400px);
}
/* 富文本输入框容器 */
.rich-input-container {
position: relative;
width: 800px;
background: var(--td-bg-color-container, #FFF);
border-radius: 12px;
border: 1px solid var(--td-component-border, #E7E7E7);
box-shadow: 0 6px 6px 0 rgba(0, 0, 0, 0.04), 0 12px 12px -1px rgba(0, 0, 0, 0.08);
&:focus-within {
border-color: var(--td-brand-color, #07C05F);
}
}
/* 选中的标签(输入框内顶部) */
.selected-tags-inline {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 16px 8px;
border-bottom: 1px solid var(--td-component-border, #f0f0f0);
}
.inline-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: default;
transition: all 0.15s;
background: var(--td-bg-color-secondarycontainer, #f3f3f3);
border: 1px solid transparent;
color: var(--td-text-color-primary, #333);
/* KB - Document (Greenish tint) */
&.kb-tag {
background: rgba(16, 185, 129, 0.08);
color: #059669;
.tag-icon {
color: #10b981;
}
}
/* KB - FAQ (Blueish tint) */
&.faq-tag {
background: rgba(0, 82, 217, 0.08);
color: #0052d9;
.tag-icon {
color: #0052d9;
}
}
/* File (Orange tint) */
&.file-tag {
background: rgba(237, 123, 47, 0.08);
color: #e65100;
.tag-icon {
color: #ed7b2f;
}
}
.tag-icon {
font-size: 14px;
display: flex;
align-items: center;
}
.tag-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: currentColor;
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-left: 2px;
border-radius: 50%;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s, background 0.15s;
color: currentColor;
&:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
}
}
:deep(.t-textarea__inner) {
width: 100%;
max-height: 200px !important;
min-height: 120px !important;
resize: none;
color: var(--td-text-color-primary, #000000e6);
font-size: 16px;
font-weight: 400;
line-height: 24px;
font-family: var(--td-font-family, "PingFang SC");
padding: 12px 16px 56px 16px;
border-radius: 0 0 12px 12px;
border: none;
box-sizing: border-box;
background: transparent;
box-shadow: none;
&:focus {
border: none;
box-shadow: none;
}
&::placeholder {
color: var(--td-text-color-placeholder, #00000066);
font-family: var(--td-font-family, "PingFang SC");
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
}
/* 当没有选中标签时,textarea 样式 */
.rich-input-container:not(:has(.selected-tags-inline)) :deep(.t-textarea__inner) {
border-radius: 12px;
padding-top: 16px;
}
/* 控制栏 */
.control-bar {
position: absolute;
bottom: 12px;
left: 16px;
right: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
max-height: 56px;
z-index: 10;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, var(--td-bg-color-container, #fff) 40%, var(--td-bg-color-container, #fff) 100%);
pointer-events: auto;
padding-top: 8px;
}
.control-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
min-width: 0;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border-radius: 6px;
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
cursor: pointer;
transition: background 0.12s;
user-select: none;
flex-shrink: 0;
&:hover {
background: var(--td-bg-color-secondarycontainer-hover, #e6e6e6);
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
}
}
}
.agent-mode-btn {
height: 28px;
padding: 0 10px;
min-width: auto;
font-weight: 500;
border: 1px solid transparent;
transition: background 0.12s, border-color 0.12s;
position: relative;
&.active,
&.agent-active {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.1) 100%);
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.12);
.agent-mode-text {
color: #07C05F;
font-weight: 600;
}
.agent-icon {
filter: brightness(0) saturate(100%) invert(58%) sepia(87%) saturate(1234%) hue-rotate(95deg) brightness(98%) contrast(89%);
}
.dropdown-arrow {
color: #07C05F;
}
&:hover {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.15) 100%);
border-color: rgba(16, 185, 129, 0.6);
}
}
&:not(.agent-active) {
background: var(--td-bg-color-container, #fff);
border-color: var(--td-component-border, #e0e0e0);
.agent-mode-text {
color: var(--td-text-color-secondary, #666);
}
.normal-mode-icon {
color: var(--td-text-color-secondary, #666);
}
&:hover {
background: var(--td-bg-color-container-hover, #fff);
border-color: var(--td-component-stroke, #b0b0b0);
}
}
}
.agent-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.agent-mode-text {
font-size: 13px;
color: var(--td-text-color-secondary, #666);
font-weight: 500;
white-space: nowrap;
margin: 0 4px;
}
.control-icon {
width: 18px;
height: 18px;
}
.kb-btn {
height: 28px;
padding: 0 10px;
min-width: auto;
position: relative;
&.active {
background: rgba(16, 185, 129, 0.1);
color: #07C05F;
&:hover {
background: rgba(16, 185, 129, 0.15);
}
}
}
.kb-count {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: #07C05F;
color: white;
font-size: 10px;
font-weight: 600;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.kb-btn-text {
font-size: 13px;
color: var(--td-text-color-secondary, #666);
font-weight: 500;
white-space: nowrap;
}
.kb-btn.active .kb-btn-text {
color: #07C05F;
}
.websearch-btn {
width: 28px;
height: 28px;
padding: 0;
min-width: auto;
display: flex;
align-items: center;
justify-content: center;
&.active {
background: rgba(16, 185, 129, 0.1);
.websearch-icon {
color: #07C05F;
}
&:hover {
background: rgba(16, 185, 129, 0.15);
}
}
&:not(.active) {
.websearch-icon {
color: var(--td-text-color-secondary, #666);
}
&:hover {
background: var(--td-bg-color-secondarycontainer-hover, #f0f0f0);
.websearch-icon {
color: var(--td-text-color-primary, #333);
}
}
}
}
:global(.websearch-tooltip-disabled) {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 220px;
font-size: 12px;
color: var(--td-text-color-secondary, #666);
}
:global(.websearch-tooltip-disabled a) {
color: #07C05F;
font-weight: 500;
text-decoration: none;
}
:global(.websearch-tooltip-disabled a:hover) {
text-decoration: underline;
}
.websearch-icon {
width: 18px;
height: 18px;
}
.dropdown-arrow {
width: 10px;
height: 10px;
margin-left: 2px;
transition: transform 0.12s;
&.rotate {
transform: rotate(180deg);
}
}
.control-right {
display: flex;
align-items: center;
gap: 8px;
}
.stop-btn {
width: 28px;
height: 28px;
padding: 0;
background: rgba(16, 185, 129, 0.08);
color: #07C05F;
border: 1.5px solid rgba(16, 185, 129, 0.2);
position: relative;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(16, 185, 129, 0.12);
border-color: #07C05F;
}
&:active {
background: rgba(16, 185, 129, 0.15);
}
svg {
display: none;
}
&::before {
content: '';
width: 12px;
height: 12px;
background: #07C05F;
border-radius: 50%;
display: block;
}
}
.send-btn {
width: 28px;
height: 28px;
padding: 0;
background-color: #07C05F;
&:hover:not(.disabled) {
background-color: #059669;
}
&.disabled {
background-color: #b5eccf;
}
img {
width: 16px;
height: 16px;
}
}
/* 模型显示样式 */
.model-display {
display: flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}
.model-selector-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
min-width: 100px;
height: 22px;
border-radius: 6px;
border: 1px solid rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.1);
transition: background 0.12s, border-color 0.12s;
cursor: pointer;
}
.model-selector-trigger:hover {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.45);
}
.model-selector-trigger.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.model-selector-trigger.disabled:hover {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
}
.model-selector-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #07C05F;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-dropdown-arrow {
width: 10px;
height: 10px;
color: #07C05F;
flex-shrink: 0;
transition: transform 0.12s;
&.rotate {
transform: rotate(180deg);
}
}
.model-selector-trigger.disabled .model-dropdown-arrow {
color: rgba(16, 185, 129, 0.4);
}
.model-selector-overlay {
position: fixed;
inset: 0;
z-index: 9998;
background: transparent;
touch-action: none;
}
.model-selector-dropdown {
position: fixed !important;
z-index: 9999;
background: var(--td-bg-color-container, #fff);
border-radius: 10px;
box-shadow: var(--td-shadow-2, 0 6px 28px rgba(15, 23, 42, 0.08));
border: 1px solid var(--td-component-border, #e7e9eb);
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 !important;
padding: 0 !important;
transform: none !important;
}
.model-selector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-bottom: 1px solid var(--td-component-border, #f2f4f5);
background: var(--td-bg-color-secondarycontainer, #fafcfc);
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-primary, #222);
}
.model-selector-content {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 6px 8px;
}
.model-selector-add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid var(--td-component-border, #e1e5e6);
background: var(--td-bg-color-container, #fff);
color: var(--td-text-color-secondary, #52575a);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.12s;
}
.model-selector-add .add-icon {
font-size: 12px;
line-height: 1;
}
.model-selector-add:hover {
border-color: #10b981;
color: #10b981;
background: #f0fdf6;
}
.model-option {
padding: 6px 8px;
cursor: pointer;
transition: background 0.12s;
border-radius: 6px;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: var(--td-bg-color-container-hover, #f6f8f7);
}
&.selected {
background: var(--td-brand-color-light, #eefdf5);
.model-option-name {
color: #10b981;
font-weight: 600;
}
}
&.empty {
color: var(--td-text-color-disabled, #9aa0a6);
cursor: default;
text-align: center;
padding: 20px 8px;
&:hover {
background: transparent;
}
}
}
.model-option-main {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 1px;
}
.model-option-name {
font-size: 12px;
color: var(--td-text-color-primary, #222);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.model-option-desc {
font-size: 11px;
color: var(--td-text-color-secondary, #8b9196);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.model-badge-remote,
.model-badge-local {
display: inline-block;
padding: 1px 5px;
font-size: 10px;
border-radius: 3px;
font-weight: 500;
flex-shrink: 0;
}
.model-badge-remote {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.model-badge-local {
background: rgba(139, 145, 150, 0.1);
color: #52575a;
}
/* Agent 模式选择下拉菜单 */
.agent-mode-selector-overlay {
position: fixed;
inset: 0;
z-index: 9998;
background: transparent;
touch-action: none;
}
.agent-mode-selector-dropdown {
position: fixed !important;
z-index: 9999;
background: var(--td-bg-color-container, #fff);
border-radius: 10px;
box-shadow: var(--td-shadow-2, 0 6px 28px rgba(15, 23, 42, 0.08));
border: 1px solid var(--td-component-border, #e7e9eb);
overflow: hidden;
padding: 6px 8px;
min-width: 200px;
display: flex;
flex-direction: column;
margin: 0 !important;
padding: 0 !important;
transform: none !important;
}
.agent-mode-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: pointer;
transition: background 0.12s;
border-radius: 6px;
position: relative;
margin: 4px 6px;
&:hover:not(.disabled) {
background: var(--td-bg-color-container-hover, #f6f8f7);
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
background: transparent;
}
}
&.selected {
background: var(--td-brand-color-light, #eefdf5);
.agent-mode-option-name {
color: #10b981;
font-weight: 700;
}
}
}
.agent-mode-option-main {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
min-width: 0;
}
.agent-mode-option-name {
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-primary, #222);
line-height: 1.4;
transition: color 0.12s;
}
.agent-mode-option-desc {
font-size: 11px;
color: var(--td-text-color-secondary, #8b9196);
line-height: 1.3;
}
.check-icon {
width: 14px;
height: 14px;
color: #10b981;
flex-shrink: 0;
margin-left: 6px;
}
.agent-mode-warning {
display: flex;
align-items: center;
margin-left: 6px;
.warning-icon {
color: #ff9800;
font-size: 14px;
}
}
.agent-mode-footer {
padding: 6px 10px;
border-top: 1px solid var(--td-component-border, #f2f4f5);
margin-top: 2px;
background: var(--td-bg-color-secondarycontainer, #fafcfc);
}
.agent-mode-link {
color: #10b981;
text-decoration: none;
font-size: 11px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 3px;
transition: all 0.12s;
&:hover {
color: #059669;
text-decoration: underline;
}
}
</style>