Files
Enterprise-Knowledge-Base/frontend/src/views/agent/AgentEditorModal.vue
T
wizardchen f7a264ca93 fix(rbac): hide UI mutation surfaces for Viewer / non-creator
Several mutation entry points stayed visible to users who lacked the
backend-required role, so Viewers (and non-creator Contributors) hit
403 on click instead of seeing the disabled state. Surgical fixes,
all matching the existing backend matrix:

  - KnowledgeBaseList: gate the entire 3-dot popup (both the 'all'
    and 'mine' tabs) behind canManageKBCard. Pin/unpin used to be
    unguarded; settings/delete were per-item gated but the trigger
    still showed. Now non-managers don't see the popup at all.

  - AgentList: the popup trigger now requires at least one menu item
    to be visible (edit OR copy OR disable OR delete), via a compound
    v-if on the popup itself. Disable/enable on own custom agents
    and shared agents both gain a hasRole('admin') guard to match
    POST /shared-agents/disabled, which writes a tenant-level
    preference and is Admin-gated.

  - AgentEditorModal: new readOnly prop hides the '确定' (save)
    button and replaces 'Cancel' with 'Close'. AgentList passes
    readOnly when the user can't manage the agent (built-in agents
    and others' custom agents for Viewers). Form-input disabling is
    intentionally NOT wired here yet — the modal has 3000+ lines of
    inputs and the only mutation surface that 403s is the save
    button. Hiding it removes the broken path without rewiring the
    whole form.

  - doc-content: generated-question delete button gated by a new
    canEditKB prop driven by KnowledgeBase.vue's existing canEdit
    computed (KB creator OR Admin OR org-share-edit). Defaults to
    Admin-only when the prop is missing — fail-closed since the
    backend matrix is now stricter (see follow-up commit).

The server still enforces the same matrix on every mutation; these
gates only stop the user from staring at a 'failed' toast.
2026-05-18 17:28:58 +08:00

4807 lines
188 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.
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="settings-overlay" @click.self="handleClose">
<div class="settings-modal">
<!-- 关闭按钮 -->
<button class="close-btn" @click="handleClose" :aria-label="$t('common.close')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="settings-container">
<!-- 左侧导航 -->
<div class="settings-sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">{{ mode === 'create' ? $t('agent.editor.createTitle') : $t('agent.editor.editTitle') }}</h2>
</div>
<div class="settings-nav">
<div
v-for="(item, index) in navItems"
:key="index"
:class="['nav-item', { 'active': currentSection === item.key }]"
@click="currentSection = item.key"
>
<t-icon :name="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.label }}</span>
</div>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="settings-content">
<div class="content-wrapper">
<!-- 基础设置 -->
<div v-show="currentSection === 'basic'" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.basicInfo') }}</h2>
<p class="section-description">{{ $t('agent.editor.basicInfoDesc') }}</p>
</div>
<div class="settings-group">
<!-- 内置智能体提示 -->
<div v-if="isBuiltinAgent" class="builtin-agent-notice">
<t-icon name="info-circle" />
<span>{{ $t('agentEditor.builtinHint') }}</span>
</div>
<!-- 运行模式(首先选择) -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.mode') }} <span class="required">*</span></label>
<p class="desc">{{ agentMode === 'smart-reasoning' ? $t('agent.editor.agentDesc') : $t('agent.editor.normalDesc') }}</p>
</div>
<div class="setting-control">
<t-radio-group v-model="agentMode" :disabled="isBuiltinAgent">
<t-radio-button value="quick-answer">
{{ $t('agent.type.normal') }}
</t-radio-button>
<t-radio-button value="smart-reasoning">
{{ $t('agent.type.agent') }}
</t-radio-button>
</t-radio-group>
</div>
</div>
<!-- 智能体类型(仅智能推理模式下显示) -->
<div v-if="isAgentMode && agentTypePresets.length > 0" class="setting-row setting-row--emphasize">
<div class="setting-info">
<label>{{ $t('agentEditor.agentType.label') }}</label>
<p class="desc">{{ $t('agentEditor.agentType.desc') }}</p>
<p
v-if="activeAgentTypePreset"
class="desc agent-type-preset-desc"
>{{ agentTypePresetDescription(activeAgentTypePreset) }}</p>
</div>
<div class="setting-control">
<t-select
:value="agentType"
@change="onAgentTypeChange"
:disabled="isBuiltinAgent"
:placeholder="$t('agentEditor.agentType.label')"
:options="agentTypeSelectOptions"
:popup-props="{ overlayClassName: 'agent-type-popup' }"
class="agent-type-select"
>
<template #option="{ option }">
<div class="agent-type-option">
<span class="agent-type-option-label">{{ option.label }}</span>
<span v-if="option.desc" class="agent-type-option-desc">{{ option.desc }}</span>
</div>
</template>
</t-select>
</div>
</div>
<!-- 名称 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.name') }} <span v-if="!isBuiltinAgent" class="required">*</span></label>
<p class="desc">{{ $t('agentEditor.desc.name') }}</p>
</div>
<div class="setting-control">
<div class="name-input-wrapper">
<!-- 内置智能体使用简洁图标 -->
<div v-if="isBuiltinAgent" class="builtin-avatar" :class="isAgentMode ? 'agent' : 'normal'">
<t-icon :name="isAgentMode ? 'control-platform' : 'chat'" size="24px" />
</div>
<!-- 自定义智能体使用 AgentAvatar -->
<AgentAvatar v-else :name="formData.name || '?'" size="medium" />
<t-input
v-model="formData.name"
:placeholder="$t('agent.editor.namePlaceholder')"
class="name-input"
:disabled="isBuiltinAgent"
/>
</div>
</div>
</div>
<!-- 描述 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.description') }}</label>
<p class="desc">{{ $t('agentEditor.desc.description') }}</p>
</div>
<div class="setting-control">
<t-textarea
v-model="formData.description"
:placeholder="$t('agent.editor.descriptionPlaceholder')"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="isBuiltinAgent"
/>
</div>
</div>
<!-- 系统提示词 -->
<div class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.systemPrompt') }} <span v-if="!isBuiltinAgent" class="required">*</span></label>
<p class="desc">{{ $t('agentEditor.desc.systemPrompt') }}{{ isBuiltinAgent ? $t('agentEditor.desc.leaveEmptyDefault') : '' }}</p>
<div class="placeholder-tags">
<span class="placeholder-label">{{ $t('agentEditor.placeholders.available') }}</span>
<t-tooltip
v-for="placeholder in availablePlaceholders"
:key="placeholder.name"
:content="placeholder.description + $t('agentEditor.placeholders.clickToInsert')"
placement="top"
>
<span
class="placeholder-tag"
@click="handlePlaceholderClick('system', placeholder.name)"
v-text="'{{' + placeholder.name + '}}'"
></span>
</t-tooltip>
<span class="placeholder-hint">{{ $t('agentEditor.placeholders.hint') }}</span>
</div>
</div>
<div class="setting-control setting-control-full" style="position: relative;">
<!-- Agent模式:统一提示词(使用 {{web_search_status}} 占位符动态控制行为) -->
<div v-if="isAgentMode" class="textarea-with-template">
<t-textarea
ref="promptTextareaRef"
v-model="formData.config.system_prompt"
:placeholder="systemPromptPlaceholder"
:autosize="{ minRows: 10, maxRows: 25 }"
@input="handlePromptInput"
class="system-prompt-textarea"
/>
<PromptTemplateSelector
type="agentSystemPrompt"
position="corner"
:hasKnowledgeBase="hasKnowledgeBase"
@select="handleSystemPromptTemplateSelect"
@reset-default="handleAgentSystemPromptResetDefault"
/>
</div>
<!-- 普通模式:单个提示词 -->
<div v-else class="textarea-with-template">
<t-textarea
ref="promptTextareaRef"
v-model="formData.config.system_prompt"
:placeholder="systemPromptPlaceholder"
:autosize="{ minRows: 10, maxRows: 25 }"
@input="handlePromptInput"
class="system-prompt-textarea"
/>
<PromptTemplateSelector
type="systemPrompt"
position="corner"
:hasKnowledgeBase="hasKnowledgeBase"
@select="handleSystemPromptTemplateSelect"
@reset-default="handleSystemPromptTemplateSelect"
/>
</div>
<!-- 占位符提示下拉框 -->
<Teleport to="body">
<div
v-if="showPlaceholderPopup && filteredPlaceholders.length > 0"
class="placeholder-popup-wrapper"
:style="popupStyle"
>
<div class="placeholder-popup">
<div
v-for="(placeholder, index) in filteredPlaceholders"
:key="placeholder.name"
class="placeholder-item"
:class="{ active: selectedPlaceholderIndex === index }"
@mousedown.prevent="insertPlaceholder(placeholder.name, true)"
@mouseenter="selectedPlaceholderIndex = index"
>
<div class="placeholder-name">
<code v-html="`{{${placeholder.name}}}`"></code>
</div>
<div class="placeholder-desc">{{ placeholder.description }}</div>
</div>
</div>
</div>
</Teleport>
</div>
</div>
<!-- 上下文模板(仅普通模式) -->
<div v-if="!isAgentMode" class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.contextTemplate') }} <span v-if="!isBuiltinAgent" class="required">*</span></label>
<p class="desc">{{ $t('agentEditor.desc.contextTemplate') }}{{ isBuiltinAgent ? $t('agentEditor.desc.leaveEmptyDefault') : '' }}</p>
<div class="placeholder-tags">
<span class="placeholder-label">{{ $t('agentEditor.placeholders.available') }}</span>
<t-tooltip
v-for="placeholder in contextTemplatePlaceholders"
:key="placeholder.name"
:content="placeholder.description + $t('agentEditor.placeholders.clickToInsert')"
placement="top"
>
<span
class="placeholder-tag"
@click="handlePlaceholderClick('context', placeholder.name)"
v-text="'{{' + placeholder.name + '}}'"
></span>
</t-tooltip>
<span class="placeholder-hint">{{ $t('agentEditor.placeholders.hint') }}</span>
</div>
</div>
<div class="setting-control setting-control-full" style="position: relative;">
<div class="textarea-with-template">
<t-textarea
ref="contextTemplateTextareaRef"
v-model="formData.config.context_template"
:placeholder="contextTemplatePlaceholder"
:autosize="{ minRows: 8, maxRows: 20 }"
@input="handleContextTemplateInput"
class="system-prompt-textarea"
/>
<PromptTemplateSelector
type="contextTemplate"
position="corner"
:hasKnowledgeBase="hasKnowledgeBase"
@select="handleContextTemplateSelect"
@reset-default="handleContextTemplateSelect"
/>
</div>
<!-- 上下文模板占位符提示下拉框 -->
<Teleport to="body">
<div
v-if="showContextPlaceholderPopup && filteredContextPlaceholders.length > 0"
class="placeholder-popup-wrapper"
:style="contextPopupStyle"
>
<div class="placeholder-popup">
<div
v-for="(placeholder, index) in filteredContextPlaceholders"
:key="placeholder.name"
class="placeholder-item"
:class="{ active: selectedContextPlaceholderIndex === index }"
@mousedown.prevent="insertContextPlaceholder(placeholder.name, true)"
@mouseenter="selectedContextPlaceholderIndex = index"
>
<div class="placeholder-name">
<code v-html="`{{${placeholder.name}}}`"></code>
</div>
<div class="placeholder-desc">{{ placeholder.description }}</div>
</div>
</div>
</div>
</Teleport>
</div>
</div>
</div>
</div>
<!-- 模型配置 -->
<div v-show="currentSection === 'model'" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.modelConfig') }}</h2>
<p class="section-description">{{ $t('agent.editor.modelConfigDesc') }}</p>
</div>
<div class="settings-group">
<!-- 模型选择 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.model') }} <span class="required">*</span></label>
<p class="desc">{{ $t('agentEditor.desc.model') }}</p>
</div>
<div class="setting-control">
<ModelSelector
model-type="KnowledgeQA"
:selected-model-id="formData.config.model_id"
:all-models="allModels"
@update:selected-model-id="(val: string) => formData.config.model_id = val"
@add-model="handleAddModel('llm')"
:placeholder="$t('agent.editor.modelPlaceholder')"
/>
</div>
</div>
<!-- 温度 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.temperature') }}</label>
<p class="desc">{{ $t('agentEditor.desc.temperature') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.temperature" :min="0" :max="1" :step="0.1" />
<span class="slider-value">{{ formData.config.temperature }}</span>
</div>
</div>
</div>
<!-- 最大生成Token数(仅普通模式) -->
<div v-if="!isAgentMode" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.maxCompletionTokens') }}</label>
<p class="desc">{{ $t('agentEditor.desc.maxTokens') }}</p>
</div>
<div class="setting-control">
<t-input-number v-model="formData.config.max_completion_tokens" :min="100" :max="100000" :step="100" theme="column" />
</div>
</div>
<!-- 思考模式 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.thinking') }}</label>
<p class="desc">{{ $t('agentEditor.desc.thinking') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="thinkingEnabled" />
</div>
</div>
</div>
</div>
<!-- 多模态配置 -->
<div v-show="currentSection === 'multimodal'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.imageUpload.sectionTitle') }}</h2>
<p class="section-description">{{ $t('agentEditor.imageUpload.sectionDesc') }}</p>
</div>
<div class="settings-group">
<!-- 图片上传(多模态) -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.imageUpload.label') }}</label>
<p class="desc">{{ $t('agentEditor.imageUpload.desc') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.image_upload_enabled" />
</div>
</div>
<!-- VLM模型(图片上传启用时) -->
<div v-if="formData.config.image_upload_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.imageUpload.vlmModel') }} <span class="required">*</span></label>
<p class="desc">{{ $t('agentEditor.imageUpload.vlmModelDesc') }}</p>
</div>
<div class="setting-control">
<ModelSelector
model-type="VLLM"
:selected-model-id="formData.config.vlm_model_id"
:all-models="allModels"
@update:selected-model-id="(val: string) => formData.config.vlm_model_id = val"
@add-model="handleAddModel('vllm')"
:placeholder="$t('agentEditor.imageUpload.vlmModelPlaceholder')"
/>
</div>
</div>
<!-- 图片存储 Provider(图片上传启用时) -->
<div v-if="formData.config.image_upload_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.imageUpload.storageProvider') }}</label>
<p class="desc">{{ $t('agentEditor.imageUpload.storageProviderDesc') }}</p>
</div>
<div class="setting-control" style="flex-direction: column; align-items: flex-end;">
<t-select
v-model="formData.config.image_storage_provider"
style="width: 280px;"
:placeholder="$t('agentEditor.imageUpload.storageProviderPlaceholder')"
clearable
>
<t-option value="" :label="$t('agentEditor.imageUpload.storageDefault')" />
<t-option
v-for="opt in imageStorageOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:disabled="opt.disabled"
>
<span class="select-option-with-tag">
<span>{{ opt.label }}</span>
<t-tag v-if="opt.disabled" theme="warning" variant="light" size="small">{{ $t('agentEditor.imageUpload.notConfigured') }}</t-tag>
</span>
</t-option>
</t-select>
<a href="javascript:void(0)" class="go-settings-link" @click.prevent="uiStore.openSettings('storage')">
{{ $t('agentEditor.imageUpload.goStorageSettings') }}
</a>
</div>
</div>
<!-- 音频上传开关 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.audioUpload.label') }}</label>
<p class="desc">{{ $t('agentEditor.audioUpload.desc') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.audio_upload_enabled" />
</div>
</div>
<!-- ASR模型(音频上传启用时) -->
<div v-if="formData.config.audio_upload_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.audioUpload.asrModel') }}</label>
<p class="desc">{{ $t('agentEditor.audioUpload.asrModelDesc') }}</p>
</div>
<div class="setting-control">
<ModelSelector
model-type="ASR"
:selected-model-id="formData.config.asr_model_id"
:all-models="allModels"
@update:selected-model-id="(val: string) => formData.config.asr_model_id = val"
@add-model="handleAddModel('asr')"
:placeholder="$t('agentEditor.audioUpload.asrModelPlaceholder')"
/>
</div>
</div>
</div>
</div>
<!-- 多轮对话(仅普通模式显示,Agent模式内部自动控制) -->
<div v-show="currentSection === 'conversation' && !isAgentMode" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.conversationSettings') }}</h2>
<p class="section-description">{{ $t('agentEditor.desc.conversationSection') }}</p>
</div>
<div class="settings-group">
<!-- 多轮对话 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.multiTurn') }}</label>
<p class="desc">{{ $t('agentEditor.desc.multiTurn') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.multi_turn_enabled" />
</div>
</div>
<!-- 保留轮数 -->
<div v-if="formData.config.multi_turn_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.historyTurns') }}</label>
<p class="desc">{{ $t('agentEditor.desc.historyRounds') }}</p>
</div>
<div class="setting-control">
<t-input-number v-model="formData.config.history_turns" :min="1" :max="20" theme="column" />
</div>
</div>
<!-- 问题改写(仅多轮对话开启且普通模式时显示) -->
<div v-if="formData.config.multi_turn_enabled && !isAgentMode" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.enableRewrite') }}</label>
<p class="desc">{{ $t('agentEditor.desc.rewrite') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.enable_rewrite" />
</div>
</div>
<!-- 问题理解模型(独立模型,留空则复用主对话模型) -->
<div v-if="formData.config.multi_turn_enabled && !isAgentMode && formData.config.enable_rewrite" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.queryUnderstandModel') }}</label>
<p class="desc">{{ $t('agentEditor.desc.queryUnderstandModel') }}</p>
</div>
<div class="setting-control">
<ModelSelector
model-type="KnowledgeQA"
:selected-model-id="formData.config.query_understand_model_id"
:all-models="allModels"
@update:selected-model-id="(val: string) => formData.config.query_understand_model_id = val"
@add-model="handleAddModel('llm')"
:placeholder="$t('agent.editor.queryUnderstandModelPlaceholder')"
/>
</div>
</div>
<!-- 改写系统提示词 -->
<div v-if="formData.config.multi_turn_enabled && !isAgentMode && formData.config.enable_rewrite" class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.rewritePromptSystem') }}</label>
<p class="desc">{{ $t('agentEditor.desc.rewriteSystemPrompt') }}</p>
<div class="placeholder-tags" v-if="rewriteSystemPlaceholders.length > 0">
<span class="placeholder-label">{{ $t('agentEditor.placeholders.available') }}</span>
<t-tooltip
v-for="placeholder in rewriteSystemPlaceholders"
:key="placeholder.name"
:content="placeholder.description + $t('agentEditor.placeholders.clickToInsert')"
placement="top"
>
<span
class="placeholder-tag"
@click="handlePlaceholderClick('rewriteSystem', placeholder.name)"
v-text="'{{' + placeholder.name + '}}'"
></span>
</t-tooltip>
<span class="placeholder-hint">{{ $t('agentEditor.placeholders.hint') }}</span>
</div>
</div>
<div class="setting-control setting-control-full" style="position: relative;">
<div class="textarea-with-template">
<t-textarea
ref="rewriteSystemTextareaRef"
v-model="formData.config.rewrite_prompt_system"
:placeholder="defaultRewritePromptSystem || $t('agent.editor.rewritePromptSystemPlaceholder')"
:autosize="{ minRows: 4, maxRows: 10 }"
@input="handleRewriteSystemInput"
/>
<PromptTemplateSelector
type="rewrite"
position="corner"
@select="handleRewriteTemplateSelect"
@reset-default="handleRewriteTemplateSelect"
/>
</div>
<Teleport to="body">
<div
v-if="rewriteSystemPopup.show && filteredRewriteSystemPlaceholders.length > 0"
class="placeholder-popup-wrapper"
:style="rewriteSystemPopup.style"
>
<div class="placeholder-popup">
<div
v-for="(placeholder, index) in filteredRewriteSystemPlaceholders"
:key="placeholder.name"
class="placeholder-item"
:class="{ active: rewriteSystemPopup.selectedIndex === index }"
@mousedown.prevent="insertGenericPlaceholder('rewriteSystem', placeholder.name, true)"
@mouseenter="rewriteSystemPopup.selectedIndex = index"
>
<div class="placeholder-name">
<code v-html="`{{${placeholder.name}}}`"></code>
</div>
<div class="placeholder-desc">{{ placeholder.description }}</div>
</div>
</div>
</div>
</Teleport>
</div>
</div>
<!-- 改写用户提示词 -->
<div v-if="formData.config.multi_turn_enabled && !isAgentMode && formData.config.enable_rewrite" class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.rewritePromptUser') }}</label>
<p class="desc">{{ $t('agentEditor.desc.rewriteUserPrompt') }}</p>
<div class="placeholder-tags" v-if="rewritePlaceholders.length > 0">
<span class="placeholder-label">{{ $t('agentEditor.placeholders.available') }}</span>
<t-tooltip
v-for="placeholder in rewritePlaceholders"
:key="placeholder.name"
:content="placeholder.description + $t('agentEditor.placeholders.clickToInsert')"
placement="top"
>
<span
class="placeholder-tag"
@click="handlePlaceholderClick('rewriteUser', placeholder.name)"
v-text="'{{' + placeholder.name + '}}'"
></span>
</t-tooltip>
<span class="placeholder-hint">{{ $t('agentEditor.placeholders.hint') }}</span>
</div>
</div>
<div class="setting-control setting-control-full" style="position: relative;">
<div class="textarea-with-template">
<t-textarea
ref="rewriteUserTextareaRef"
v-model="formData.config.rewrite_prompt_user"
:placeholder="defaultRewritePromptUser || $t('agent.editor.rewritePromptUserPlaceholder')"
:autosize="{ minRows: 4, maxRows: 10 }"
@input="handleRewriteUserInput"
/>
<PromptTemplateSelector
type="rewrite"
position="corner"
@select="handleRewriteTemplateSelect"
@reset-default="handleRewriteTemplateSelect"
/>
</div>
<Teleport to="body">
<div
v-if="rewriteUserPopup.show && filteredRewriteUserPlaceholders.length > 0"
class="placeholder-popup-wrapper"
:style="rewriteUserPopup.style"
>
<div class="placeholder-popup">
<div
v-for="(placeholder, index) in filteredRewriteUserPlaceholders"
:key="placeholder.name"
class="placeholder-item"
:class="{ active: rewriteUserPopup.selectedIndex === index }"
@mousedown.prevent="insertGenericPlaceholder('rewriteUser', placeholder.name, true)"
@mouseenter="rewriteUserPopup.selectedIndex = index"
>
<div class="placeholder-name">
<code v-html="`{{${placeholder.name}}}`"></code>
</div>
<div class="placeholder-desc">{{ placeholder.description }}</div>
</div>
</div>
</div>
</Teleport>
</div>
</div>
</div>
</div>
<!-- 工具配置(仅 Agent 模式) -->
<div v-show="currentSection === 'tools' && isAgentMode" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.toolsConfig') }}</h2>
<p class="section-description">{{ $t('agent.editor.toolsConfigDesc') }}</p>
</div>
<!-- 合并面板:能力状态 + 预设切换 -->
<div class="tools-overview">
<div class="tools-overview-row">
<div class="tools-status-chip">
<t-icon name="folder" />
<template v-if="kbSelectionMode === 'none'">
<span>{{ $t('agentEditor.tools.statusNoKb') }}</span>
</template>
<template v-else>
<span class="tools-status-metric">
<strong>{{ ragKbCount }}</strong> {{ $t('agentEditor.tools.kbMetricRag') }}
</span>
<span class="tools-status-sep">·</span>
<span class="tools-status-metric">
<strong>{{ wikiKbCount }}</strong> {{ $t('agentEditor.tools.kbMetricWiki') }}
</span>
</template>
</div>
<div v-if="inactiveToolCount > 0" class="tools-status-chip tools-status-chip--warn">
<t-icon name="error-circle" />
<span>{{ $t('agentEditor.tools.statusInactive', { count: inactiveToolCount }) }}</span>
</div>
</div>
</div>
<div class="settings-group">
<!-- 允许的工具(按组渲染,统一网格) -->
<div class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.allowedTools') }}</label>
<p class="desc">{{ $t('agentEditor.desc.selectTools') }}</p>
</div>
<div class="setting-control setting-control-full">
<t-checkbox-group v-model="formData.config.allowed_tools" class="tool-groups">
<section
v-for="group in groupedAvailableTools"
:key="group.key"
:class="['tool-group', `tool-group--${group.key}`]"
>
<header class="tool-group-header">
<span class="tool-group-bar" />
<span class="tool-group-title">{{ group.label }}</span>
<span class="tool-group-count">{{ group.tools.length }}</span>
<span v-if="group.key === 'wiki_edit'" class="tool-group-warning">
<t-icon name="error-circle" />
{{ $t('agentEditor.tools.writeWarning') }}
</span>
</header>
<div class="tool-grid">
<t-checkbox
v-for="tool in group.tools"
:key="tool.value"
:value="tool.value"
:disabled="tool.disabled"
:class="['tool-card', { 'tool-card--disabled': tool.disabled, 'tool-card--danger': tool.danger }]"
>
<div class="tool-card-body">
<div class="tool-card-head">
<span class="tool-card-name">{{ tool.label }}</span>
<span v-if="tool.danger" class="tool-card-badge">
{{ $t('agentEditor.tools.dangerTag') }}
</span>
</div>
<span v-if="tool.description" class="tool-card-desc">{{ tool.description }}</span>
<span v-if="tool.disabled && tool.disabledReason" class="tool-card-hint">
{{ tool.disabledReason }}
</span>
</div>
</t-checkbox>
</div>
</section>
</t-checkbox-group>
</div>
</div>
<!-- 有效工具预览:所见即所得 -->
<div class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agentEditor.tools.effectiveLabel') }}</label>
<p class="desc">{{ $t('agentEditor.tools.effectiveDesc') }}</p>
</div>
<div class="setting-control setting-control-full">
<div class="effective-tools">
<template v-if="effectiveTools.length === 0">
<div class="effective-tools-empty">
{{ $t('agentEditor.tools.effectiveEmpty') }}
</div>
</template>
<template v-else>
<span
v-for="item in effectiveTools"
:key="item.value"
:class="['effective-chip', { 'effective-chip--inactive': !item.active }]"
:title="item.reason || ''"
>
<span class="effective-chip-label">{{ item.label }}</span>
<span v-if="!item.active" class="effective-chip-reason">{{ item.reason }}</span>
</span>
</template>
</div>
</div>
</div>
<!-- 最大迭代次数 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.maxIterations') }}</label>
<p class="desc">{{ $t('agentEditor.desc.maxIterations') }}</p>
</div>
<div class="setting-control">
<t-input-number v-model="formData.config.max_iterations" :min="1" :max="50" theme="column" />
</div>
</div>
<!-- LLM 调用超时时间 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.llmCallTimeout.label') }}</label>
<p class="desc">{{ $t('agentEditor.llmCallTimeout.desc') }}</p>
<p class="desc-hint">{{ $t('agentEditor.llmCallTimeout.hint') }}</p>
</div>
<div class="setting-control">
<t-input-number
v-model="formData.config.llm_call_timeout"
:min="0"
:max="3600"
theme="column"
:placeholder="$t('agentEditor.llmCallTimeout.placeholder')"
clearable
/>
</div>
</div>
<!-- MCP 服务选择 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.mcp.label') }}</label>
<p class="desc">{{ $t('agentEditor.mcp.desc') }}</p>
</div>
<div class="setting-control">
<t-radio-group v-model="mcpSelectionMode">
<t-radio-button value="all">{{ $t('agentEditor.selection.all') }}</t-radio-button>
<t-radio-button value="selected">{{ $t('agentEditor.selection.selected') }}</t-radio-button>
<t-radio-button value="none">{{ $t('agentEditor.selection.disabled') }}</t-radio-button>
</t-radio-group>
</div>
</div>
<!-- 选择指定 MCP 服务 -->
<div v-if="mcpSelectionMode === 'selected' && mcpOptions.length > 0" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.mcp.selectLabel') }}</label>
<p class="desc">{{ $t('agentEditor.mcp.selectDesc') }}</p>
</div>
<div class="setting-control">
<t-select
v-model="formData.config.mcp_services"
multiple
:placeholder="$t('agentEditor.mcp.selectPlaceholder')"
filterable
>
<t-option
v-for="mcp in mcpOptions"
:key="mcp.value"
:value="mcp.value"
:label="mcp.label"
/>
</t-select>
</div>
</div>
</div>
</div>
<!-- Skills 配置(仅 Agent 模式) -->
<div v-show="currentSection === 'skills' && isAgentMode" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.skillsConfig') }}</h2>
<p class="section-description">{{ $t('agent.editor.skillsConfigDesc') }}</p>
</div>
<div class="settings-group">
<!-- Skills 选择模式 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.skillsSelection') }}</label>
<p class="desc">{{ $t('agent.editor.skillsSelectionDesc') }}</p>
</div>
<div class="setting-control">
<t-radio-group v-model="skillsSelectionMode">
<t-radio-button value="all">{{ $t('agent.editor.skillsAll') }}</t-radio-button>
<t-radio-button value="selected">{{ $t('agent.editor.skillsSelected') }}</t-radio-button>
<t-radio-button value="none">{{ $t('agent.editor.skillsNone') }}</t-radio-button>
</t-radio-group>
</div>
</div>
<!-- 选择指定 Skills -->
<div v-if="skillsSelectionMode === 'selected' && skillOptions.length > 0" class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.selectSkills') }}</label>
<p class="desc">{{ $t('agent.editor.selectSkillsDesc') }}</p>
</div>
<div class="setting-control setting-control-full">
<t-checkbox-group v-model="formData.config.selected_skills" class="skills-checkbox-group">
<t-checkbox
v-for="skill in skillOptions"
:key="skill.name"
:value="skill.name"
class="skill-checkbox-item"
>
<div class="skill-item-content">
<span class="skill-name">{{ skill.name }}</span>
<span class="skill-desc">{{ skill.description }}</span>
</div>
</t-checkbox>
</t-checkbox-group>
</div>
</div>
<!-- 无可用 Skills 提示 -->
<div v-if="skillOptions.length === 0" class="setting-row">
<div class="setting-info">
<p class="desc empty-hint">{{ $t('agent.editor.noSkillsAvailable') }}</p>
</div>
</div>
<!-- Skills 说明 -->
<div class="skill-info-box">
<t-icon name="lightbulb" class="info-icon" />
<div class="info-content">
<p><strong>{{ $t('agent.editor.skillsInfoTitle') }}</strong></p>
<p>{{ $t('agent.editor.skillsInfoContent') }}</p>
</div>
</div>
</div>
</div>
<!-- 知识库配置 -->
<div v-show="currentSection === 'knowledge'" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.knowledgeConfig') }}</h2>
<p class="section-description">{{ $t('agent.editor.knowledgeConfigDesc') }}</p>
</div>
<div class="settings-group">
<!-- 关联知识库 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.knowledgeBases') }}</label>
<p class="desc">{{ $t('agentEditor.desc.kbScope') }}</p>
</div>
<div class="setting-control">
<t-radio-group v-model="kbSelectionMode">
<t-radio-button value="all">{{ $t('agent.editor.allKnowledgeBases') }}</t-radio-button>
<t-radio-button value="selected">{{ $t('agent.editor.selectedKnowledgeBases') }}</t-radio-button>
<t-radio-button value="none">{{ $t('agent.editor.noKnowledgeBase') }}</t-radio-button>
</t-radio-group>
</div>
</div>
<!-- 选择指定知识库(仅在选择"指定知识库"时显示) -->
<div v-if="kbSelectionMode === 'selected'" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.selectKnowledgeBases') }}</label>
<p class="desc">{{ $t('agent.editor.selectKnowledgeBasesDesc') }}</p>
</div>
<div class="setting-control">
<t-select
v-model="formData.config.knowledge_bases"
multiple
:placeholder="$t('agent.editor.selectKnowledgeBases')"
filterable
:min-collapsed-num="3"
>
<t-option-group v-if="filteredMyKbOptions.length" :label="$t('agent.editor.myKnowledgeBases')">
<t-option
v-for="kb in filteredMyKbOptions"
:key="kb.value"
:value="kb.value"
:label="kb.label"
:disabled="kb.disabled"
>
<div class="kb-option-item" :title="kb.disabled ? kb.disabledReason : ''">
<span class="kb-option-icon" :class="kb.type === 'faq' ? 'faq-icon' : 'doc-icon'">
<t-icon :name="kb.type === 'faq' ? 'chat-bubble-help' : 'folder'" />
</span>
<span class="kb-option-label">{{ kb.label }}</span>
<span v-if="kb.ragEnabled" class="kb-option-tag tag-rag">RAG</span>
<span v-if="kb.wikiEnabled" class="kb-option-tag tag-wiki">Wiki</span>
<span class="kb-option-count">{{ kb.count || 0 }}</span>
<span v-if="kb.disabled" class="kb-option-disabled-hint">{{ kb.disabledReason }}</span>
</div>
</t-option>
</t-option-group>
<t-option-group v-if="filteredSharedKbOptions.length" :label="$t('agent.editor.sharedKnowledgeBases')">
<t-option
v-for="kb in filteredSharedKbOptions"
:key="kb.value"
:value="kb.value"
:label="kb.label"
:disabled="kb.disabled"
>
<div class="kb-option-item" :title="kb.disabled ? kb.disabledReason : ''">
<span class="kb-option-icon" :class="kb.type === 'faq' ? 'faq-icon' : 'doc-icon'">
<t-icon :name="kb.type === 'faq' ? 'chat-bubble-help' : 'folder'" />
</span>
<span class="kb-option-label">{{ kb.label }}</span>
<span v-if="kb.ragEnabled" class="kb-option-tag tag-rag">RAG</span>
<span v-if="kb.wikiEnabled" class="kb-option-tag tag-wiki">Wiki</span>
<span v-if="kb.orgName" class="kb-option-org">{{ kb.orgName }}</span>
<span class="kb-option-count">{{ kb.count || 0 }}</span>
<span v-if="kb.disabled" class="kb-option-disabled-hint">{{ kb.disabledReason }}</span>
</div>
</t-option>
</t-option-group>
</t-select>
</div>
</div>
<!-- 支持的文件类型(限制用户可选择的文件类型) -->
<div v-if="hasKnowledgeBase" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.fileTypes.label') }}</label>
<p class="desc">{{ $t('agentEditor.fileTypes.desc') }}</p>
</div>
<div class="setting-control">
<t-select
v-model="formData.config.supported_file_types"
multiple
:placeholder="$t('agentEditor.fileTypes.allTypes')"
:min-collapsed-num="3"
clearable
>
<t-option
v-for="ft in availableFileTypes"
:key="ft.value"
:value="ft.value"
:label="ft.label"
/>
</t-select>
</div>
</div>
<!-- 仅在提及时检索知识库(当配置了知识库时显示) -->
<div v-if="hasKnowledgeBase" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.retrieveKBOnlyWhenMentioned') }}</label>
<p class="desc">{{ $t('agent.editor.retrieveKBOnlyWhenMentionedDesc') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.retrieve_kb_only_when_mentioned" />
</div>
</div>
<!-- ReRank 模型(关联知识库时常驻显示,仅在作用域内存在 RAG 类型 KB 时必填) -->
<div v-if="hasKnowledgeBase" class="setting-row">
<div class="setting-info">
<label>
{{ $t('agent.editor.rerankModel') }}
<span v-if="needsRerankModel" class="required">*</span>
</label>
<p class="desc">
{{ $t('agent.editor.rerankModelDesc') }}
<template v-if="!needsRerankModel">
<br />
<span class="hint">{{ $t('agent.editor.rerankModelOptionalHint') }}</span>
</template>
</p>
</div>
<div class="setting-control">
<ModelSelector
model-type="Rerank"
:selected-model-id="formData.config.rerank_model_id"
:all-models="allModels"
@update:selected-model-id="(val: string) => formData.config.rerank_model_id = val"
@add-model="handleAddModel('rerank')"
:placeholder="$t('agent.editor.rerankModelPlaceholder')"
/>
</div>
</div>
<!-- FAQ 策略设置(仅当选择了 FAQ 类型知识库时显示) -->
<div v-if="hasFaqKnowledgeBase" class="faq-strategy-section">
<div class="faq-strategy-header">
<t-icon name="chat-bubble-help" class="faq-icon" />
<span>{{ $t('agentEditor.faq.title') }}</span>
<t-tooltip :content="$t('agentEditor.faq.tooltip')">
<t-icon name="help-circle" class="help-icon" />
</t-tooltip>
</div>
<!-- FAQ 优先开关 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.faq.enableLabel') }}</label>
<p class="desc">{{ $t('agentEditor.faq.enableDesc') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.faq_priority_enabled" />
</div>
</div>
<!-- FAQ 直接回答阈值 -->
<div v-if="formData.config.faq_priority_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.faq.thresholdLabel') }}</label>
<p class="desc">{{ $t('agentEditor.faq.thresholdDesc') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.faq_direct_answer_threshold" :min="0.7" :max="1" :step="0.05" />
<span class="slider-value">{{ formData.config.faq_direct_answer_threshold?.toFixed(2) }}</span>
</div>
</div>
</div>
<!-- FAQ 分数加权 -->
<div v-if="formData.config.faq_priority_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.faq.boostLabel') }}</label>
<p class="desc">{{ $t('agentEditor.faq.boostDesc') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.faq_score_boost" :min="1" :max="2" :step="0.1" />
<span class="slider-value">{{ formData.config.faq_score_boost?.toFixed(1) }}x</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 网络搜索配置 -->
<div v-show="currentSection === 'websearch'" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.webSearchConfig') }}</h2>
<p class="section-description">{{ $t('agent.editor.webSearchConfigDesc') }}</p>
</div>
<div class="settings-group">
<!-- 网络搜索 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.webSearch') }}</label>
<p class="desc">{{ $t('agentEditor.desc.webSearch') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.web_search_enabled" />
</div>
</div>
<!-- 网络搜索最大结果数 -->
<div v-if="formData.config.web_search_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.webSearchProvider') }}</label>
<p class="desc">{{ $t('agentEditor.desc.webSearchProvider') }}</p>
</div>
<div class="setting-control">
<t-select
v-model="formData.config.web_search_provider_id"
clearable
:placeholder="$t('agent.editor.webSearchProviderPlaceholder')"
style="width: 240px;"
>
<t-option
v-for="p in webSearchProviderList"
:key="p.id"
:value="p.id"
:label="p.name"
>
<span>{{ p.name }}</span>
<t-tag v-if="p.is_default" theme="primary" size="small" style="margin-left: 6px;">{{ $t('common.default') }}</t-tag>
</t-option>
</t-select>
</div>
</div>
<!-- 网络搜索最大结果数 -->
<div v-if="formData.config.web_search_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.webSearchMaxResults') }}</label>
<p class="desc">{{ $t('agentEditor.desc.webSearchMaxResults') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.web_search_max_results" :min="1" :max="10" />
<span class="slider-value">{{ formData.config.web_search_max_results }}</span>
</div>
</div>
</div>
<!-- 自动抓取页面内容 -->
<div v-if="formData.config.web_search_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.webFetchEnabled') }}</label>
<p class="desc">{{ $t('agentEditor.desc.webFetchEnabled') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.web_fetch_enabled" />
</div>
</div>
<!-- 抓取页面数 -->
<div v-if="formData.config.web_search_enabled && formData.config.web_fetch_enabled" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.webFetchTopN') }}</label>
<p class="desc">{{ $t('agentEditor.desc.webFetchTopN') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.web_fetch_top_n" :min="1" :max="10" />
<span class="slider-value">{{ formData.config.web_fetch_top_n }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 检索策略(仅在有知识库能力时显示) -->
<div v-show="currentSection === 'retrieval' && hasKnowledgeBase" class="section">
<div class="section-header">
<h2>{{ $t('agent.editor.retrievalStrategy') }}</h2>
<p class="section-description">{{ $t('agentEditor.desc.retrievalSection') }}</p>
</div>
<div class="settings-group">
<!-- 查询扩展(仅普通模式) -->
<div v-if="!isAgentMode" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.enableQueryExpansion') }}</label>
<p class="desc">{{ $t('agentEditor.desc.queryExpansion') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.enable_query_expansion" />
</div>
</div>
<!-- 向量召回TopK -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.embeddingTopK') }}</label>
<p class="desc">{{ $t('agentEditor.desc.embeddingTopK') }}</p>
</div>
<div class="setting-control">
<t-input-number v-model="formData.config.embedding_top_k" :min="1" :max="50" theme="column" />
</div>
</div>
<!-- 关键词阈值 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.keywordThreshold') }}</label>
<p class="desc">{{ $t('agentEditor.desc.keywordThreshold') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.keyword_threshold" :min="0" :max="1" :step="0.01" />
<span class="slider-value">{{ formData.config.keyword_threshold?.toFixed(2) }}</span>
</div>
</div>
</div>
<!-- 向量阈值 -->
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.vectorThreshold') }}</label>
<p class="desc">{{ $t('agentEditor.desc.vectorThreshold') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.vector_threshold" :min="0" :max="1" :step="0.01" />
<span class="slider-value">{{ formData.config.vector_threshold?.toFixed(2) }}</span>
</div>
</div>
</div>
<!-- 重排TopK(仅在配置了 Rerank 模型时展示) -->
<div v-if="formData.config.rerank_model_id" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.rerankTopK') }}</label>
<p class="desc">{{ $t('agentEditor.desc.rerankTopK') }}</p>
</div>
<div class="setting-control">
<t-input-number v-model="formData.config.rerank_top_k" :min="1" :max="20" theme="column" />
</div>
</div>
<!-- 重排阈值(仅在配置了 Rerank 模型时展示) -->
<div v-if="formData.config.rerank_model_id" class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.rerankThreshold') }}</label>
<p class="desc">{{ $t('agentEditor.desc.rerankThreshold') }}</p>
</div>
<div class="setting-control">
<div class="slider-wrapper">
<t-slider v-model="formData.config.rerank_threshold" :min="-10" :max="10" :step="0.01" />
<span class="slider-value">{{ formData.config.rerank_threshold?.toFixed(1) }}</span>
</div>
</div>
</div>
<!-- 表格数据分析(仅普通模式,命中 CSV/Excel 时会多一次 LLM 调用生成 SQL) -->
<div v-if="!isAgentMode" class="setting-row">
<div class="setting-info">
<label>{{ $t('agentEditor.dataAnalysis.enableLabel') }}</label>
<p class="desc">{{ $t('agentEditor.dataAnalysis.enableDesc') }}</p>
</div>
<div class="setting-control">
<t-switch v-model="formData.config.data_analysis_enabled" />
</div>
</div>
<!-- 兜底策略(仅普通模式) -->
<template v-if="!isAgentMode">
<div class="setting-row">
<div class="setting-info">
<label>{{ $t('agent.editor.fallbackStrategy') }}</label>
<p class="desc">{{ $t('agentEditor.desc.fallbackStrategy') }}</p>
</div>
<div class="setting-control">
<t-radio-group v-model="formData.config.fallback_strategy">
<t-radio-button value="fixed">{{ $t('agentEditor.fallback.fixed') }}</t-radio-button>
<t-radio-button value="model">{{ $t('agentEditor.fallback.model') }}</t-radio-button>
</t-radio-group>
</div>
</div>
<!-- 固定兜底回复 -->
<div v-if="formData.config.fallback_strategy === 'fixed'" class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.fallbackResponse') }}</label>
<p class="desc">{{ $t('agentEditor.desc.fallbackResponse') }}</p>
</div>
<div class="setting-control setting-control-full">
<div class="textarea-with-template">
<t-textarea
v-model="formData.config.fallback_response"
:placeholder="defaultFallbackResponse || $t('agent.editor.fallbackResponsePlaceholder')"
:autosize="{ minRows: 2, maxRows: 6 }"
/>
<PromptTemplateSelector
type="fallback"
position="corner"
fallbackMode="fixed"
@select="handleFallbackResponseTemplateSelect"
@reset-default="handleFallbackResponseTemplateSelect"
/>
</div>
</div>
</div>
<!-- 兜底提示词 -->
<div v-if="formData.config.fallback_strategy === 'model'" class="setting-row setting-row-vertical">
<div class="setting-info">
<label>{{ $t('agent.editor.fallbackPrompt') }}</label>
<p class="desc">{{ $t('agentEditor.desc.fallbackPrompt') }}</p>
<div class="placeholder-tags" v-if="fallbackPlaceholders.length > 0">
<span class="placeholder-label">{{ $t('agentEditor.placeholders.available') }}</span>
<t-tooltip
v-for="placeholder in fallbackPlaceholders"
:key="placeholder.name"
:content="placeholder.description + $t('agentEditor.placeholders.clickToInsert')"
placement="top"
>
<span
class="placeholder-tag"
@click="handlePlaceholderClick('fallback', placeholder.name)"
v-text="'{{' + placeholder.name + '}}'"
></span>
</t-tooltip>
<span class="placeholder-hint">{{ $t('agentEditor.placeholders.hint') }}</span>
</div>
</div>
<div class="setting-control setting-control-full" style="position: relative;">
<div class="textarea-with-template">
<t-textarea
ref="fallbackPromptTextareaRef"
v-model="formData.config.fallback_prompt"
:placeholder="defaultFallbackPrompt || $t('agent.editor.fallbackPromptPlaceholder')"
:autosize="{ minRows: 4, maxRows: 10 }"
@input="handleFallbackPromptInput"
/>
<PromptTemplateSelector
type="fallback"
position="corner"
fallbackMode="model"
@select="handleFallbackPromptTemplateSelect"
@reset-default="handleFallbackPromptTemplateSelect"
/>
</div>
<Teleport to="body">
<div
v-if="fallbackPromptPopup.show && filteredFallbackPlaceholders.length > 0"
class="placeholder-popup-wrapper"
:style="fallbackPromptPopup.style"
>
<div class="placeholder-popup">
<div
v-for="(placeholder, index) in filteredFallbackPlaceholders"
:key="placeholder.name"
class="placeholder-item"
:class="{ active: fallbackPromptPopup.selectedIndex === index }"
@mousedown.prevent="insertGenericPlaceholder('fallback', placeholder.name, true)"
@mouseenter="fallbackPromptPopup.selectedIndex = index"
>
<div class="placeholder-name">
<code v-html="`{{${placeholder.name}}}`"></code>
</div>
<div class="placeholder-desc">{{ placeholder.description }}</div>
</div>
</div>
</div>
</Teleport>
</div>
</div>
</template>
</div>
</div>
<!-- 共享管理(仅编辑模式且非内置智能体) -->
<div v-if="props.mode === 'edit' && props.agent?.id && !props.agent?.is_builtin" v-show="currentSection === 'share'" class="section">
<AgentShareSettings :agent-id="props.agent.id" :agent="props.agent" />
</div>
<!-- IM集成(仅编辑模式) -->
<div v-if="props.mode === 'edit' && props.agent?.id" v-show="currentSection === 'im'" class="section">
<div class="section-header">
<h2>{{ $t('agentEditor.im.title') }}</h2>
<p class="section-description">
{{ $t('agentEditor.im.description') }}
<a href="https://github.com/Tencent/WeKnora/blob/main/docs/IM%E9%9B%86%E6%88%90%E5%BC%80%E5%8F%91%E6%96%87%E6%A1%A3.md" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.docLink') }}
<t-icon name="link" class="link-icon" />
</a>
</p>
</div>
<div class="settings-group">
<IMChannelPanel :agent-id="props.agent.id" />
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="settings-footer">
<t-button variant="outline" @click="handleClose">{{ props.readOnly ? $t('common.close') : $t('common.cancel') }}</t-button>
<t-button v-if="!props.readOnly" theme="primary" :loading="saving" @click="handleSave">{{ $t('common.confirm') }}</t-button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { MessagePlugin } from 'tdesign-vue-next';
import {
createAgent,
updateAgent,
getPlaceholders,
getAgentTypePresets,
type CustomAgent,
type PlaceholderDefinition,
type AgentTypePreset,
type AgentType,
type AgentTypeKBFilter,
type KBCapabilities,
} from '@/api/agent';
import { listModels, type ModelConfig } from '@/api/model';
import { listKnowledgeBases } from '@/api/knowledge-base';
import { listMCPServices, type MCPService } from '@/api/mcp-service';
import { listSkills, type SkillInfo } from '@/api/skill';
import { listWebSearchProviders, type WebSearchProviderEntity } from '@/api/web-search-provider';
import { getAgentConfig, getStorageEngineStatus, getPromptTemplates, type StorageEngineStatusItem, type PromptTemplate } from '@/api/system';
import { getTenantRetrievalConfig } from '@/api/retrieval';
import { useUIStore } from '@/stores/ui';
import { useAuthStore } from '@/stores/auth';
import { useOrganizationStore } from '@/stores/organization';
import AgentAvatar from '@/components/AgentAvatar.vue';
import PromptTemplateSelector from '@/components/PromptTemplateSelector.vue';
import ModelSelector from '@/components/ModelSelector.vue';
import AgentShareSettings from '@/components/AgentShareSettings.vue';
import IMChannelPanel from '@/components/IMChannelPanel.vue';
import {
evaluateToolRequirement,
deriveKbFilterFromTools,
type RequirementMissKind,
type ScopeCapabilities,
} from '@/utils/tool-capabilities';
const uiStore = useUIStore();
const authStore = useAuthStore();
const orgStore = useOrganizationStore();
const { t, locale: i18nLocale } = useI18n();
const props = defineProps<{
visible: boolean;
mode: 'create' | 'edit';
agent?: CustomAgent | null;
initialSection?: string;
// readOnly hides the save button so a Viewer who clicks an agent
// card to inspect its config doesn't see a "确定" that 403s on the
// backend update endpoint. Field-level disable is intentionally NOT
// wired here yet (the modal has 3000+ lines of form inputs); instead
// we just remove the only mutation surface — the footer button.
readOnly?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'success'): void;
}>();
const currentSection = ref(props.initialSection || 'basic');
const saving = ref(false);
const allModels = ref<ModelConfig[]>([]);
const kbOptions = ref<{ label: string; value: string; type?: 'document' | 'faq'; count?: number; shared?: boolean; orgName?: string; ragEnabled?: boolean; wikiEnabled?: boolean; capabilities?: KBCapabilities }[]>([]);
// 智能体类型预设(仅 smart-reasoning 模式下展示)
const agentTypePresets = ref<AgentTypePreset[]>([]);
// Agent 系统提示词模板缓存(用于切换智能体类型时根据 system_prompt_id 解析出实际文本填入)
const agentSystemPromptTemplates = ref<PromptTemplate[]>([]);
const mcpOptions = ref<{ label: string; value: string }[]>([]);
const webSearchProviderList = ref<WebSearchProviderEntity[]>([]);
const skillOptions = ref<{ name: string; description: string }[]>([]);
// 是否允许启用 Skills(取决于后端沙箱是否启用,disabled 时为 false;未请求前为 false 避免闪显)
const skillsAvailable = ref(false);
// 存储引擎可用状态(用于图片存储 provider 选择)
const storageEngineStatus = ref<StorageEngineStatusItem[]>([]);
const imageStorageOptions = computed(() => {
const statusMap: Record<string, boolean> = {};
for (const e of storageEngineStatus.value) {
statusMap[e.name] = e.available;
}
return [
{ value: 'local', label: t('settings.storage.engineLocal'), disabled: false },
{ value: 'minio', label: 'MinIO', disabled: statusMap.minio === false },
{ value: 'cos', label: t('settings.storage.engineCos'), disabled: statusMap.cos === false },
{ value: 'tos', label: t('settings.storage.engineTos'), disabled: statusMap.tos === false },
{ value: 's3', label: 'Amazon S3', disabled: statusMap.s3 === false },
{ value: 'oss', label: t('settings.storage.engineOss'), disabled: statusMap.oss === false },
];
});
// 系统默认配置(用于内置智能体显示默认提示词)
const defaultAgentSystemPrompt = ref(''); // Agent 模式的默认系统提示词(来自 agent-config)
const defaultNormalSystemPrompt = ref(''); // 普通模式默认系统提示词(来自 prompt-templates 的 default 项)
const defaultContextTemplate = ref('');
const defaultRewritePromptSystem = ref('');
const defaultRewritePromptUser = ref('');
const defaultFallbackPrompt = ref('');
const defaultFallbackResponse = ref('');
// 默认检索参数
const defaultEmbeddingTopK = ref(10);
const defaultKeywordThreshold = ref(0.3);
const defaultVectorThreshold = ref(0.5);
const defaultRerankTopK = ref(5);
const defaultRerankThreshold = ref(0.5);
const defaultMaxCompletionTokens = ref(2048);
const defaultTemperature = ref(0.7);
// 知识库相关工具列表(用于 watch(hasKnowledgeBase) 从"无"变"有"时 seed 默认工具)
const knowledgeBaseTools = ['grep_chunks', 'knowledge_search', 'list_knowledge_chunks', 'query_knowledge_graph', 'get_document_info', 'database_query'];
// Wiki 读取类工具(用于 watch(agentMode) 切到 smart-reasoning 时 seed 默认工具)
const wikiReadTools = ['wiki_search', 'wiki_read_page', 'wiki_read_source_doc', 'wiki_flag_issue'];
// 初始化标志,防止初始化时触发 watch 自动添加工具
const isInitializing = ref(false);
// 知识库选择模式:all=全部, selected=指定, none=不使用
const kbSelectionMode = ref<'all' | 'selected' | 'none'>('none');
// MCP 服务选择模式:all=全部, selected=指定, none=不使用
const mcpSelectionMode = ref<'all' | 'selected' | 'none'>('none');
// Skills 选择模式:all=全部, selected=指定, none=不使用
const skillsSelectionMode = ref<'all' | 'selected' | 'none'>('none');
// 可用工具列表(与后台 internal/agent/tools/definitions.go 保持一致)
// group 决定 UI 分组:base / rag / wiki_read / wiki_edit / wiki_issue / data
// danger: 写类破坏性工具,UI 上给出显著提示
// 工具的 KB 能力依赖关系统一在 `@/utils/tool-capabilities` 声明,
// `availableTools` 通过 `evaluateToolRequirement` 读取,不在这里重复维护。
const allTools = computed(() => [
// 基础思考类
{ value: 'thinking', label: t('agentEditor.tools.thinking'), description: t('agentEditor.tools.thinkingDesc'), group: 'base' },
{ value: 'todo_write', label: t('agentEditor.tools.todoWrite'), description: t('agentEditor.tools.todoWriteDesc'), group: 'base' },
// 知识库语义/关键词检索
{ value: 'grep_chunks', label: t('agentEditor.tools.grepChunks'), description: t('agentEditor.tools.grepChunksDesc'), group: 'rag' },
{ value: 'knowledge_search', label: t('agentEditor.tools.knowledgeSearch'), description: t('agentEditor.tools.knowledgeSearchDesc'), group: 'rag' },
{ value: 'list_knowledge_chunks', label: t('agentEditor.tools.listChunks'), description: t('agentEditor.tools.listChunksDesc'), group: 'rag' },
{ value: 'query_knowledge_graph', label: t('agentEditor.tools.queryGraph'), description: t('agentEditor.tools.queryGraphDesc'), group: 'rag' },
{ value: 'get_document_info', label: t('agentEditor.tools.getDocInfo'), description: t('agentEditor.tools.getDocInfoDesc'), group: 'rag' },
{ value: 'database_query', label: t('agentEditor.tools.dbQuery'), description: t('agentEditor.tools.dbQueryDesc'), group: 'rag' },
// Wiki 读取类(阅读、搜索、标记问题)
{ value: 'wiki_search', label: t('agentEditor.tools.wikiSearch'), description: t('agentEditor.tools.wikiSearchDesc'), group: 'wiki_read' },
{ value: 'wiki_read_page', label: t('agentEditor.tools.wikiReadPage'), description: t('agentEditor.tools.wikiReadPageDesc'), group: 'wiki_read' },
{ value: 'wiki_read_source_doc', label: t('agentEditor.tools.wikiReadSourceDoc'), description: t('agentEditor.tools.wikiReadSourceDocDesc'), group: 'wiki_read' },
{ value: 'wiki_flag_issue', label: t('agentEditor.tools.wikiFlagIssue'), description: t('agentEditor.tools.wikiFlagIssueDesc'), group: 'wiki_read' },
// Wiki 编辑类(会直接修改 Wiki 内容)
{ value: 'wiki_write_page', label: t('agentEditor.tools.wikiWritePage'), description: t('agentEditor.tools.wikiWritePageDesc'), group: 'wiki_edit', danger: true },
{ value: 'wiki_replace_text', label: t('agentEditor.tools.wikiReplaceText'), description: t('agentEditor.tools.wikiReplaceTextDesc'), group: 'wiki_edit', danger: true },
{ value: 'wiki_rename_page', label: t('agentEditor.tools.wikiRenamePage'), description: t('agentEditor.tools.wikiRenamePageDesc'), group: 'wiki_edit', danger: true },
{ value: 'wiki_delete_page', label: t('agentEditor.tools.wikiDeletePage'), description: t('agentEditor.tools.wikiDeletePageDesc'), group: 'wiki_edit', danger: true },
// Wiki 巡检类
{ value: 'wiki_read_issue', label: t('agentEditor.tools.wikiReadIssue'), description: t('agentEditor.tools.wikiReadIssueDesc'), group: 'wiki_issue' },
{ value: 'wiki_update_issue', label: t('agentEditor.tools.wikiUpdateIssue'), description: t('agentEditor.tools.wikiUpdateIssueDesc'), group: 'wiki_issue' },
// 数据分析
{ value: 'data_analysis', label: t('agentEditor.tools.dataAnalysis'), description: t('agentEditor.tools.dataAnalysisDesc'), group: 'data' },
{ value: 'data_schema', label: t('agentEditor.tools.dataSchema'), description: t('agentEditor.tools.dataSchemaDesc'), group: 'data' },
]);
// 工具分组元信息
const toolGroups = computed(() => [
{ key: 'base', label: t('agentEditor.tools.groupBase') },
{ key: 'rag', label: t('agentEditor.tools.groupRag') },
{ key: 'wiki_read', label: t('agentEditor.tools.groupWikiRead') },
{ key: 'wiki_edit', label: t('agentEditor.tools.groupWikiEdit') },
{ key: 'wiki_issue', label: t('agentEditor.tools.groupWikiIssue') },
{ key: 'data', label: t('agentEditor.tools.groupData') },
]);
// 知识库分组:我的 vs 共享的
const myKbOptions = computed(() => kbOptions.value.filter(kb => !kb.shared));
const sharedKbOptions = computed(() => kbOptions.value.filter(kb => kb.shared));
// 根据知识库配置动态计算是否有知识库能力
const hasKnowledgeBase = computed(() => {
return kbSelectionMode.value !== 'none';
});
// 当前配置下进入到智能体作用域的知识库列表
// 注意:用户可能选了 knowledge_bases(按库级),也可能选了 knowledge_ids(按文档级)
// 这里仅用于 UI 上的工具可用性判定,按库级来计算
const kbsInScope = computed(() => {
if (kbSelectionMode.value === 'none') return [];
if (kbSelectionMode.value === 'all') return kbOptions.value;
const selectedIds = formData.value.config.knowledge_bases || [];
return kbOptions.value.filter(kb => selectedIds.includes(kb.value));
});
// 是否存在至少一个启用了 RAG 能力的知识库(向量 or 关键词)
const hasRagKnowledgeBase = computed(() => {
return kbsInScope.value.some(kb => kb.ragEnabled);
});
// 是否存在至少一个启用了 Wiki 能力的知识库
const hasWikiKnowledgeBase = computed(() => {
return kbsInScope.value.some(kb => kb.wikiEnabled);
});
// 作用域内 RAG/Wiki 知识库数量(用于顶部状态栏)
const ragKbCount = computed(() => kbsInScope.value.filter(kb => kb.ragEnabled).length);
const wikiKbCount = computed(() => kbsInScope.value.filter(kb => kb.wikiEnabled).length);
// 检测选择的知识库中是否包含 FAQ 类型
const hasFaqKnowledgeBase = computed(() => {
if (kbSelectionMode.value === 'none') return false;
if (kbSelectionMode.value === 'all') {
// 全部知识库模式,检查是否有任何 FAQ 类型的知识库
return kbOptions.value.some(kb => kb.type === 'faq');
}
// 指定知识库模式,检查选中的知识库中是否有 FAQ 类型
const selectedKbIds = formData.value.config.knowledge_bases || [];
return kbOptions.value.some(kb => selectedKbIds.includes(kb.value) && kb.type === 'faq');
});
// 把"作用域内 KB 能力"聚合成一个 ScopeCapabilities 对象,交给
// `evaluateToolRequirement` 统一判定;UI 上的所有可用性提示都应出自此处。
const scopeCapabilities = computed<ScopeCapabilities>(() => {
const scope: ScopeCapabilities = { vector: false, keyword: false, wiki: false, graph: false, faq: false };
for (const kb of kbsInScope.value) {
const caps = kb.capabilities;
if (caps) {
if (caps.vector) scope.vector = true;
if (caps.keyword) scope.keyword = true;
if (caps.wiki) scope.wiki = true;
if (caps.graph) scope.graph = true;
if (caps.faq) scope.faq = true;
} else {
// 向后兼容:capabilities 尚未加载时,退回到 ragEnabled/wikiEnabled 推断
if (kb.ragEnabled) { scope.vector = true; scope.keyword = true; }
if (kb.wikiEnabled) scope.wiki = true;
if (kb.type === 'faq') scope.faq = true;
}
}
return scope;
});
// 把 evaluateToolRequirement 返回的 missKind 映射到 i18n 文案。
// 新增的 needsGraph / needsFaq 暂时复用 requiresRagKb 文案("需要 RAG 知识库"),
// 后续可按需增加独立的 i18n 键。
const missKindToReason = (kind: RequirementMissKind): string | undefined => {
switch (kind) {
case 'needsKb': return t('agentEditor.tools.requiresKb');
case 'needsWiki': return t('agentEditor.tools.requiresWikiKb');
case 'needsRag':
case 'needsGraph':
case 'needsFaq': return t('agentEditor.tools.requiresRagKb');
case 'none':
default: return undefined;
}
};
const availableTools = computed(() => {
const scope = scopeCapabilities.value;
const hasAnyKb = hasKnowledgeBase.value;
return allTools.value.map(tool => {
const { ok, missKind } = evaluateToolRequirement(tool.value, scope, hasAnyKb);
return {
...tool,
disabled: !ok,
disabledReason: ok ? undefined : missKindToReason(missKind),
};
});
});
// 按分组切片后的工具列表,用于模板分组渲染
const groupedAvailableTools = computed(() => {
const map: Record<string, typeof availableTools.value> = {};
for (const tool of availableTools.value) {
const g = tool.group || 'base';
if (!map[g]) map[g] = [];
map[g].push(tool);
}
return toolGroups.value
.map(g => ({
...g,
tools: map[g.key] || [],
}))
.filter(g => g.tools.length > 0);
});
// ==================== 有效工具预览 ====================
// 最终运行时智能体实际能使用的工具集合(仅做预览展示)
// 规则:基于 allowed_tools 过滤
// 1) 勾选但缺失对应能力(无 KB / 无 Wiki 能力 KB)的工具会被灰显/隐藏
// 2) 无论是否勾选,web_search / web_fetch 随 web_search_enabled 出现
// 3) final_answer 始终存在
// 4) 当 kb_selection_mode === 'none' 时,RAG/Wiki 工具都视为不可用
const effectiveTools = computed(() => {
const chosen = new Set(formData.value.config.allowed_tools || []);
const items: Array<{ value: string; label: string; reason?: string; active: boolean }> = [];
for (const tool of availableTools.value) {
const picked = chosen.has(tool.value);
if (!picked) continue;
if (tool.disabled) {
items.push({ value: tool.value, label: tool.label, active: false, reason: tool.disabledReason });
} else {
items.push({ value: tool.value, label: tool.label, active: true });
}
}
if (formData.value.config.web_search_enabled) {
items.push({ value: 'web_search', label: t('agentEditor.tools.webSearch'), active: true });
items.push({ value: 'web_fetch', label: t('agentEditor.tools.webFetch'), active: true });
}
items.push({ value: 'final_answer', label: t('agentEditor.tools.finalAnswer'), active: true });
return items;
});
// 勾选了但当前配置下无法生效的工具数量(用于顶部状态提示)
const inactiveToolCount = computed(() => effectiveTools.value.filter(i => !i.active).length);
// 可用文件类型列表
const availableFileTypes = [
{ value: 'pdf', label: 'PDF', description: t('agentEditor.fileTypes.pdf') },
{ value: 'docx', label: 'Word', description: t('agentEditor.fileTypes.word') },
{ value: 'txt', label: t('agentEditor.fileTypes.textLabel'), description: t('agentEditor.fileTypes.text') },
{ value: 'md', label: 'Markdown', description: t('agentEditor.fileTypes.markdown') },
{ value: 'csv', label: 'CSV', description: t('agentEditor.fileTypes.csv') },
{ value: 'xlsx', label: 'Excel', description: t('agentEditor.fileTypes.excel') },
{ value: 'jpg', label: t('agentEditor.fileTypes.imageLabel'), description: t('agentEditor.fileTypes.image') },
];
// 占位符相关 - 从 API 获取
const placeholderData = ref<{
system_prompt: PlaceholderDefinition[];
agent_system_prompt: PlaceholderDefinition[];
context_template: PlaceholderDefinition[];
rewrite_system_prompt: PlaceholderDefinition[];
rewrite_prompt: PlaceholderDefinition[];
fallback_prompt: PlaceholderDefinition[];
}>({
system_prompt: [],
agent_system_prompt: [],
context_template: [],
rewrite_system_prompt: [],
rewrite_prompt: [],
fallback_prompt: [],
});
// 系统提示词占位符(根据模式动态选择)
const availablePlaceholders = computed(() => {
return isAgentMode.value ? placeholderData.value.agent_system_prompt : placeholderData.value.system_prompt;
});
// 上下文模板占位符
const contextTemplatePlaceholders = computed(() => placeholderData.value.context_template);
// 改写系统提示词占位符
const rewriteSystemPlaceholders = computed(() => placeholderData.value.rewrite_system_prompt);
// 改写用户提示词占位符
const rewritePlaceholders = computed(() => placeholderData.value.rewrite_prompt);
// 兜底提示词占位符
const fallbackPlaceholders = computed(() => placeholderData.value.fallback_prompt);
const promptTextareaRef = ref<any>(null);
const showPlaceholderPopup = ref(false);
const selectedPlaceholderIndex = ref(0);
const placeholderPrefix = ref('');
const popupStyle = ref({ top: '0px', left: '0px' });
let placeholderPopupTimer: any = null;
// 上下文模板占位符相关
const contextTemplateTextareaRef = ref<any>(null);
const showContextPlaceholderPopup = ref(false);
const selectedContextPlaceholderIndex = ref(0);
const contextPlaceholderPrefix = ref('');
const contextPopupStyle = ref({ top: '0px', left: '0px' });
let contextPlaceholderPopupTimer: any = null;
// 通用占位符弹出相关(用于改写提示词和兜底提示词)
interface PlaceholderPopupState {
show: boolean;
selectedIndex: number;
prefix: string;
style: { top: string; left: string };
timer: any;
fieldKey: string;
placeholders: PlaceholderDefinition[];
}
const rewriteSystemPopup = ref<PlaceholderPopupState>({
show: false, selectedIndex: 0, prefix: '', style: { top: '0px', left: '0px' }, timer: null, fieldKey: 'rewrite_prompt_system', placeholders: []
});
const rewriteUserPopup = ref<PlaceholderPopupState>({
show: false, selectedIndex: 0, prefix: '', style: { top: '0px', left: '0px' }, timer: null, fieldKey: 'rewrite_prompt_user', placeholders: []
});
const fallbackPromptPopup = ref<PlaceholderPopupState>({
show: false, selectedIndex: 0, prefix: '', style: { top: '0px', left: '0px' }, timer: null, fieldKey: 'fallback_prompt', placeholders: []
});
const rewriteSystemTextareaRef = ref<any>(null);
const rewriteUserTextareaRef = ref<any>(null);
const fallbackPromptTextareaRef = ref<any>(null);
const navItems = computed(() => {
const items: { key: string; icon: string; label: string }[] = [
{ key: 'basic', icon: 'info-circle', label: t('agent.editor.basicInfo') },
{ key: 'model', icon: 'control-platform', label: t('agent.editor.modelConfig') },
];
// 知识库配置(放在工具上面)
items.push({ key: 'knowledge', icon: 'folder', label: t('agent.editor.knowledgeConfig') });
// Agent模式才显示工具配置
if (isAgentMode.value) {
items.push({ key: 'tools', icon: 'tools', label: t('agent.editor.toolsConfig') });
}
// Agent 模式且沙箱已启用时才显示 Skills 配置(disabled 时无法启用 Skills)
if (isAgentMode.value && skillsAvailable.value) {
items.push({ key: 'skills', icon: 'lightbulb', label: t('agent.editor.skillsConfig') });
}
// 有知识库能力时才显示检索策略
if (hasKnowledgeBase.value) {
items.push({ key: 'retrieval', icon: 'search', label: t('agent.editor.retrievalStrategy') });
}
// 网络搜索(独立菜单)
items.push({ key: 'websearch', icon: 'internet', label: t('agent.editor.webSearchConfig') });
// 多模态配置(图片上传)
items.push({ key: 'multimodal', icon: 'image', label: t('agentEditor.imageUpload.navLabel') });
// 多轮对话(仅普通模式显示,Agent模式内部自动控制)
if (!isAgentMode.value) {
items.push({ key: 'conversation', icon: 'chat', label: t('agent.editor.conversationSettings') });
}
// 共享管理(仅编辑模式且非内置智能体,Lite 模式下隐藏)
if (props.mode === 'edit' && props.agent?.id && !props.agent?.is_builtin && !authStore.isLiteMode) {
items.push({ key: 'share', icon: 'share', label: t('knowledgeEditor.sidebar.share') });
}
// IM集成(仅编辑模式,创建时Agent还没有ID)
if (props.mode === 'edit' && props.agent?.id) {
items.push({ key: 'im', icon: 'chat-message', label: t('agentEditor.im.title') });
}
return items;
});
// 初始数据
const defaultFormData = {
name: '',
description: '',
is_builtin: false,
config: {
// 基础设置
agent_mode: 'smart-reasoning' as 'quick-answer' | 'smart-reasoning',
system_prompt: '',
context_template: '{{query}}',
// 模型设置
model_id: '',
rerank_model_id: '',
temperature: 0.7,
max_completion_tokens: 2048,
thinking: false, // 默认禁用思考模式
// Agent模式设置
max_iterations: 10,
llm_call_timeout: 120, // 120 seconds
allowed_tools: [] as string[],
reflection_enabled: false,
// MCP 服务设置
mcp_selection_mode: 'none' as 'all' | 'selected' | 'none',
mcp_services: [] as string[],
// Skills 设置
skills_selection_mode: 'none' as 'all' | 'selected' | 'none',
selected_skills: [] as string[],
// 知识库设置:新建智能体默认选择 "全部知识库",
// 让用户无需先去勾选 KB 即可上手;如有需要可改为 "selected" / "none"。
kb_selection_mode: 'all' as 'all' | 'selected' | 'none',
knowledge_bases: [] as string[],
retrieve_kb_only_when_mentioned: false,
// 智能推理下的类型预设:新建 agent 时默认给 RAG 问答(最常用场景)。
// 编辑既有 agent 时会被 agent 自己保存的 agent_type 覆盖。
agent_type: 'rag-qa' as AgentType,
system_prompt_id: '' as string,
// 图片上传/多模态设置
image_upload_enabled: false,
vlm_model_id: '',
image_storage_provider: '',
// 文件类型限制
supported_file_types: [] as string[],
// 数据分析阶段开关(默认关闭,避免在普通问答上多一次 LLM 调用生成 SQL)
data_analysis_enabled: false,
// FAQ 策略设置
faq_priority_enabled: true, // 是否启用 FAQ 优先策略
faq_direct_answer_threshold: 0.9, // FAQ 直接回答阈值(相似度高于此值直接使用 FAQ 答案)
faq_score_boost: 1.2, // FAQ 分数加权系数
// 网络搜索设置
web_search_enabled: false,
web_search_max_results: 5,
// 多轮对话设置
multi_turn_enabled: false,
history_turns: 5,
// 检索策略设置
embedding_top_k: 10,
keyword_threshold: 0.3,
vector_threshold: 0.5,
rerank_top_k: 5,
rerank_threshold: 0.5,
// 高级设置(普通模式)
enable_query_expansion: true,
enable_rewrite: true,
query_understand_model_id: '',
rewrite_prompt_system: '',
rewrite_prompt_user: '',
fallback_strategy: 'model' as 'fixed' | 'model',
fallback_response: '',
fallback_prompt: '',
// 已废弃字段(保留兼容)
welcome_message: '',
suggested_prompts: [] as string[],
}
};
const formData = ref(JSON.parse(JSON.stringify(defaultFormData)));
const agentMode = computed({
get: () => formData.value.config.agent_mode,
set: (val: 'quick-answer' | 'smart-reasoning') => { formData.value.config.agent_mode = val; }
});
const isAgentMode = computed(() => agentMode.value === 'smart-reasoning');
// ============================================================================
// 智能体类型预设(仅 smart-reasoning 模式下可见)
// 选择类型后自动填充 system_prompt_id / allowed_tools 等;
// 选择 "custom" 或没有匹配预设时不做任何覆盖。
// ============================================================================
const agentType = computed({
get: () => (formData.value.config.agent_type as AgentType) || 'custom',
set: (val: AgentType) => { formData.value.config.agent_type = val; },
});
// 当前激活的预设对象(用于 KB 过滤 / UI 徽章)
const activeAgentTypePreset = computed<AgentTypePreset | null>(() => {
if (!isAgentMode.value) return null;
const id = agentType.value;
if (!id || id === 'custom') return null;
return agentTypePresets.value.find(p => p.id === id) || null;
});
// 根据当前 locale 挑选 i18n 标签
const agentTypePresetLabel = (p: AgentTypePreset): string => {
const locale = i18nLocale.value || 'default';
return p.i18n?.[locale]?.label || p.i18n?.default?.label || p.id;
};
const agentTypePresetDescription = (p: AgentTypePreset): string => {
const locale = i18nLocale.value || 'default';
return p.i18n?.[locale]?.description || p.i18n?.default?.description || '';
};
// t-select 的 options 数据:label 给 TDesign 自己(用于选中态显示),desc 走自定义 option slot
const agentTypeSelectOptions = computed(() => {
return agentTypePresets.value.map(p => ({
value: p.id,
label: agentTypePresetLabel(p),
desc: agentTypePresetDescription(p),
}));
});
// 为每个预设生成"我的 <label>"的默认名称,让用户可以一键保存
// custom 类型默认名为空(让用户自己想)
const getPresetDefaultName = (preset: AgentTypePreset | null): string => {
if (!preset || preset.id === 'custom') return '';
return t('agentEditor.agentType.defaultNamePattern', { label: agentTypePresetLabel(preset) });
};
const getPresetDefaultDescription = (preset: AgentTypePreset | null): string => {
if (!preset) return '';
return agentTypePresetDescription(preset);
};
// 判断当前名称/描述是否由系统自动填入(任一预设的默认值 或 空)
// 用于在切换类型时只覆盖"未被用户手动编辑"的值,避免覆盖用户输入
const isNameSystemGenerated = (name: string): boolean => {
if (!name) return true;
return agentTypePresets.value.some(p => getPresetDefaultName(p) === name);
};
const isDescriptionSystemGenerated = (desc: string): boolean => {
if (!desc) return true;
return agentTypePresets.value.some(p => getPresetDefaultDescription(p) === desc);
};
// 按预设 id 返回面向用户的不兼容原因文案。
// 不要直接把 "vector / keyword / wiki" 这些底层 capability 名回传给用户 —
// 用户不关心技术实现,只想知道"为什么我这个知识库不能用"。
const presetKbMismatchKeyMap: Record<string, string> = {
'rag-qa': 'ragQa',
'wiki-qa': 'wikiQa',
'hybrid-rag-wiki': 'hybridRagWiki',
'data-analysis': 'dataAnalysis',
};
const presetKbMismatchReason = (preset: AgentTypePreset): string => {
const subKey = presetKbMismatchKeyMap[preset.id];
if (subKey) return t(`agentEditor.agentType.kbMismatch.${subKey}`);
return t('agentEditor.agentType.kbMismatch.generic');
};
// 计算预设的"有效 KB 过滤器":工具推导 + YAML 增量叠加。
//
// 设计原则:
// - 工具 → any_of("KB 至少要能被其中一个工具用得上")由
// `deriveKbFilterFromTools` 自动算出;
// - YAML 里的 `kb_filter` 只负责**工具推不出来**的业务规则(如
// data-analysis 的 `none_of: ["faq"]`),作为增量合并,而不是整体覆盖;
// - `all_of` / `none_of` 直接从 YAML 继承(工具不表达这类约束)。
//
// 这样 rag-qa / wiki-qa / hybrid 在 YAML 里彻底不写 `kb_filter`,
// data-analysis 只需声明额外的 `none_of`,"工具→能力"的映射只在
// `@/utils/tool-capabilities` 维护一份。
const effectiveKbFilter = (preset: AgentTypePreset | null): AgentTypeKBFilter | null => {
if (!preset) return null;
const derived = deriveKbFilterFromTools(preset.config?.allowed_tools || []);
const yaml = preset.kb_filter;
// YAML 提供 any_of 时整体覆盖推导(给显式控制留口子);否则用推导的
const anyOf = (yaml?.any_of && yaml.any_of.length > 0) ? yaml.any_of : (derived?.any_of ?? []);
const allOf = yaml?.all_of ?? [];
const noneOf = yaml?.none_of ?? [];
if (anyOf.length === 0 && allOf.length === 0 && noneOf.length === 0) return null;
return { any_of: anyOf, all_of: allOf, none_of: noneOf };
};
// 评估单个 KB 是否满足给定预设的 kb_filter
const kbSatisfiesPresetFilter = (kb: { capabilities?: KBCapabilities; ragEnabled?: boolean; wikiEnabled?: boolean; type?: string }, preset: AgentTypePreset | null): { ok: boolean; reason: string } => {
const filter = effectiveKbFilter(preset);
if (!preset || !filter) return { ok: true, reason: '' };
const caps = kb.capabilities || {
vector: !!kb.ragEnabled,
keyword: !!kb.ragEnabled,
wiki: !!kb.wikiEnabled,
graph: false,
faq: kb.type === 'faq',
};
const has = (name: string): boolean => {
switch (name) {
case 'vector': return !!caps.vector;
case 'keyword': return !!caps.keyword;
case 'wiki': return !!caps.wiki;
case 'graph': return !!caps.graph;
case 'faq': return !!caps.faq;
default: return false;
}
};
const reason = presetKbMismatchReason(preset);
if (filter.all_of && filter.all_of.length > 0) {
for (const n of filter.all_of) {
if (!has(n)) return { ok: false, reason };
}
}
if (filter.any_of && filter.any_of.length > 0) {
if (!filter.any_of.some(n => has(n))) {
return { ok: false, reason };
}
}
if (filter.none_of && filter.none_of.length > 0) {
for (const n of filter.none_of) {
if (has(n)) return { ok: false, reason };
}
}
return { ok: true, reason: '' };
};
// "快速问答 / RAG 模式"对 KB 的隐式要求:必须有 vector 或 keyword 索引。
// 这里跟 `activeAgentTypePreset` 解耦——quick-answer 没有 agent_type,
// 所以预设链路恒为 null,但 wiki-only KB 在 RAG 模式下检索结果永远为空,
// 必须在 UI 上 disable + 提示,避免用户白选。
const kbSatisfiesQuickAnswerMode = (kb: { capabilities?: KBCapabilities; ragEnabled?: boolean }): { ok: boolean; reason: string } => {
if (agentMode.value !== 'quick-answer') return { ok: true, reason: '' };
const hasRag = kb.capabilities
? (!!kb.capabilities.vector || !!kb.capabilities.keyword)
: !!kb.ragEnabled;
if (hasRag) return { ok: true, reason: '' };
return { ok: false, reason: t('agentEditor.agentType.kbMismatch.quickAnswer') };
};
// KB 过滤后的选项(用于"指定知识库"下拉)— 不满足的仍保留但标记 disabled + tooltip
const filteredKbOptionsForPreset = computed(() => {
const preset = activeAgentTypePreset.value;
return kbOptions.value.map(kb => {
const presetResult = kbSatisfiesPresetFilter(kb, preset);
const modeResult = kbSatisfiesQuickAnswerMode(kb);
const ok = presetResult.ok && modeResult.ok;
const reason = !presetResult.ok ? presetResult.reason : (!modeResult.ok ? modeResult.reason : '');
return { ...kb, disabled: !ok, disabledReason: reason };
});
});
const filteredMyKbOptions = computed(() => filteredKbOptionsForPreset.value.filter(kb => !kb.shared));
const filteredSharedKbOptions = computed(() => filteredKbOptionsForPreset.value.filter(kb => kb.shared));
// 当前选中的 KB 中,有多少个在新预设 / 模式下会被禁用(用于保存前提示)。
// quick-answer 模式下 preset 恒为 null,但 wiki-only KB 仍属"被禁用",
// 所以这里不再依赖 preset 是否存在,直接看是否有被 disable 的选中项。
const incompatibleSelectedKbCount = computed(() => {
if (kbSelectionMode.value !== 'selected') return 0;
const selected = new Set(formData.value.config.knowledge_bases || []);
return filteredKbOptionsForPreset.value.filter(kb => selected.has(kb.value) && kb.disabled).length;
});
// 应用一个预设的 config 到 formData.config(仅覆盖预设里明确设置的字段,其他不动)
const applyAgentTypePreset = (preset: AgentTypePreset | null) => {
if (!preset || !preset.config) return;
const c = preset.config;
const target = formData.value.config;
if (c.system_prompt_id !== undefined) {
target.system_prompt_id = c.system_prompt_id;
// 根据 system_prompt_id 从已加载的模板列表里查出正文并回填到用户可见的 textarea
const tmpl = agentSystemPromptTemplates.value.find(t => t.id === c.system_prompt_id);
if (tmpl && typeof tmpl.content === 'string') {
target.system_prompt = tmpl.content;
} else {
// 模板列表还没加载完 / 或预设引用了不存在的 id:清空让用户感知到变化
target.system_prompt = '';
if (c.system_prompt_id) {
console.warn(`[AgentType] system_prompt_id "${c.system_prompt_id}" not found in agent_system_prompt templates`);
}
}
}
if (typeof c.temperature === 'number') target.temperature = c.temperature;
if (typeof c.max_iterations === 'number') target.max_iterations = c.max_iterations;
if (Array.isArray(c.allowed_tools)) target.allowed_tools = [...c.allowed_tools];
if (typeof c.retain_retrieval_history === 'boolean') target.retain_retrieval_history = c.retain_retrieval_history;
if (typeof c.faq_priority_enabled === 'boolean') target.faq_priority_enabled = c.faq_priority_enabled;
if (typeof c.web_search_enabled === 'boolean') target.web_search_enabled = c.web_search_enabled;
// supported_file_types 采用"强同步"语义:只有 data-analysis 需要限定 csv/xlsx,
// 其余类型切过来时必须清空,否则会从上一个类型带过来残留。
if (Array.isArray(c.supported_file_types)) {
target.supported_file_types = [...c.supported_file_types];
} else {
target.supported_file_types = [];
}
// kb_selection_mode 同步到 formData 以及 UI 状态(两处都要改,否则单选按钮不更新)
if (c.kb_selection_mode) {
target.kb_selection_mode = c.kb_selection_mode;
kbSelectionMode.value = c.kb_selection_mode;
}
};
// 用户手动切换类型 → 应用预设
const onAgentTypeChange = (val: AgentType) => {
// 切换前捕获"名称/描述是否可安全覆盖"
// 已编辑过的用户输入(不等于任何预设默认值)绝不覆盖
const canOverrideName = isNameSystemGenerated(formData.value.name);
const canOverrideDesc = isDescriptionSystemGenerated(formData.value.description);
agentType.value = val;
const preset = agentTypePresets.value.find(p => p.id === val) || null;
if (val !== 'custom') {
applyAgentTypePreset(preset);
}
// 用新预设的默认名/描述刷新自动填充字段
if (canOverrideName) {
formData.value.name = getPresetDefaultName(preset);
}
if (canOverrideDesc) {
formData.value.description = getPresetDefaultDescription(preset);
}
// 如果新预设与当前已选 KB 冲突,软提示(不强制移除)
if (incompatibleSelectedKbCount.value > 0) {
MessagePlugin.warning(
t('agentEditor.agentType.kbIncompatibleWarn', { count: incompatibleSelectedKbCount.value }),
4000,
);
}
};
// 思考模式计算属性(直接绑定 boolean)
const thinkingEnabled = computed({
get: () => formData.value.config.thinking === true,
set: (val: boolean) => { formData.value.config.thinking = val; }
});
// 是否为内置智能体
const isBuiltinAgent = computed(() => {
return formData.value.is_builtin === true;
});
// 系统提示词的 placeholder
const systemPromptPlaceholder = computed(() => {
return t('agent.editor.systemPromptPlaceholder');
});
// 上下文模板的 placeholder
const contextTemplatePlaceholder = computed(() => {
return t('agent.editor.contextTemplatePlaceholder');
});
// 是否需要配置 ReRank 模型(仅当关联的知识库中有 RAG 类型时需要)
const needsRerankModel = computed(() => {
if (!hasKnowledgeBase.value) return false;
const mode = kbSelectionMode.value;
if (mode === 'all') {
// "全部"模式下,只要存在任何一个 RAG 知识库就需要
return kbOptions.value.some(kb => kb.ragEnabled);
}
if (mode === 'selected') {
const selectedIds = formData.value.config.knowledge_bases || [];
return kbOptions.value.some(kb => selectedIds.includes(kb.value) && kb.ragEnabled);
}
return false;
});
// 监听可见性变化,重置表单
watch(() => props.visible, async (val) => {
if (val) {
currentSection.value = props.initialSection || 'basic';
// 先加载依赖数据(包括默认配置)
await loadDependencies();
if (props.mode === 'edit' && props.agent) {
// 深度复制对象以避免引用问题
const agentData = JSON.parse(JSON.stringify(props.agent));
// 确保 config 对象存在
if (!agentData.config) {
agentData.config = JSON.parse(JSON.stringify(defaultFormData.config));
}
// 补全可能缺失的字段
agentData.config = { ...defaultFormData.config, ...agentData.config };
// 确保数组字段存在
if (!agentData.config.suggested_prompts) agentData.config.suggested_prompts = [];
if (!agentData.config.knowledge_bases) agentData.config.knowledge_bases = [];
if (!agentData.config.allowed_tools) agentData.config.allowed_tools = [];
if (!agentData.config.mcp_services) agentData.config.mcp_services = [];
if (!agentData.config.selected_skills) agentData.config.selected_skills = [];
if (!agentData.config.supported_file_types) agentData.config.supported_file_types = [];
// 兼容旧数据:如果没有 agent_mode 字段,根据 allowed_tools 推断
if (!agentData.config.agent_mode) {
const isAgent = agentData.config.max_iterations > 1 || (agentData.config.allowed_tools && agentData.config.allowed_tools.length > 0);
agentData.config.agent_mode = isAgent ? 'smart-reasoning' : 'quick-answer';
}
// 设置初始化标志,防止 watch 自动添加工具
isInitializing.value = true;
formData.value = agentData;
// 初始化知识库选择模式
initKbSelectionMode();
initMcpSelectionMode();
initSkillsSelectionMode();
// 初始化完成后重置标志
nextTick(() => {
isInitializing.value = false;
});
// 内置智能体:如果提示词为空,填入系统默认值
if (agentData.is_builtin) {
fillBuiltinAgentDefaults();
}
} else {
// 创建新智能体,使用系统默认值
const newFormData = JSON.parse(JSON.stringify(defaultFormData));
// 应用系统默认检索参数
newFormData.config.embedding_top_k = defaultEmbeddingTopK.value;
newFormData.config.keyword_threshold = defaultKeywordThreshold.value;
newFormData.config.vector_threshold = defaultVectorThreshold.value;
newFormData.config.rerank_top_k = defaultRerankTopK.value;
newFormData.config.rerank_threshold = defaultRerankThreshold.value;
newFormData.config.max_completion_tokens = defaultMaxCompletionTokens.value;
newFormData.config.temperature = defaultTemperature.value;
// 应用系统默认提示词(根据模式填充)
const isAgent = newFormData.config.agent_mode === 'smart-reasoning';
if (isAgent) {
// Agent 模式使用 agent-config 的默认系统提示词
if (defaultAgentSystemPrompt.value) {
newFormData.config.system_prompt = defaultAgentSystemPrompt.value;
}
} else {
// 快速问答模式:默认提示词来自 prompt-templates 的 default 项
if (defaultNormalSystemPrompt.value) {
newFormData.config.system_prompt = defaultNormalSystemPrompt.value;
}
if (defaultContextTemplate.value) {
newFormData.config.context_template = defaultContextTemplate.value;
}
if (defaultRewritePromptSystem.value) {
newFormData.config.rewrite_prompt_system = defaultRewritePromptSystem.value;
}
if (defaultRewritePromptUser.value) {
newFormData.config.rewrite_prompt_user = defaultRewritePromptUser.value;
}
if (defaultFallbackPrompt.value) {
newFormData.config.fallback_prompt = defaultFallbackPrompt.value;
}
if (defaultFallbackResponse.value) {
newFormData.config.fallback_response = defaultFallbackResponse.value;
}
}
formData.value = newFormData;
// 新建智能体:知识库默认 "全部",MCP / Skills 仍默认 "不使用"。
kbSelectionMode.value = 'all';
mcpSelectionMode.value = 'none';
skillsSelectionMode.value = 'none';
// 新建智能推理 agent 时,立即应用默认的 agent_type 预设
// (补齐 system_prompt / allowed_tools / kb_selection_mode 等),
// 否则用户在 modal 打开瞬间看到的"默认表单"和类型下拉显示的类型不一致。
if (newFormData.config.agent_mode === 'smart-reasoning') {
const defaultTypeId = newFormData.config.agent_type as AgentType;
const preset = agentTypePresets.value.find(p => p.id === defaultTypeId) || null;
if (defaultTypeId && defaultTypeId !== 'custom') {
applyAgentTypePreset(preset);
}
// 给新建表单补上"我的 XXX"默认名 + 预设描述,让用户可直接保存;
// 用户输入过的值不会被覆盖(此处是新建场景,字段必定为空)。
if (!formData.value.name) {
formData.value.name = getPresetDefaultName(preset);
}
if (!formData.value.description) {
formData.value.description = getPresetDefaultDescription(preset);
}
}
}
}
});
// 初始化知识库选择模式
const initKbSelectionMode = () => {
if (formData.value.config.kb_selection_mode) {
// 如果有保存的模式,直接使用
kbSelectionMode.value = formData.value.config.kb_selection_mode;
} else if (formData.value.config.knowledge_bases?.length > 0) {
// 有指定知识库
kbSelectionMode.value = 'selected';
} else {
kbSelectionMode.value = 'none';
}
};
// 初始化 MCP 选择模式
const initMcpSelectionMode = () => {
if (formData.value.config.mcp_selection_mode) {
// 如果有保存的模式,直接使用
mcpSelectionMode.value = formData.value.config.mcp_selection_mode;
} else if (formData.value.config.mcp_services?.length > 0) {
// 有指定 MCP 服务
mcpSelectionMode.value = 'selected';
} else {
mcpSelectionMode.value = 'none';
}
};
// 初始化 Skills 选择模式
const initSkillsSelectionMode = () => {
if (formData.value.config.skills_selection_mode) {
// 如果有保存的模式,直接使用
skillsSelectionMode.value = formData.value.config.skills_selection_mode;
} else if (formData.value.config.selected_skills?.length > 0) {
// 有指定 Skills
skillsSelectionMode.value = 'selected';
} else {
skillsSelectionMode.value = 'none';
}
};
// 内置智能体:填入系统默认值
const fillBuiltinAgentDefaults = () => {
const config = formData.value.config;
const isAgent = config.agent_mode === 'smart-reasoning';
if (isAgent) {
// Agent 模式:使用 agent-config 的默认提示词
if (!config.system_prompt && defaultAgentSystemPrompt.value) {
config.system_prompt = defaultAgentSystemPrompt.value;
}
} else {
// 普通模式:默认系统提示词、上下文模板等来自 prompt-templates 的 default 项
if (!config.system_prompt && defaultNormalSystemPrompt.value) {
config.system_prompt = defaultNormalSystemPrompt.value;
}
if (!config.context_template && defaultContextTemplate.value) {
config.context_template = defaultContextTemplate.value;
}
}
// 通用默认值
if (!config.rewrite_prompt_system && defaultRewritePromptSystem.value) {
config.rewrite_prompt_system = defaultRewritePromptSystem.value;
}
if (!config.rewrite_prompt_user && defaultRewritePromptUser.value) {
config.rewrite_prompt_user = defaultRewritePromptUser.value;
}
if (!config.fallback_prompt && defaultFallbackPrompt.value) {
config.fallback_prompt = defaultFallbackPrompt.value;
}
if (!config.fallback_response && defaultFallbackResponse.value) {
config.fallback_response = defaultFallbackResponse.value;
}
};
// 监听知识库选择模式变化
watch(kbSelectionMode, (mode) => {
formData.value.config.kb_selection_mode = mode;
if (mode === 'none') {
// 不使用知识库,清空相关配置
formData.value.config.knowledge_bases = [];
} else if (mode === 'all') {
// 全部知识库,清空指定列表
formData.value.config.knowledge_bases = [];
}
// selected 模式保持 knowledge_bases 不变
});
// 监听 MCP 选择模式变化
watch(mcpSelectionMode, (mode) => {
formData.value.config.mcp_selection_mode = mode;
if (mode === 'none') {
// 不使用 MCP,清空相关配置
formData.value.config.mcp_services = [];
} else if (mode === 'all') {
// 全部 MCP,清空指定列表
formData.value.config.mcp_services = [];
}
// selected 模式保持 mcp_services 不变
});
// 监听 Skills 选择模式变化
watch(skillsSelectionMode, (mode) => {
formData.value.config.skills_selection_mode = mode;
if (mode === 'none') {
// 不使用 Skills,清空相关配置
formData.value.config.selected_skills = [];
} else if (mode === 'all') {
// 全部 Skills,清空指定列表
formData.value.config.selected_skills = [];
}
// selected 模式保持 selected_skills 不变
});
// 监听模式变化,自动调整配置
watch(agentMode, (val, _oldVal) => {
if (val === 'smart-reasoning') {
// 切换到 Agent 模式,根据知识库配置启用工具。
// 注意:默认不注入 thinking / todo_write —— 它们用于显式反思或多步计划,
// 会显著增加 token 消耗,用户按需手动勾选。
if (formData.value.config.allowed_tools.length === 0) {
const tools: string[] = [];
if (hasRagKnowledgeBase.value) {
tools.push(
'knowledge_search',
'grep_chunks',
'list_knowledge_chunks',
'query_knowledge_graph',
'get_document_info',
'database_query',
);
}
if (hasWikiKnowledgeBase.value) {
tools.push(...wikiReadTools);
}
formData.value.config.allowed_tools = tools;
}
if (formData.value.config.max_iterations <= 1) {
formData.value.config.max_iterations = 10;
}
// 切换到 Agent 模式时,如果系统提示词是快速问答的默认值或为空,替换为 Agent 默认提示词
if (defaultAgentSystemPrompt.value) {
const isDefaultNormalPrompt = formData.value.config.system_prompt === defaultNormalSystemPrompt.value;
if (!formData.value.config.system_prompt || isDefaultNormalPrompt) {
formData.value.config.system_prompt = defaultAgentSystemPrompt.value;
}
}
} else {
// 切换到普通模式,清空工具
formData.value.config.allowed_tools = [];
formData.value.config.max_iterations = 1; // 设置为1表示单轮 RAG
// 切换到快速问答模式时,如果系统提示词是 Agent 的默认值或为空,替换为快速问答默认提示词
if (defaultNormalSystemPrompt.value) {
const isDefaultAgentPrompt = formData.value.config.system_prompt === defaultAgentSystemPrompt.value;
if (!formData.value.config.system_prompt || isDefaultAgentPrompt) {
formData.value.config.system_prompt = defaultNormalSystemPrompt.value;
}
}
// 其他提示词只在为空时填充
if (!formData.value.config.context_template && defaultContextTemplate.value) {
formData.value.config.context_template = defaultContextTemplate.value;
}
if (!formData.value.config.rewrite_prompt_system && defaultRewritePromptSystem.value) {
formData.value.config.rewrite_prompt_system = defaultRewritePromptSystem.value;
}
if (!formData.value.config.rewrite_prompt_user && defaultRewritePromptUser.value) {
formData.value.config.rewrite_prompt_user = defaultRewritePromptUser.value;
}
if (!formData.value.config.fallback_prompt && defaultFallbackPrompt.value) {
formData.value.config.fallback_prompt = defaultFallbackPrompt.value;
}
if (!formData.value.config.fallback_response && defaultFallbackResponse.value) {
formData.value.config.fallback_response = defaultFallbackResponse.value;
}
}
});
// 监听知识库启用状态变化:
// - 从"无"变"有":自动补齐 RAG 基础工具,方便用户开箱即用(仅 seed 行为);
// - 从"有"变"无":**不再**自动擦工具,依赖不满足时由 `availableTools` 灰显
// + 运行时工具注册器过滤。`allowed_tools` 代表用户意图,只应在用户显式操作
// (切 agent_type / 切 agent_mode / 手勾工具)时变更。
// 历史背景:旧版本在 KB 能力消失时会擦除 KB/Wiki 工具,导致用户切换
// `kb_selection_mode` 到 "selected"、但尚未勾具体 KB 的过渡期里静默丢失工具,
// 对默认工具全是 wiki_* 的内置"维基问答"智能体尤为致命。
watch(hasKnowledgeBase, (hasKB, oldHasKB) => {
// 如果当前在检索策略页面但没有知识库能力了,切换到基础设置
if (!hasKB && currentSection.value === 'retrieval') {
currentSection.value = 'basic';
}
// 初始化期间或非 Agent 模式下不自动调整工具
if (isInitializing.value || !isAgentMode.value) return;
if (hasKB && !oldHasKB) {
// 从无知识库变为有知识库,seed 默认的 RAG 工具(仅补齐未勾的)
const currentTools = formData.value.config.allowed_tools || [];
const toolsToAdd = knowledgeBaseTools.filter((tool: string) => !currentTools.includes(tool));
formData.value.config.allowed_tools = [...currentTools, ...toolsToAdd];
}
});
// 监听运行模式变化,自动切换页面
watch(isAgentMode, (isAgent) => {
// 如果当前在高级设置页面但切换到了Agent模式,切换到基础设置
if (isAgent && currentSection.value === 'advanced') {
currentSection.value = 'basic';
}
// 如果当前在多轮对话页面但切换到了Agent模式,切换到基础设置(Agent模式下多轮对话由内部控制)
if (isAgent && currentSection.value === 'conversation') {
currentSection.value = 'basic';
}
});
// 监听设置弹窗关闭,刷新模型列表
watch(() => uiStore.showSettingsModal, async (visible, prevVisible) => {
// 从设置页面返回时(弹窗关闭),刷新模型列表
if (prevVisible && !visible && props.visible) {
try {
const [models, statusRes] = await Promise.all([
listModels(),
getStorageEngineStatus(),
]);
if (models && models.length > 0) {
allModels.value = models;
}
if (statusRes?.data?.engines) {
storageEngineStatus.value = statusRes.data.engines;
}
} catch (e) {
console.warn('Failed to refresh data after settings closed', e);
}
}
});
// 加载依赖数据
const loadDependencies = async () => {
try {
// 加载所有模型列表(ModelSelector 组件会自动按类型过滤)
const models = await listModels();
if (models && models.length > 0) {
allModels.value = models;
}
// 加载知识库列表(我的 + 共享的)
const kbRes: any = await listKnowledgeBases();
const myKbs: typeof kbOptions.value = [];
if (kbRes.data) {
kbRes.data.forEach((kb: any) => {
const strategy = kb.indexing_strategy;
const caps: KBCapabilities | undefined = kb.capabilities;
myKbs.push({
label: kb.name,
value: kb.id,
type: kb.type || 'document',
count: kb.type === 'faq' ? (kb.chunk_count || 0) : (kb.knowledge_count || 0),
shared: false,
ragEnabled: caps ? (caps.vector || caps.keyword) : (!strategy || strategy.vector_enabled || strategy.keyword_enabled),
wikiEnabled: caps ? caps.wiki : (strategy?.wiki_enabled || false),
capabilities: caps,
});
});
}
// 加载共享给我的知识库
const sharedKbs: typeof kbOptions.value = [];
try {
const sharedList = await orgStore.fetchSharedKnowledgeBases();
if (sharedList && sharedList.length > 0) {
const myKbIds = new Set(myKbs.map(kb => kb.value));
sharedList.forEach((shared: any) => {
const kb = shared.knowledge_base;
if (!kb || myKbIds.has(kb.id)) return;
const caps: KBCapabilities | undefined = kb.capabilities;
sharedKbs.push({
label: kb.name,
value: kb.id,
type: kb.type || 'document',
count: kb.type === 'faq' ? (kb.chunk_count || 0) : (kb.knowledge_count || 0),
shared: true,
orgName: shared.org_name,
ragEnabled: caps ? (caps.vector || caps.keyword) : (!kb.indexing_strategy || kb.indexing_strategy.vector_enabled || kb.indexing_strategy.keyword_enabled),
wikiEnabled: caps ? caps.wiki : (kb.indexing_strategy?.wiki_enabled || false),
capabilities: caps,
});
});
}
} catch (e) {
console.warn('Failed to load shared knowledge bases', e);
}
kbOptions.value = [...myKbs, ...sharedKbs];
// 加载 MCP 服务列表(只加载启用的)
try {
const mcpList = await listMCPServices();
if (mcpList && mcpList.length > 0) {
mcpOptions.value = mcpList
.filter((mcp: MCPService) => mcp.enabled)
.map((mcp: MCPService) => ({ label: mcp.name, value: mcp.id }));
}
} catch (e) {
console.warn('Failed to load MCP services', e);
}
// 加载预装 Skills 列表及沙箱可用性(skills_available=false 时前端不展示 Skills 配置)
try {
const skillsRes = await listSkills();
skillsAvailable.value = skillsRes.skills_available !== false;
if (skillsRes.data && skillsRes.data.length > 0) {
skillOptions.value = skillsRes.data;
}
} catch (e) {
console.warn('Failed to load skills', e);
skillsAvailable.value = false;
}
// 加载智能体类型预设(smart-reasoning 模式下的"类型"下拉)
try {
const presetsRes: any = await getAgentTypePresets();
if (presetsRes?.data && Array.isArray(presetsRes.data)) {
agentTypePresets.value = presetsRes.data as AgentTypePreset[];
}
} catch (e) {
console.warn('Failed to load agent type presets', e);
}
// 加载 prompt-templates 用于:
// 1. agent_system_prompt 列表(applyAgentTypePreset 通过 system_prompt_id 回填正文)
// 2. 普通模式(builtin-quick-answer)创建时的默认 system_prompt / context_template /
// rewrite / fallback —— 取每个分类里 default: true 的那条
try {
const tmplRes = await getPromptTemplates();
const cfg = tmplRes?.data;
if (cfg?.agent_system_prompt && Array.isArray(cfg.agent_system_prompt)) {
agentSystemPromptTemplates.value = cfg.agent_system_prompt;
}
const pickDefault = (arr?: PromptTemplate[]): PromptTemplate | undefined =>
Array.isArray(arr) ? arr.find(t => t.default) : undefined;
const sysPrompt = pickDefault(cfg?.system_prompt);
if (sysPrompt?.content) defaultNormalSystemPrompt.value = sysPrompt.content;
const ctxTmpl = pickDefault(cfg?.context_template);
if (ctxTmpl?.content) defaultContextTemplate.value = ctxTmpl.content;
const rewriteTmpl = pickDefault(cfg?.rewrite);
if (rewriteTmpl?.content) defaultRewritePromptSystem.value = rewriteTmpl.content;
if (rewriteTmpl?.user) defaultRewritePromptUser.value = rewriteTmpl.user;
// fallback templates split into fixed-response (default) and model-mode (mode === 'model').
const fallbackList = Array.isArray(cfg?.fallback) ? cfg.fallback : [];
const fixedFallback = fallbackList.find(t => t.default && t.mode !== 'model');
if (fixedFallback?.content) defaultFallbackResponse.value = fixedFallback.content;
const modelFallback = fallbackList.find(t => t.mode === 'model' && t.default) || fallbackList.find(t => t.mode === 'model');
if (modelFallback?.content) defaultFallbackPrompt.value = modelFallback.content;
} catch (e) {
console.warn('Failed to load prompt templates', e);
}
// 加载存储引擎可用状态(用于图片存储 provider 选择)
try {
const statusRes = await getStorageEngineStatus();
if (statusRes?.data?.engines) {
storageEngineStatus.value = statusRes.data.engines;
}
} catch (e) {
console.warn('Failed to load storage engine status', e);
}
// 加载网络搜索引擎配置列表
try {
const wsRes: any = await listWebSearchProviders();
if (wsRes?.data && Array.isArray(wsRes.data)) {
webSearchProviderList.value = wsRes.data;
}
} catch (e) {
console.warn('Failed to load web search providers', e);
}
// 加载占位符定义(从统一 API)
try {
const placeholdersRes = await getPlaceholders();
if (placeholdersRes.data) {
placeholderData.value = placeholdersRes.data;
}
} catch (e) {
console.warn('Failed to load placeholders', e);
}
// 加载 Agent 模式默认提示词(来自 agent-config,用于 smart-reasoning 模式)
try {
const agentConfig = await getAgentConfig();
if (agentConfig.data?.system_prompt) {
defaultAgentSystemPrompt.value = agentConfig.data.system_prompt;
}
} catch (e) {
console.warn('Failed to load agent config', e);
}
// 加载租户检索默认参数(embedding_top_k / 阈值 / rerank top_k 等)。
// temperature 与 max_completion_tokens 不在 RetrievalConfig 中,保持组件内
// 硬编码默认值(0.7 / 2048);用户在 agent 表单里可逐项调整。
try {
const retrievalRes: any = await getTenantRetrievalConfig();
const rc = retrievalRes?.data;
if (rc?.embedding_top_k) defaultEmbeddingTopK.value = rc.embedding_top_k;
if (rc?.keyword_threshold !== undefined) defaultKeywordThreshold.value = rc.keyword_threshold;
if (rc?.vector_threshold !== undefined) defaultVectorThreshold.value = rc.vector_threshold;
if (rc?.rerank_top_k) defaultRerankTopK.value = rc.rerank_top_k;
if (rc?.rerank_threshold !== undefined) defaultRerankThreshold.value = rc.rerank_threshold;
} catch (e) {
console.warn('Failed to load retrieval config', e);
}
} catch (e) {
console.error('Failed to load dependencies', e);
}
};
// 跳转到模型管理页面添加模型
const handleAddModel = (subSection: string) => {
uiStore.openSettings('models', subSection);
};
const handleClose = () => {
showPlaceholderPopup.value = false;
showContextPlaceholderPopup.value = false;
rewriteSystemPopup.value.show = false;
rewriteUserPopup.value.show = false;
fallbackPromptPopup.value.show = false;
emit('update:visible', false);
};
// 过滤后的占位符列表
const filteredPlaceholders = computed(() => {
if (!placeholderPrefix.value) {
return availablePlaceholders.value;
}
const prefix = placeholderPrefix.value.toLowerCase();
return availablePlaceholders.value.filter(p =>
p.name.toLowerCase().startsWith(prefix)
);
});
// 过滤后的上下文模板占位符列表
const filteredContextPlaceholders = computed(() => {
if (!contextPlaceholderPrefix.value) {
return contextTemplatePlaceholders.value;
}
const prefix = contextPlaceholderPrefix.value.toLowerCase();
return contextTemplatePlaceholders.value.filter(p =>
p.name.toLowerCase().startsWith(prefix)
);
});
// 过滤后的改写系统提示词占位符列表
const filteredRewriteSystemPlaceholders = computed(() => {
if (!rewriteSystemPopup.value.prefix) {
return rewriteSystemPlaceholders.value;
}
const prefix = rewriteSystemPopup.value.prefix.toLowerCase();
return rewriteSystemPlaceholders.value.filter(p =>
p.name.toLowerCase().startsWith(prefix)
);
});
// 过滤后的改写用户提示词占位符列表
const filteredRewriteUserPlaceholders = computed(() => {
if (!rewriteUserPopup.value.prefix) {
return rewritePlaceholders.value;
}
const prefix = rewriteUserPopup.value.prefix.toLowerCase();
return rewritePlaceholders.value.filter(p =>
p.name.toLowerCase().startsWith(prefix)
);
});
// 过滤后的兜底提示词占位符列表
const filteredFallbackPlaceholders = computed(() => {
if (!fallbackPromptPopup.value.prefix) {
return fallbackPlaceholders.value;
}
const prefix = fallbackPromptPopup.value.prefix.toLowerCase();
return fallbackPlaceholders.value.filter(p =>
p.name.toLowerCase().startsWith(prefix)
);
});
// 获取 textarea 元素
const getTextareaElement = (): HTMLTextAreaElement | null => {
if (promptTextareaRef.value) {
if (promptTextareaRef.value.$el) {
return promptTextareaRef.value.$el.querySelector('textarea');
}
if (promptTextareaRef.value instanceof HTMLTextAreaElement) {
return promptTextareaRef.value;
}
}
return null;
};
// 计算光标位置
const calculateCursorPosition = (textarea: HTMLTextAreaElement) => {
const cursorPos = textarea.selectionStart;
const textBeforeCursor = formData.value.config.system_prompt.substring(0, cursorPos);
const style = window.getComputedStyle(textarea);
const textareaRect = textarea.getBoundingClientRect();
const lineHeight = parseFloat(style.lineHeight) || 20;
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingLeft = parseFloat(style.paddingLeft) || 0;
// 计算当前行号
const lines = textBeforeCursor.split('\n');
const currentLine = lines.length - 1;
const currentLineText = lines[currentLine];
// 创建临时 span 计算文本宽度
const span = document.createElement('span');
span.style.font = style.font;
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'pre';
span.textContent = currentLineText;
document.body.appendChild(span);
const textWidth = span.offsetWidth;
document.body.removeChild(span);
const scrollTop = textarea.scrollTop;
const top = textareaRect.top + paddingTop + (currentLine * lineHeight) - scrollTop + lineHeight + 4;
const scrollLeft = textarea.scrollLeft;
const left = textareaRect.left + paddingLeft + textWidth - scrollLeft;
return { top, left };
};
// 检查并显示占位符提示
const checkAndShowPlaceholderPopup = () => {
const textarea = getTextareaElement();
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = formData.value.config.system_prompt.substring(0, cursorPos);
// 查找最近的 {{ 位置
let lastOpenPos = -1;
for (let i = textBeforeCursor.length - 1; i >= 1; i--) {
if (textBeforeCursor[i] === '{' && textBeforeCursor[i - 1] === '{') {
const textAfterOpen = textBeforeCursor.substring(i + 1);
if (!textAfterOpen.includes('}}')) {
lastOpenPos = i - 1;
break;
}
}
}
if (lastOpenPos === -1) {
showPlaceholderPopup.value = false;
placeholderPrefix.value = '';
return;
}
const textAfterOpen = textBeforeCursor.substring(lastOpenPos + 2);
placeholderPrefix.value = textAfterOpen;
const filtered = filteredPlaceholders.value;
if (filtered.length > 0) {
nextTick(() => {
const position = calculateCursorPosition(textarea);
popupStyle.value = {
top: `${position.top}px`,
left: `${position.left}px`
};
showPlaceholderPopup.value = true;
selectedPlaceholderIndex.value = 0;
});
} else {
showPlaceholderPopup.value = false;
}
};
// 处理输入
const handlePromptInput = () => {
if (placeholderPopupTimer) {
clearTimeout(placeholderPopupTimer);
}
placeholderPopupTimer = setTimeout(() => {
checkAndShowPlaceholderPopup();
}, 50);
};
// 插入占位符
const insertPlaceholder = (placeholderName: string, fromPopup: boolean = false) => {
const textarea = getTextareaElement();
if (!textarea) return;
showPlaceholderPopup.value = false;
placeholderPrefix.value = '';
selectedPlaceholderIndex.value = 0;
nextTick(() => {
const cursorPos = textarea.selectionStart;
const currentValue = formData.value.config.system_prompt || '';
const textBeforeCursor = currentValue.substring(0, cursorPos);
const textAfterCursor = currentValue.substring(cursorPos);
// 只有从下拉列表选择时才查找 {{ 并替换
if (fromPopup) {
let lastOpenPos = -1;
for (let i = textBeforeCursor.length - 1; i >= 1; i--) {
if (textBeforeCursor[i] === '{' && textBeforeCursor[i - 1] === '{') {
lastOpenPos = i - 1;
break;
}
}
if (lastOpenPos !== -1) {
const textBeforeOpen = currentValue.substring(0, lastOpenPos);
const newValue = textBeforeOpen + `{{${placeholderName}}}` + textAfterCursor;
formData.value.config.system_prompt = newValue;
nextTick(() => {
const newCursorPos = textBeforeOpen.length + placeholderName.length + 4;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
return;
}
}
// 直接在光标位置插入完整占位符
const newValue = textBeforeCursor + `{{${placeholderName}}}` + textAfterCursor;
formData.value.config.system_prompt = newValue;
nextTick(() => {
const newCursorPos = cursorPos + placeholderName.length + 4;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
});
};
// 获取上下文模板 textarea 元素
const getContextTemplateTextareaElement = (): HTMLTextAreaElement | null => {
if (contextTemplateTextareaRef.value) {
if (contextTemplateTextareaRef.value.$el) {
return contextTemplateTextareaRef.value.$el.querySelector('textarea');
}
if (contextTemplateTextareaRef.value instanceof HTMLTextAreaElement) {
return contextTemplateTextareaRef.value;
}
}
return null;
};
// 计算上下文模板光标位置
const calculateContextCursorPosition = (textarea: HTMLTextAreaElement) => {
const cursorPos = textarea.selectionStart;
const textBeforeCursor = formData.value.config.context_template.substring(0, cursorPos);
const style = window.getComputedStyle(textarea);
const textareaRect = textarea.getBoundingClientRect();
const lineHeight = parseFloat(style.lineHeight) || 20;
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const lines = textBeforeCursor.split('\n');
const currentLine = lines.length - 1;
const currentLineText = lines[currentLine];
const span = document.createElement('span');
span.style.font = style.font;
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'pre';
span.textContent = currentLineText;
document.body.appendChild(span);
const textWidth = span.offsetWidth;
document.body.removeChild(span);
const scrollTop = textarea.scrollTop;
const top = textareaRect.top + paddingTop + (currentLine * lineHeight) - scrollTop + lineHeight + 4;
const scrollLeft = textarea.scrollLeft;
const left = textareaRect.left + paddingLeft + textWidth - scrollLeft;
return { top, left };
};
// 检查并显示上下文模板占位符提示
const checkAndShowContextPlaceholderPopup = () => {
const textarea = getContextTemplateTextareaElement();
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = formData.value.config.context_template.substring(0, cursorPos);
let lastOpenPos = -1;
for (let i = textBeforeCursor.length - 1; i >= 1; i--) {
if (textBeforeCursor[i] === '{' && textBeforeCursor[i - 1] === '{') {
const textAfterOpen = textBeforeCursor.substring(i + 1);
if (!textAfterOpen.includes('}}')) {
lastOpenPos = i - 1;
break;
}
}
}
if (lastOpenPos === -1) {
showContextPlaceholderPopup.value = false;
contextPlaceholderPrefix.value = '';
return;
}
const textAfterOpen = textBeforeCursor.substring(lastOpenPos + 2);
contextPlaceholderPrefix.value = textAfterOpen;
const filtered = filteredContextPlaceholders.value;
if (filtered.length > 0) {
nextTick(() => {
const position = calculateContextCursorPosition(textarea);
contextPopupStyle.value = {
top: `${position.top}px`,
left: `${position.left}px`
};
showContextPlaceholderPopup.value = true;
selectedContextPlaceholderIndex.value = 0;
});
} else {
showContextPlaceholderPopup.value = false;
}
};
// 处理上下文模板输入
const handleContextTemplateInput = () => {
if (contextPlaceholderPopupTimer) {
clearTimeout(contextPlaceholderPopupTimer);
}
contextPlaceholderPopupTimer = setTimeout(() => {
checkAndShowContextPlaceholderPopup();
}, 50);
};
// 插入上下文模板占位符
const insertContextPlaceholder = (placeholderName: string, fromPopup: boolean = false) => {
const textarea = getContextTemplateTextareaElement();
if (!textarea) return;
showContextPlaceholderPopup.value = false;
contextPlaceholderPrefix.value = '';
selectedContextPlaceholderIndex.value = 0;
nextTick(() => {
const cursorPos = textarea.selectionStart;
const currentValue = formData.value.config.context_template || '';
const textBeforeCursor = currentValue.substring(0, cursorPos);
const textAfterCursor = currentValue.substring(cursorPos);
// 只有从下拉列表选择时才查找 {{ 并替换
if (fromPopup) {
let lastOpenPos = -1;
for (let i = textBeforeCursor.length - 1; i >= 1; i--) {
if (textBeforeCursor[i] === '{' && textBeforeCursor[i - 1] === '{') {
lastOpenPos = i - 1;
break;
}
}
if (lastOpenPos !== -1) {
const textBeforeOpen = currentValue.substring(0, lastOpenPos);
const newValue = textBeforeOpen + `{{${placeholderName}}}` + textAfterCursor;
formData.value.config.context_template = newValue;
nextTick(() => {
const newCursorPos = textBeforeOpen.length + placeholderName.length + 4;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
return;
}
}
// 直接在光标位置插入完整占位符
const newValue = textBeforeCursor + `{{${placeholderName}}}` + textAfterCursor;
formData.value.config.context_template = newValue;
nextTick(() => {
const newCursorPos = cursorPos + placeholderName.length + 4;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
});
};
// 通用获取 textarea 元素
const getGenericTextareaElement = (type: 'rewriteSystem' | 'rewriteUser' | 'fallback'): HTMLTextAreaElement | null => {
const refMap = {
rewriteSystem: rewriteSystemTextareaRef,
rewriteUser: rewriteUserTextareaRef,
fallback: fallbackPromptTextareaRef,
};
const ref = refMap[type];
if (ref.value) {
if (ref.value.$el) {
return ref.value.$el.querySelector('textarea');
}
if (ref.value instanceof HTMLTextAreaElement) {
return ref.value;
}
}
return null;
};
// 通用计算光标位置
const calculateGenericCursorPosition = (textarea: HTMLTextAreaElement, fieldValue: string) => {
const cursorPos = textarea.selectionStart;
const textBeforeCursor = fieldValue.substring(0, cursorPos);
const lines = textBeforeCursor.split('\n');
const currentLine = lines.length - 1;
const currentLineText = lines[currentLine];
const textareaRect = textarea.getBoundingClientRect();
const style = window.getComputedStyle(textarea);
const lineHeight = parseFloat(style.lineHeight) || 20;
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const span = document.createElement('span');
span.style.font = style.font;
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.whiteSpace = 'pre';
span.textContent = currentLineText;
document.body.appendChild(span);
const textWidth = span.offsetWidth;
document.body.removeChild(span);
const scrollTop = textarea.scrollTop;
const top = textareaRect.top + paddingTop + (currentLine * lineHeight) - scrollTop + lineHeight + 4;
const scrollLeft = textarea.scrollLeft;
const left = textareaRect.left + paddingLeft + textWidth - scrollLeft;
return { top, left };
};
// 通用检查并显示占位符弹出
const checkAndShowGenericPlaceholderPopup = (
type: 'rewriteSystem' | 'rewriteUser' | 'fallback',
popup: typeof rewriteSystemPopup,
fieldKey: keyof typeof formData.value.config,
filteredPlaceholders: PlaceholderDefinition[]
) => {
const textarea = getGenericTextareaElement(type);
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const fieldValue = String(formData.value.config[fieldKey] || '');
const textBeforeCursor = fieldValue.substring(0, cursorPos);
let lastOpenPos = -1;
for (let i = textBeforeCursor.length - 1; i >= 1; i--) {
if (textBeforeCursor[i] === '{' && textBeforeCursor[i - 1] === '{') {
const textAfterOpen = textBeforeCursor.substring(i + 1);
if (!textAfterOpen.includes('}}')) {
lastOpenPos = i - 1;
break;
}
}
}
if (lastOpenPos === -1) {
popup.value.show = false;
popup.value.prefix = '';
return;
}
const textAfterOpen = textBeforeCursor.substring(lastOpenPos + 2);
popup.value.prefix = textAfterOpen;
if (filteredPlaceholders.length > 0) {
nextTick(() => {
const position = calculateGenericCursorPosition(textarea, fieldValue);
popup.value.style = {
top: `${position.top}px`,
left: `${position.left}px`
};
popup.value.show = true;
popup.value.selectedIndex = 0;
});
} else {
popup.value.show = false;
}
};
// 处理改写系统提示词输入
const handleRewriteSystemInput = () => {
if (rewriteSystemPopup.value.timer) {
clearTimeout(rewriteSystemPopup.value.timer);
}
rewriteSystemPopup.value.timer = setTimeout(() => {
checkAndShowGenericPlaceholderPopup('rewriteSystem', rewriteSystemPopup, 'rewrite_prompt_system', filteredRewriteSystemPlaceholders.value);
}, 50);
};
// 处理改写用户提示词输入
const handleRewriteUserInput = () => {
if (rewriteUserPopup.value.timer) {
clearTimeout(rewriteUserPopup.value.timer);
}
rewriteUserPopup.value.timer = setTimeout(() => {
checkAndShowGenericPlaceholderPopup('rewriteUser', rewriteUserPopup, 'rewrite_prompt_user', filteredRewriteUserPlaceholders.value);
}, 50);
};
// 处理兜底提示词输入
const handleFallbackPromptInput = () => {
if (fallbackPromptPopup.value.timer) {
clearTimeout(fallbackPromptPopup.value.timer);
}
fallbackPromptPopup.value.timer = setTimeout(() => {
checkAndShowGenericPlaceholderPopup('fallback', fallbackPromptPopup, 'fallback_prompt', filteredFallbackPlaceholders.value);
}, 50);
};
// 通用插入占位符
const insertGenericPlaceholder = (type: 'rewriteSystem' | 'rewriteUser' | 'fallback', placeholderName: string, fromPopup: boolean = false) => {
const textarea = getGenericTextareaElement(type);
if (!textarea) return;
const popupMap = {
rewriteSystem: rewriteSystemPopup,
rewriteUser: rewriteUserPopup,
fallback: fallbackPromptPopup,
};
const fieldKeyMap: Record<string, keyof typeof formData.value.config> = {
rewriteSystem: 'rewrite_prompt_system',
rewriteUser: 'rewrite_prompt_user',
fallback: 'fallback_prompt',
};
const popup = popupMap[type];
const fieldKey = fieldKeyMap[type];
popup.value.show = false;
popup.value.prefix = '';
popup.value.selectedIndex = 0;
nextTick(() => {
const cursorPos = textarea.selectionStart;
const currentValue = String(formData.value.config[fieldKey] || '');
const textBeforeCursor = currentValue.substring(0, cursorPos);
const textAfterCursor = currentValue.substring(cursorPos);
// 只有从下拉列表选择时才查找 {{ 并替换
if (fromPopup) {
let lastOpenPos = -1;
for (let i = textBeforeCursor.length - 1; i >= 1; i--) {
if (textBeforeCursor[i] === '{' && textBeforeCursor[i - 1] === '{') {
lastOpenPos = i - 1;
break;
}
}
if (lastOpenPos !== -1) {
const textBeforeOpen = currentValue.substring(0, lastOpenPos);
const newValue = textBeforeOpen + `{{${placeholderName}}}` + textAfterCursor;
(formData.value.config as any)[fieldKey] = newValue;
nextTick(() => {
const newCursorPos = textBeforeOpen.length + placeholderName.length + 4;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
return;
}
}
// 直接在光标位置插入完整占位符
const newValue = textBeforeCursor + `{{${placeholderName}}}` + textAfterCursor;
(formData.value.config as any)[fieldKey] = newValue;
nextTick(() => {
const newCursorPos = cursorPos + placeholderName.length + 4;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
});
};
// 设置上下文模板 textarea 事件监听
const setupContextTemplateEventListeners = () => {
nextTick(() => {
const textarea = getContextTemplateTextareaElement();
if (textarea) {
textarea.addEventListener('keydown', (e: KeyboardEvent) => {
if (showContextPlaceholderPopup.value && filteredContextPlaceholders.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
if (selectedContextPlaceholderIndex.value < filteredContextPlaceholders.value.length - 1) {
selectedContextPlaceholderIndex.value++;
} else {
selectedContextPlaceholderIndex.value = 0;
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
if (selectedContextPlaceholderIndex.value > 0) {
selectedContextPlaceholderIndex.value--;
} else {
selectedContextPlaceholderIndex.value = filteredContextPlaceholders.value.length - 1;
}
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const selected = filteredContextPlaceholders.value[selectedContextPlaceholderIndex.value];
if (selected) {
insertContextPlaceholder(selected.name, true);
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
showContextPlaceholderPopup.value = false;
contextPlaceholderPrefix.value = '';
}
}
}, true);
}
});
};
// 设置 textarea 事件监听
const setupTextareaEventListeners = () => {
nextTick(() => {
const textarea = getTextareaElement();
if (textarea) {
textarea.addEventListener('keydown', (e: KeyboardEvent) => {
if (showPlaceholderPopup.value && filteredPlaceholders.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
if (selectedPlaceholderIndex.value < filteredPlaceholders.value.length - 1) {
selectedPlaceholderIndex.value++;
} else {
selectedPlaceholderIndex.value = 0;
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
if (selectedPlaceholderIndex.value > 0) {
selectedPlaceholderIndex.value--;
} else {
selectedPlaceholderIndex.value = filteredPlaceholders.value.length - 1;
}
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const selected = filteredPlaceholders.value[selectedPlaceholderIndex.value];
if (selected) {
insertPlaceholder(selected.name, true);
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
showPlaceholderPopup.value = false;
placeholderPrefix.value = '';
}
}
}, true);
}
});
};
// 通用设置 textarea 事件监听
const setupGenericTextareaEventListeners = (
type: 'rewriteSystem' | 'rewriteUser' | 'fallback',
popup: typeof rewriteSystemPopup,
filteredPlaceholders: () => PlaceholderDefinition[]
) => {
nextTick(() => {
const textarea = getGenericTextareaElement(type);
if (textarea) {
textarea.addEventListener('keydown', (e: KeyboardEvent) => {
const filtered = filteredPlaceholders();
if (popup.value.show && filtered.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
if (popup.value.selectedIndex < filtered.length - 1) {
popup.value.selectedIndex++;
} else {
popup.value.selectedIndex = 0;
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
if (popup.value.selectedIndex > 0) {
popup.value.selectedIndex--;
} else {
popup.value.selectedIndex = filtered.length - 1;
}
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const selected = filtered[popup.value.selectedIndex];
if (selected) {
insertGenericPlaceholder(type, selected.name, true);
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
popup.value.show = false;
popup.value.prefix = '';
}
}
}, true);
}
});
};
// 处理点击占位符标签
const handlePlaceholderClick = (type: 'system' | 'context' | 'rewriteSystem' | 'rewriteUser' | 'fallback', placeholderName: string) => {
if (type === 'system') {
insertPlaceholder(placeholderName);
} else if (type === 'context') {
insertContextPlaceholder(placeholderName);
} else {
insertGenericPlaceholder(type, placeholderName);
}
};
// 监听 visible 变化设置事件监听
watch(() => props.visible, (val) => {
if (val) {
nextTick(() => {
setupTextareaEventListeners();
setupContextTemplateEventListeners();
setupGenericTextareaEventListeners('rewriteSystem', rewriteSystemPopup, () => filteredRewriteSystemPlaceholders.value);
setupGenericTextareaEventListeners('rewriteUser', rewriteUserPopup, () => filteredRewriteUserPlaceholders.value);
setupGenericTextareaEventListeners('fallback', fallbackPromptPopup, () => filteredFallbackPlaceholders.value);
});
}
});
// 模板选择处理函数
const handleSystemPromptTemplateSelect = (template: PromptTemplate) => {
formData.value.config.system_prompt = template.content;
};
// Agent 系统提示词的"恢复默认":
// 当前选中了非 custom 的智能体类型时,"默认"应当是该类型预设绑定的提示词
// (比如 Wiki 问答 → wiki_researcher),而不是 agent_system_prompt 模板表里
// 全局 default: true 的那一条。只有当类型为 custom 或找不到预设绑定的模板时,
// 才回退到 PromptTemplateSelector 传来的全局默认模板。
const handleAgentSystemPromptResetDefault = (fallback: PromptTemplate) => {
const typeId = agentType.value;
if (typeId && typeId !== 'custom') {
const preset = agentTypePresets.value.find(p => p.id === typeId);
const presetPromptId = preset?.config?.system_prompt_id;
if (presetPromptId) {
const tmpl = agentSystemPromptTemplates.value.find(t => t.id === presetPromptId);
if (tmpl && typeof tmpl.content === 'string') {
formData.value.config.system_prompt = tmpl.content;
formData.value.config.system_prompt_id = tmpl.id;
return;
}
}
}
// Fallback:没有合适的类型预设,用 PromptTemplateSelector 找到的全局默认
formData.value.config.system_prompt = fallback.content;
formData.value.config.system_prompt_id = fallback.id;
};
const handleContextTemplateSelect = (template: PromptTemplate) => {
formData.value.config.context_template = template.content;
};
const handleRewriteTemplateSelect = (template: PromptTemplate) => {
// Rewrite templates contain both content (system) and user fields
formData.value.config.rewrite_prompt_system = template.content;
if (template.user) {
formData.value.config.rewrite_prompt_user = template.user;
}
};
const handleFallbackResponseTemplateSelect = (template: PromptTemplate) => {
formData.value.config.fallback_response = template.content;
};
const handleFallbackPromptTemplateSelect = (template: PromptTemplate) => {
formData.value.config.fallback_prompt = template.content;
};
// 辅助函数:检查提示词是否包含指定占位符
const hasPlaceholder = (text: string | undefined, placeholder: string): boolean => {
if (!text) return false;
return text.includes(`{{${placeholder}}}`);
};
const handleSave = async () => {
// 验证必填项(内置智能体不验证名称和系统提示词)
if (!isBuiltinAgent.value) {
if (!formData.value.name || !formData.value.name.trim()) {
MessagePlugin.error(t('agent.editor.nameRequired'));
currentSection.value = 'basic';
return;
}
// 自定义智能体必须填写系统提示词
if (!formData.value.config.system_prompt || !formData.value.config.system_prompt.trim()) {
MessagePlugin.error(t('agent.editor.systemPromptRequired'));
currentSection.value = 'basic';
return;
}
// 自定义智能体普通模式必须填写上下文模板
if (!isAgentMode.value && (!formData.value.config.context_template || !formData.value.config.context_template.trim())) {
MessagePlugin.error(t('agent.editor.contextTemplateRequired'));
currentSection.value = 'basic';
return;
}
}
// 校验占位符(普通模式 + 开启多轮对话改写)
if (!isAgentMode.value && formData.value.config.multi_turn_enabled && formData.value.config.enable_rewrite) {
const rewritePrompt = formData.value.config.rewrite_prompt_user || '';
// 只有用户自定义了改写提示词时才校验
if (rewritePrompt.trim()) {
if (!hasPlaceholder(rewritePrompt, 'query')) {
MessagePlugin.error(t('agent.editor.queryMissingInRewrite'));
currentSection.value = 'conversation';
return;
}
}
}
// 校验占位符(兜底策略为模型生成时)
if (!isAgentMode.value && formData.value.config.fallback_strategy === 'model') {
const fallbackPrompt = formData.value.config.fallback_prompt || '';
// 只有用户自定义了兜底提示词时才校验
if (fallbackPrompt.trim() && !hasPlaceholder(fallbackPrompt, 'query')) {
MessagePlugin.error(t('agent.editor.queryMissingInFallback'));
currentSection.value = 'retrieval';
return;
}
}
if (!formData.value.config.model_id) {
MessagePlugin.error(t('agent.editor.modelRequired'));
currentSection.value = 'model';
return;
}
// 校验 VLM 模型(当图片上传启用时必填)
if (formData.value.config.image_upload_enabled && !formData.value.config.vlm_model_id) {
MessagePlugin.error(t('agentEditor.imageUpload.vlmModelRequired'));
currentSection.value = 'multimodal';
return;
}
// ReRank 模型(可选)
// 运行时若 rerank_model_id 为空会自动跳过 rerank,无需在保存时强制要求。
// 仅当用户已选择 rerank 模型时,才校验相关参数。
// 过滤空推荐问题
if (formData.value.config.suggested_prompts) {
formData.value.config.suggested_prompts = formData.value.config.suggested_prompts.filter((p: string) => p.trim() !== '');
}
saving.value = true;
try {
if (props.mode === 'create') {
await createAgent(formData.value);
MessagePlugin.success(t('agent.messages.created'));
} else {
await updateAgent(formData.value.id, formData.value);
MessagePlugin.success(t('agent.messages.updated'));
}
emit('success');
handleClose();
} catch (e: any) {
MessagePlugin.error(e?.message || t('agent.messages.saveFailed'));
} finally {
saving.value = false;
}
};
</script>
<style scoped lang="less">
// 复用创建知识库的样式
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.settings-modal {
position: relative;
width: 90vw;
max-width: 1100px;
height: 85vh;
max-height: 750px;
background: var(--td-bg-color-container);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
.close-btn {
position: absolute;
top: 20px;
right: 20px;
width: 32px;
height: 32px;
border: none;
background: var(--td-bg-color-secondarycontainer);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-secondary);
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
}
}
.settings-container {
display: flex;
height: 100%;
overflow: hidden;
}
.settings-sidebar {
width: 200px;
background: var(--td-bg-color-settings-modal);
border-right: 1px solid var(--td-component-stroke);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 24px 20px;
border-bottom: 1px solid var(--td-component-stroke);
}
.sidebar-title {
margin: 0;
font-family: var(--app-font-family);
font-size: 18px;
font-weight: 600;
color: var(--td-text-color-primary);
}
.settings-nav {
flex: 1;
padding: 12px 8px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 4px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--app-font-family);
font-size: 14px;
color: var(--td-text-color-secondary);
&:hover {
background: var(--td-bg-color-secondarycontainer-hover);
color: var(--td-text-color-primary);
}
&.active {
background: rgba(7, 192, 95, 0.1);
color: var(--td-brand-color);
font-weight: 500;
}
}
.nav-icon {
margin-right: 8px;
font-size: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.nav-label {
flex: 1;
}
.settings-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-wrapper {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.section {
width: 100%;
}
// 与知识库设置一致的 section-header 样式
.section-header {
margin-bottom: 32px;
h2 {
font-size: 20px;
font-weight: 600;
color: var(--td-text-color-primary);
margin: 0 0 8px 0;
}
.section-description {
font-size: 14px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
.doc-link {
margin-left: 8px;
}
}
}
// 与知识库设置一致的 settings-group 样式
.settings-group {
display: flex;
flex-direction: column;
gap: 0;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid var(--td-component-stroke);
&:last-child {
border-bottom: none;
}
&.setting-row-vertical {
flex-direction: column;
gap: 12px;
.setting-info {
max-width: 100%;
padding-right: 0;
}
}
// 强调行:用于"智能体类型"这类对用户影响最大、需要突出的关键配置。
// 视觉上只做极轻度强调 —— 左侧 3px 品牌色竖条 + label 加粗。
&.setting-row--emphasize {
position: relative;
padding-left: 14px;
&::before {
content: '';
position: absolute;
left: 0;
top: 22px;
bottom: 22px;
width: 3px;
border-radius: 2px;
background: var(--td-brand-color, #0052d9);
}
.setting-info label {
font-weight: 600;
}
}
}
.setting-info {
flex: 1;
max-width: 55%;
padding-right: 24px;
&.full-width {
max-width: 100%;
padding-right: 0;
}
.setting-info-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
label {
margin-bottom: 0;
}
}
label {
font-size: 15px;
font-weight: 500;
color: var(--td-text-color-primary);
display: block;
margin-bottom: 4px;
.required {
color: var(--td-error-color);
margin-left: 2px;
}
}
.desc {
font-size: 13px;
color: var(--td-text-color-secondary);
margin: 0;
line-height: 1.5;
.hint {
color: var(--td-warning-color, var(--td-text-color-placeholder));
}
}
}
.setting-control {
flex-shrink: 0;
min-width: 360px;
display: flex;
justify-content: flex-end;
align-items: flex-start;
&.setting-control-full {
width: 100%;
min-width: 100%;
justify-content: flex-start;
}
// 让 select 和 input 占满控件区域
:deep(.t-select),
:deep(.t-input),
:deep(.t-textarea) {
width: 100%;
}
:deep(.t-input-number) {
width: 120px;
}
}
.select-option-with-tag {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.go-settings-link {
font-size: 12px;
color: var(--td-brand-color);
margin-top: 4px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// 名称输入框带头像预览
.name-input-wrapper {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.name-input {
flex: 1;
}
}
.settings-footer {
padding: 16px 32px;
border-top: 1px solid var(--td-component-stroke);
display: flex;
justify-content: flex-end;
gap: 12px;
flex-shrink: 0;
}
// 模式提示样式
.mode-hint {
display: flex;
align-items: center;
padding: 10px 14px;
background: var(--td-success-color-light);
border-radius: 6px;
border: 1px solid var(--td-success-color-focus);
color: var(--td-brand-color);
font-size: 13px;
line-height: 1.5;
}
// 过渡动画
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.settings-modal {
transform: scale(0.95);
}
}
// Slider 样式
.slider-wrapper {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
:deep(.t-slider) {
flex: 1;
}
}
.slider-value {
width: 40px;
text-align: right;
font-family: var(--app-font-family-mono);
font-size: 14px;
color: var(--td-text-color-primary);
}
// 推荐问题列表
.suggested-prompts-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.prompt-item {
display: flex;
align-items: center;
gap: 8px;
:deep(.t-input) {
flex: 1;
}
}
// ===== 工具配置:overview 面板 =====
.tools-overview {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
padding: 14px 16px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 10px;
border: 1px solid var(--td-component-stroke);
}
.tools-overview-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
&--preset {
border-top: 1px dashed var(--td-component-stroke);
padding-top: 10px;
justify-content: space-between;
}
}
.tools-status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 13px;
color: var(--td-text-color-secondary);
background: var(--td-bg-color-container);
border-radius: 999px;
border: 1px solid var(--td-component-stroke);
.t-icon {
color: var(--td-text-color-secondary);
font-size: 14px;
}
.tools-status-metric {
display: inline-flex;
align-items: baseline;
gap: 4px;
strong {
font-size: 14px;
font-weight: 600;
color: var(--td-text-color-primary);
}
}
.tools-status-sep {
color: var(--td-text-color-placeholder);
}
&--warn {
color: var(--td-warning-color);
background: var(--td-warning-color-1, rgba(237, 118, 20, 0.08));
border-color: var(--td-warning-color-light, #fcd7b6);
.t-icon { color: var(--td-warning-color); }
}
}
// ===== 按组的卡片网格 =====
.tool-groups {
display: flex;
flex-direction: column;
gap: 22px;
width: 100%;
}
.tool-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.tool-group-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 2px;
.tool-group-bar {
display: inline-block;
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--td-brand-color);
}
.tool-group-title {
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-primary);
letter-spacing: 0.2px;
}
.tool-group-count {
min-width: 20px;
padding: 0 6px;
font-size: 11px;
color: var(--td-text-color-secondary);
background: var(--td-bg-color-secondarycontainer);
border-radius: 999px;
text-align: center;
line-height: 18px;
}
.tool-group-warning {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 12px;
color: var(--td-warning-color);
background: var(--td-warning-color-1, rgba(237, 118, 20, 0.08));
border: 1px solid var(--td-warning-color-light, #fcd7b6);
border-radius: 999px;
.t-icon { font-size: 13px; }
}
}
// 不同分组的左侧色条
.tool-group--base .tool-group-bar { background: var(--td-gray-color-6, #a0a7ab); }
.tool-group--rag .tool-group-bar { background: var(--td-brand-color); }
.tool-group--wiki_read .tool-group-bar { background: var(--td-success-color, #2ba471); }
.tool-group--wiki_edit .tool-group-bar { background: var(--td-warning-color, #ed7b2f); }
.tool-group--wiki_issue .tool-group-bar { background: var(--td-purple-5, #8e56dd); }
.tool-group--data .tool-group-bar { background: var(--td-cyan-6, #09a3b7); }
// 统一两列网格;小屏退化单列
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: 100%;
@media (max-width: 720px) {
grid-template-columns: 1fr;
}
}
// ===== 工具卡片(基于 t-checkbox 的 label 结构) =====
.tool-card {
margin: 0; // 清掉 TDesign checkbox 默认外边距
padding: 12px 14px;
background: var(--td-bg-color-container);
border-radius: 8px;
border: 1px solid var(--td-component-stroke);
transition: border-color .2s, background .2s;
cursor: pointer;
overflow: hidden;
&:hover:not(.tool-card--disabled) {
border-color: var(--td-brand-color);
background: var(--td-brand-color-1, rgba(0, 82, 217, 0.04));
}
// checkbox 的勾选框 + label 改造
:deep(.t-checkbox__input) {
margin-top: 2px;
flex-shrink: 0;
}
:deep(.t-checkbox__label) {
flex: 1;
min-width: 0;
padding-left: 10px;
}
&.t-is-checked {
border-color: var(--td-brand-color);
background: var(--td-brand-color-1, rgba(0, 82, 217, 0.06));
}
&--disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--danger {
border-color: var(--td-warning-color-light, #fcd7b6);
&:hover:not(.tool-card--disabled) {
border-color: var(--td-warning-color);
background: var(--td-warning-color-1, rgba(237, 118, 20, 0.06));
}
&.t-is-checked {
border-color: var(--td-warning-color);
background: var(--td-warning-color-1, rgba(237, 118, 20, 0.08));
}
}
}
.tool-card-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tool-card-head {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tool-card-name {
font-size: 13.5px;
font-weight: 500;
color: var(--td-text-color-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 0 1 auto;
min-width: 0;
}
.tool-card-badge {
flex: 0 0 auto;
font-size: 10.5px;
line-height: 1;
padding: 3px 6px;
color: var(--td-warning-color);
background: transparent;
border: 1px solid var(--td-warning-color-light, #fcd7b6);
border-radius: 4px;
letter-spacing: 0.3px;
}
.tool-card-desc {
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tool-card-hint {
font-size: 11px;
color: var(--td-warning-color);
font-style: italic;
line-height: 1.4;
}
.tool-card--disabled {
.tool-card-name,
.tool-card-desc {
color: var(--td-text-color-placeholder);
}
}
// ===== 有效工具预览(芯片组)=====
.effective-tools {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
background: var(--td-bg-color-container);
border-radius: 8px;
border: 1px dashed var(--td-component-stroke);
min-height: 52px;
align-items: flex-start;
}
.effective-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
font-size: 12px;
line-height: 18px;
color: var(--td-brand-color);
background: var(--td-brand-color-1, rgba(0, 82, 217, 0.08));
border: 1px solid var(--td-brand-color-2, rgba(0, 82, 217, 0.16));
border-radius: 999px;
max-width: 100%;
}
.effective-chip-label {
font-weight: 500;
}
.effective-chip-reason {
font-size: 11px;
color: var(--td-warning-color);
font-style: normal;
&::before {
content: "· ";
color: var(--td-text-color-placeholder);
margin-right: 2px;
}
}
.effective-chip--inactive {
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-secondarycontainer);
border-color: var(--td-component-stroke);
.effective-chip-label {
text-decoration: line-through;
}
}
.effective-tools-empty {
font-size: 12px;
color: var(--td-text-color-placeholder);
font-style: italic;
}
// Skills 选择样式
.skills-checkbox-group {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
width: 100%;
}
.skill-checkbox-item {
display: flex;
align-items: flex-start;
padding: 12px 16px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 8px;
border: 1px solid var(--td-component-stroke);
transition: all 0.2s ease;
&:hover {
border-color: var(--td-brand-color);
background: var(--td-success-color-light);
}
:deep(.t-checkbox__input) {
margin-top: 2px;
}
:deep(.t-checkbox__label) {
flex: 1;
}
}
.skill-item-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.skill-name {
font-size: 14px;
font-weight: 500;
color: var(--td-text-color-primary);
}
.skill-desc {
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.5;
}
.skill-info-box {
display: flex;
gap: 12px;
padding: 16px;
background: var(--td-brand-color-light);
border-radius: 8px;
border: 1px solid var(--td-brand-color-focus);
margin-top: 16px;
.info-icon {
font-size: 20px;
color: var(--td-brand-color);
flex-shrink: 0;
margin-top: 2px;
}
.info-content {
flex: 1;
p {
margin: 0;
font-size: 13px;
color: var(--td-text-color-secondary);
line-height: 1.6;
&:first-child {
margin-bottom: 4px;
}
strong {
color: var(--td-brand-color);
}
}
}
}
.empty-hint {
color: var(--td-text-color-placeholder);
font-style: italic;
}
// textarea 与模板选择器容器
.textarea-with-template {
position: relative;
width: 100%;
}
// 系统提示词输入框样式
.system-prompt-textarea {
width: 100%;
font-family: var(--app-font-family-mono);
font-size: 13px;
:deep(textarea) {
resize: vertical !important;
min-height: 200px;
}
}
// 占位符标签组样式
.placeholder-tags {
margin-top: 6px;
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
line-height: 1.4;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 4px;
// 隐藏滚动条但保持可滚动
scrollbar-width: thin;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.placeholder-label {
color: var(--td-text-color-secondary, #666);
flex-shrink: 0;
}
.placeholder-hint {
color: var(--td-text-color-placeholder, #999);
font-size: 11px;
user-select: none;
flex-shrink: 0;
}
.placeholder-tag {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 3px;
font-family: var(--app-font-family-mono);
font-size: 11px;
color: var(--td-text-color-primary, #333);
background-color: var(--td-bg-color-secondarycontainer, #f3f3f3);
cursor: pointer;
transition: all 0.2s;
user-select: none;
border: 1px solid transparent;
flex-shrink: 0;
&:hover {
color: var(--td-brand-color, #0052d9);
background-color: var(--td-brand-color-light, #ecf2fe);
border-color: var(--td-brand-color-focus, #d0e0fd);
}
&:active {
background-color: var(--td-brand-color-focus, #d0e0fd);
}
}
}
.placeholder-popup-wrapper {
position: fixed;
z-index: 10001;
pointer-events: auto;
}
.placeholder-popup {
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-stroke, #e5e7eb);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
max-width: 320px;
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.placeholder-item {
padding: 6px 10px;
cursor: pointer;
transition: background-color 0.15s;
border-radius: 4px;
&:hover,
&.active {
background-color: var(--td-bg-color-container-hover, #f5f7fa);
}
.placeholder-name {
margin-bottom: 2px;
code {
background: var(--td-bg-color-container-hover, #f5f7fa);
padding: 2px 5px;
border-radius: 3px;
font-family: var(--app-font-family-mono);
font-size: 11px;
color: var(--td-brand-color, #0052d9);
}
}
.placeholder-desc {
font-size: 11px;
color: var(--td-text-color-secondary, #666);
}
}
// 内置智能体提示
.builtin-agent-notice {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--td-warning-color-light);
border: 1px solid var(--td-warning-color-focus);
border-radius: 8px;
margin-bottom: 16px;
color: var(--td-warning-color);
font-size: 14px;
.t-icon {
font-size: 16px;
flex-shrink: 0;
}
}
// 内置智能体头像
.builtin-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
flex-shrink: 0;
&.normal {
background: linear-gradient(135deg, rgba(7, 192, 95, 0.15) 0%, rgba(7, 192, 95, 0.08) 100%);
color: var(--td-brand-color-active);
}
&.agent {
background: linear-gradient(135deg, rgba(124, 77, 255, 0.15) 0%, rgba(124, 77, 255, 0.08) 100%);
color: var(--td-brand-color);
}
}
// 提示词开关
.prompt-toggle {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
.prompt-toggle-label {
font-size: 13px;
color: var(--td-text-color-secondary);
}
}
// 提示词禁用提示
.prompt-disabled-hint {
color: var(--td-text-color-placeholder);
font-size: 13px;
font-style: italic;
padding: 12px 16px;
background: var(--td-bg-color-secondarycontainer);
border-radius: 6px;
}
// 系统提示词Tabs
.system-prompt-tabs {
width: 100%;
.prompt-variant-tabs {
:deep(.t-tabs__nav) {
margin-bottom: 12px;
}
}
}
// 知识库选项样式
.kb-option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
}
.kb-option-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 6px;
font-size: 14px;
// Document KB
&.doc-icon {
background: rgba(16, 185, 129, 0.1);
color: var(--td-success-color);
}
// FAQ KB
&.faq-icon {
background: rgba(0, 82, 217, 0.1);
color: var(--td-brand-color);
}
}
.kb-option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--td-text-color-primary);
}
.kb-option-org {
flex-shrink: 0;
font-size: 11px;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-secondarycontainer);
padding: 1px 6px;
border-radius: 4px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb-option-disabled-hint {
flex-shrink: 0;
font-size: 11px;
color: var(--td-warning-color-6, #d46b08);
background: var(--td-warning-color-1, #fff7e6);
padding: 1px 6px;
border-radius: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-type-preset-desc {
margin-top: 4px;
color: var(--td-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.agent-type-select {
width: 100%;
min-width: 240px;
max-width: 360px;
}
.kb-option-count {
flex-shrink: 0;
font-size: 11px;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-secondarycontainer);
padding: 1px 6px;
border-radius: 4px;
}
.kb-option-tag {
flex-shrink: 0;
font-size: 10px;
font-weight: 500;
padding: 0 5px;
border-radius: 3px;
line-height: 18px;
}
.tag-rag {
color: #165dff;
background: rgba(22, 93, 255, 0.1);
}
.tag-wiki {
color: #00b42a;
background: rgba(0, 180, 42, 0.1);
}
// FAQ 策略区域样式
.faq-strategy-section {
margin-top: 24px;
padding: 16px;
background: rgba(0, 82, 217, 0.04);
border: 1px solid rgba(0, 82, 217, 0.15);
border-radius: 8px;
}
.faq-strategy-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 14px;
font-weight: 600;
color: var(--td-brand-color);
.faq-icon {
font-size: 18px;
}
.help-icon {
font-size: 14px;
color: var(--td-text-color-placeholder);
cursor: help;
}
}
.faq-strategy-section .setting-row {
padding: 12px 0;
border-bottom: 1px solid rgba(0, 82, 217, 0.1);
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:first-of-type {
padding-top: 0;
}
}
</style>
<!-- Non-scoped styles: TDesign teleports the popup outside this component, so
scoped selectors can't reach .agent-type-popup .t-select-option. -->
<style lang="less">
.agent-type-popup {
.t-select-option {
// 默认 option 是 32px 单行;我们要双行显示,取消固定高度并放宽 padding
height: auto !important;
min-height: 48px;
line-height: 1.4;
padding: 8px 12px;
white-space: normal;
}
}
.agent-type-option {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
width: 100%;
}
.agent-type-option-label {
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
line-height: 1.4;
}
.agent-type-option-desc {
font-size: 12px;
color: var(--td-text-color-secondary);
line-height: 1.4;
white-space: normal;
overflow-wrap: break-word;
}
</style>