mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
feat(contextualGuides): add multiple contextual guide components for agent and knowledge base creation
- Introduced new components: AgentCreateContextualGuide, KbCreateContextualGuide, TenantModelsGuide, and SpotlightGuide to enhance user onboarding. - Implemented dynamic step configurations for each guide, allowing tailored user experiences based on context. - Enhanced the existing NewUserGuide component to utilize the new SpotlightGuide for improved visual guidance. - Updated localization files to include new strings for contextual guides, ensuring a comprehensive user experience across languages. - Refactored existing components to integrate with the new guide system, improving maintainability and user interaction.
This commit is contained in:
174
frontend/src/components/AgentCreateContextualGuide.vue
Normal file
174
frontend/src/components/AgentCreateContextualGuide.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<SpotlightGuide v-model:active="active" :steps="guideSteps" step-i18n-prefix="contextualGuide.agentCreate.steps"
|
||||||
|
labels-prefix="contextualGuide" @finish="onFinish" @dismiss="onFinish" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import SpotlightGuide from '@/components/SpotlightGuide.vue'
|
||||||
|
import {
|
||||||
|
AGENT_EDITOR_FOCUS_SECTION_EVENT,
|
||||||
|
markContextualGuideDone,
|
||||||
|
isContextualGuideDone,
|
||||||
|
isGlobalUserGuideDone,
|
||||||
|
} from '@/config/contextualGuides'
|
||||||
|
import type { SpotlightGuideStep } from '@/types/spotlightGuide'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
when: boolean
|
||||||
|
isAgentMode: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const active = ref(false)
|
||||||
|
|
||||||
|
const focusSection = (section: string) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(AGENT_EDITOR_FOCUS_SECTION_EVENT, { detail: { section } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideSteps = computed<SpotlightGuideStep[]>(() => {
|
||||||
|
const steps: SpotlightGuideStep[] = [
|
||||||
|
{
|
||||||
|
key: 'mode',
|
||||||
|
target: '[data-guide="agent-create-mode"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'agentType',
|
||||||
|
target: '[data-guide="agent-create-agent-type"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
target: '[data-guide="agent-create-name"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'navModel',
|
||||||
|
target: '[data-guide="agent-editor-nav-model"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('model'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
target: '[data-guide="agent-create-model"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('model'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'navKnowledge',
|
||||||
|
target: '[data-guide="agent-editor-nav-knowledge"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('knowledge'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'knowledge',
|
||||||
|
target: '[data-guide="agent-create-knowledge"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('knowledge'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'navWebsearch',
|
||||||
|
target: '[data-guide="agent-editor-nav-websearch"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('websearch'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'navMultimodal',
|
||||||
|
target: '[data-guide="agent-editor-nav-multimodal"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('multimodal'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'multimodal',
|
||||||
|
target: '[data-guide="agent-create-multimodal"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('multimodal'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (props.isAgentMode) {
|
||||||
|
steps.push({
|
||||||
|
key: 'navTools',
|
||||||
|
target: '[data-guide="agent-editor-nav-tools"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('tools'),
|
||||||
|
optional: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
key: 'submit',
|
||||||
|
target: '[data-guide="agent-create-submit"]',
|
||||||
|
placement: 'top',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
interact: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return steps
|
||||||
|
})
|
||||||
|
|
||||||
|
let openTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let waitGlobalTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const onFinish = () => {
|
||||||
|
markContextualGuideDone('agentCreate')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryOpen = () => {
|
||||||
|
if (!props.when || isContextualGuideDone('agentCreate') || active.value) return
|
||||||
|
openTimer = setTimeout(() => {
|
||||||
|
if (!props.when || isContextualGuideDone('agentCreate')) return
|
||||||
|
active.value = true
|
||||||
|
}, 450)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleOpen = () => {
|
||||||
|
if (openTimer) {
|
||||||
|
clearTimeout(openTimer)
|
||||||
|
openTimer = null
|
||||||
|
}
|
||||||
|
if (waitGlobalTimer) {
|
||||||
|
clearTimeout(waitGlobalTimer)
|
||||||
|
waitGlobalTimer = null
|
||||||
|
}
|
||||||
|
if (!props.when || isContextualGuideDone('agentCreate')) return
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const poll = () => {
|
||||||
|
if (!props.when || isContextualGuideDone('agentCreate')) return
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.when,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
if (openTimer) clearTimeout(openTimer)
|
||||||
|
if (waitGlobalTimer) clearTimeout(waitGlobalTimer)
|
||||||
|
openTimer = null
|
||||||
|
waitGlobalTimer = null
|
||||||
|
active.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleOpen()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
100
frontend/src/components/ContextualGuide.vue
Normal file
100
frontend/src/components/ContextualGuide.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<SpotlightGuide v-model:active="active" :steps="config.steps" :step-i18n-prefix="config.stepI18nPrefix"
|
||||||
|
labels-prefix="contextualGuide" @finish="onFinish" @dismiss="onFinish" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import SpotlightGuide from '@/components/SpotlightGuide.vue'
|
||||||
|
import {
|
||||||
|
CONTEXTUAL_GUIDE_TOURS,
|
||||||
|
isContextualGuideDone,
|
||||||
|
isGlobalUserGuideDone,
|
||||||
|
type ContextualGuideTourConfig,
|
||||||
|
type ContextualGuideTourId,
|
||||||
|
} from '@/config/contextualGuides'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tour: ContextualGuideTourId
|
||||||
|
/** 为 true 且未完成过该情境引导时,在满足全局引导已结束后自动打开 */
|
||||||
|
when: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const config: ContextualGuideTourConfig = CONTEXTUAL_GUIDE_TOURS[props.tour]
|
||||||
|
const active = ref(false)
|
||||||
|
|
||||||
|
let openTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let waitGlobalTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (openTimer) {
|
||||||
|
clearTimeout(openTimer)
|
||||||
|
openTimer = null
|
||||||
|
}
|
||||||
|
if (waitGlobalTimer) {
|
||||||
|
clearTimeout(waitGlobalTimer)
|
||||||
|
waitGlobalTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryOpen = () => {
|
||||||
|
if (active.value) return
|
||||||
|
if (!props.when) return
|
||||||
|
if (isContextualGuideDone(props.tour)) return
|
||||||
|
if (!isGlobalUserGuideDone()) return
|
||||||
|
|
||||||
|
openTimer = setTimeout(() => {
|
||||||
|
if (!props.when || isContextualGuideDone(props.tour) || active.value) return
|
||||||
|
active.value = true
|
||||||
|
}, config.openDelayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleOpen = () => {
|
||||||
|
clearTimers()
|
||||||
|
if (!props.when || isContextualGuideDone(props.tour)) return
|
||||||
|
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待全局新手引导结束后再展示情境引导,避免两层遮罩叠加
|
||||||
|
const poll = () => {
|
||||||
|
if (!props.when || isContextualGuideDone(props.tour)) {
|
||||||
|
clearTimers()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
waitGlobalTimer = null
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFinish = () => {
|
||||||
|
localStorage.setItem(config.storageKey, '1')
|
||||||
|
config.alsoCompleteTours?.forEach((id) => {
|
||||||
|
localStorage.setItem(CONTEXTUAL_GUIDE_TOURS[id].storageKey, '1')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.when,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
scheduleOpen()
|
||||||
|
} else {
|
||||||
|
clearTimers()
|
||||||
|
active.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2099,7 +2099,7 @@ defineExpose({
|
|||||||
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple
|
<input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple
|
||||||
style="display:none" @change="handleImageSelect" />
|
style="display:none" @change="handleImageSelect" />
|
||||||
<!-- 富文本输入框容器 -->
|
<!-- 富文本输入框容器 -->
|
||||||
<div class="rich-input-container">
|
<div class="rich-input-container" data-guide="chat-input">
|
||||||
<!-- 图片预览区域 -->
|
<!-- 图片预览区域 -->
|
||||||
<div v-if="uploadedImages.length > 0" class="image-preview-bar">
|
<div v-if="uploadedImages.length > 0" class="image-preview-bar">
|
||||||
<div v-for="(img, idx) in uploadedImages" :key="idx" class="image-preview-item">
|
<div v-for="(img, idx) in uploadedImages" :key="idx" class="image-preview-item">
|
||||||
@@ -2255,7 +2255,7 @@ defineExpose({
|
|||||||
allSelectedItems.length
|
allSelectedItems.length
|
||||||
}) : $t('input.knowledgeBase') }}</span>
|
}) : $t('input.knowledgeBase') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<div ref="atButtonRef" class="control-btn kb-btn" :class="{
|
<div ref="atButtonRef" class="control-btn kb-btn" data-guide="chat-kb-mention" :class="{
|
||||||
'active': allSelectedItems.length > 0,
|
'active': allSelectedItems.length > 0,
|
||||||
'disabled': isKnowledgeBaseDisabledByAgent
|
'disabled': isKnowledgeBaseDisabledByAgent
|
||||||
}" @click.stop @mousedown.prevent="triggerMention">
|
}" @click.stop @mousedown.prevent="triggerMention">
|
||||||
@@ -2332,7 +2332,7 @@ defineExpose({
|
|||||||
</t-tooltip>
|
</t-tooltip>
|
||||||
|
|
||||||
<!-- 发送按钮 -->
|
<!-- 发送按钮 -->
|
||||||
<div v-if="!isReplying" @click="createSession(query)" class="control-btn send-btn"
|
<div v-if="!isReplying" @click="createSession(query)" class="control-btn send-btn" data-guide="chat-send"
|
||||||
:class="{ 'disabled': !query.length }">
|
:class="{ 'disabled': !query.length }">
|
||||||
<img src="../assets/img/sending-aircraft.svg" :alt="$t('input.send')" />
|
<img src="../assets/img/sending-aircraft.svg" :alt="$t('input.send')" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
202
frontend/src/components/KbCreateContextualGuide.vue
Normal file
202
frontend/src/components/KbCreateContextualGuide.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<SpotlightGuide v-model:active="active" :steps="guideSteps" step-i18n-prefix="contextualGuide.kbCreate.steps"
|
||||||
|
labels-prefix="contextualGuide" @finish="onFinish" @dismiss="onFinish" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import SpotlightGuide from '@/components/SpotlightGuide.vue'
|
||||||
|
import {
|
||||||
|
KB_EDITOR_FOCUS_SECTION_EVENT,
|
||||||
|
markContextualGuideDone,
|
||||||
|
isContextualGuideDone,
|
||||||
|
isGlobalUserGuideDone,
|
||||||
|
} from '@/config/contextualGuides'
|
||||||
|
import type { SpotlightGuideStep } from '@/types/spotlightGuide'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
when: boolean
|
||||||
|
isFaq: boolean
|
||||||
|
needsEmbedding: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const active = ref(false)
|
||||||
|
|
||||||
|
const focusSection = (section: string) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(KB_EDITOR_FOCUS_SECTION_EVENT, { detail: { section } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideSteps = computed<SpotlightGuideStep[]>(() => {
|
||||||
|
const steps: SpotlightGuideStep[] = [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
target: '[data-guide="kb-create-type"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
target: '[data-guide="kb-create-name"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!props.isFaq) {
|
||||||
|
steps.push({
|
||||||
|
key: 'indexing',
|
||||||
|
target: '[data-guide="kb-create-indexing"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push(
|
||||||
|
{
|
||||||
|
key: 'navModels',
|
||||||
|
target: '[data-guide="kb-editor-nav-models"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('models'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'llm',
|
||||||
|
target: '[data-guide="kb-create-llm"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('models'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (props.needsEmbedding) {
|
||||||
|
steps.push({
|
||||||
|
key: 'embedding',
|
||||||
|
target: '[data-guide="kb-create-embedding"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('models'),
|
||||||
|
optional: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.isFaq) {
|
||||||
|
steps.push(
|
||||||
|
{
|
||||||
|
key: 'parser',
|
||||||
|
target: '[data-guide="kb-editor-nav-parser"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('parser'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chunking',
|
||||||
|
target: '[data-guide="kb-editor-nav-chunking"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('chunking'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'storage',
|
||||||
|
target: '[data-guide="kb-editor-nav-storage"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('storage'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'navMultimodal',
|
||||||
|
target: '[data-guide="kb-editor-nav-multimodal"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('multimodal'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'multimodalToggle',
|
||||||
|
target: '[data-guide="kb-create-multimodal-toggle"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('multimodal'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'multimodalVllm',
|
||||||
|
target: '[data-guide="kb-create-multimodal-vllm"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('multimodal'),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
steps.push({
|
||||||
|
key: 'faq',
|
||||||
|
target: '[data-guide="kb-editor-nav-faq"]',
|
||||||
|
placement: 'right',
|
||||||
|
before: () => focusSection('faq'),
|
||||||
|
optional: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
key: 'submit',
|
||||||
|
target: '[data-guide="kb-create-submit"]',
|
||||||
|
placement: 'top',
|
||||||
|
before: () => focusSection('basic'),
|
||||||
|
interact: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return steps
|
||||||
|
})
|
||||||
|
|
||||||
|
let openTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let waitGlobalTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const onFinish = () => {
|
||||||
|
markContextualGuideDone('kbCreate')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryOpen = () => {
|
||||||
|
if (!props.when || isContextualGuideDone('kbCreate') || active.value) return
|
||||||
|
openTimer = setTimeout(() => {
|
||||||
|
if (!props.when || isContextualGuideDone('kbCreate')) return
|
||||||
|
active.value = true
|
||||||
|
}, 450)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleOpen = () => {
|
||||||
|
if (openTimer) {
|
||||||
|
clearTimeout(openTimer)
|
||||||
|
openTimer = null
|
||||||
|
}
|
||||||
|
if (waitGlobalTimer) {
|
||||||
|
clearTimeout(waitGlobalTimer)
|
||||||
|
waitGlobalTimer = null
|
||||||
|
}
|
||||||
|
if (!props.when || isContextualGuideDone('kbCreate')) return
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const poll = () => {
|
||||||
|
if (!props.when || isContextualGuideDone('kbCreate')) return
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.when,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
if (openTimer) clearTimeout(openTimer)
|
||||||
|
if (waitGlobalTimer) clearTimeout(waitGlobalTimer)
|
||||||
|
openTimer = null
|
||||||
|
waitGlobalTimer = null
|
||||||
|
active.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleOpen()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,94 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<SpotlightGuide v-model:active="active" :steps="steps" step-i18n-prefix="newUserGuide.steps"
|
||||||
<Transition name="guide-fade">
|
labels-prefix="newUserGuide" @finish="onFinish" @step-change="onStepChange" />
|
||||||
<div v-if="active" class="guide" role="dialog" aria-modal="true"
|
|
||||||
:aria-label="t('newUserGuide.steps.welcome.title')" @keydown.esc.prevent="dismiss" @keydown.left.prevent="prev"
|
|
||||||
@keydown.right.prevent="next" tabindex="-1" ref="rootRef">
|
|
||||||
<!-- 四块暗色挡板围住高亮区:中间留出真实空洞(DOM 上无任何元素),
|
|
||||||
点击直接穿透到下层控件;四周挡板拦截点击,避免引导期间误触。 -->
|
|
||||||
<template v-if="hole">
|
|
||||||
<!-- box-shadow 镂空:内缘与描边同为圆角,避免矩形挖洞 + 圆角描边的直角缺口 -->
|
|
||||||
<div class="guide__spot" :style="spotStyle" aria-hidden="true" />
|
|
||||||
<div v-for="(piece, i) in backdropPieces" :key="i" class="guide__backdrop guide__backdrop--hit"
|
|
||||||
:style="piece" />
|
|
||||||
</template>
|
|
||||||
<div v-else class="guide__backdrop guide__backdrop--full" />
|
|
||||||
|
|
||||||
<!-- 高亮描边 -->
|
|
||||||
<div v-if="hole" class="guide__ring" :style="ringStyle" aria-hidden="true" />
|
|
||||||
|
|
||||||
<!-- 说明卡片 -->
|
|
||||||
<div ref="cardRef" class="guide__card" :class="{ 'guide__card--center': !hole }" :style="cardStyle">
|
|
||||||
<button type="button" class="guide__close" :aria-label="t('newUserGuide.skip')" @click="dismiss">
|
|
||||||
<t-icon name="close" size="18px" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="guide__progress">
|
|
||||||
<span v-for="(s, i) in steps" :key="s.key" class="guide__dot"
|
|
||||||
:class="{ 'is-active': i === index, 'is-done': i < index }" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="guide__step-label">{{ t('newUserGuide.stepOf', { current: index + 1, total: steps.length }) }}</p>
|
|
||||||
<h3 class="guide__title">{{ stepTitle }}</h3>
|
|
||||||
<p class="guide__desc">{{ stepDesc }}</p>
|
|
||||||
|
|
||||||
<div class="guide__actions">
|
|
||||||
<button type="button" class="guide__skip" @click="dismiss">{{ t('newUserGuide.skip') }}</button>
|
|
||||||
<div class="guide__actions-main">
|
|
||||||
<t-button v-if="index > 0" size="small" variant="outline" @click="prev">
|
|
||||||
{{ t('newUserGuide.prev') }}
|
|
||||||
</t-button>
|
|
||||||
<t-button v-if="!isLast" size="small" theme="primary" @click="next">
|
|
||||||
{{ t('newUserGuide.next') }}
|
|
||||||
</t-button>
|
|
||||||
<t-button v-else size="small" theme="primary" @click="finish">
|
|
||||||
{{ t('newUserGuide.done') }}
|
|
||||||
</t-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import SpotlightGuide from '@/components/SpotlightGuide.vue'
|
||||||
|
import { GLOBAL_USER_GUIDE_KEY } from '@/config/contextualGuides'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import type { SpotlightGuideStep } from '@/types/spotlightGuide'
|
||||||
|
|
||||||
const STORAGE_KEY = 'weknora:new-user-guide-done:v1'
|
|
||||||
const OPEN_EVENT = 'weknora:open-new-user-guide'
|
const OPEN_EVENT = 'weknora:open-new-user-guide'
|
||||||
const CARD_WIDTH = 340
|
|
||||||
const GAP = 16
|
|
||||||
const EDGE = 16
|
|
||||||
const PAD = 8
|
|
||||||
/** 与侧栏 menu_item(4px)+ 内边距协调,略大于目标圆角即可 */
|
|
||||||
const holeRadius = 8
|
|
||||||
const BACKDROP_COLOR = 'rgba(15, 18, 22, 0.58)'
|
|
||||||
|
|
||||||
type Placement = 'right' | 'left' | 'bottom' | 'top'
|
|
||||||
|
|
||||||
interface GuideStep {
|
|
||||||
key: string
|
|
||||||
/** 高亮目标的 CSS 选择器;缺省表示居中卡片(欢迎 / 结束) */
|
|
||||||
target?: string
|
|
||||||
/** 优先放置方位 */
|
|
||||||
placement?: Placement
|
|
||||||
/** 进入该步骤前的准备动作(如展开侧栏、切换路由) */
|
|
||||||
before?: () => void | Promise<void>
|
|
||||||
/** 目标不存在时是否跳过该步骤(角色/版本可能隐藏入口) */
|
|
||||||
optional?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
let settingsOpenedByGuide = false
|
let settingsOpenedByGuide = false
|
||||||
|
|
||||||
// 顺序按真实使用依赖编排:先配模型(硬前置)→ 建知识库 → 对话 → 智能体
|
const steps = computed<SpotlightGuideStep[]>(() => [
|
||||||
// → 设置入口 → 完成。
|
|
||||||
const steps = computed<GuideStep[]>(() => [
|
|
||||||
{ key: 'welcome' },
|
{ key: 'welcome' },
|
||||||
{
|
{
|
||||||
key: 'knowledge',
|
key: 'knowledge',
|
||||||
@@ -116,7 +43,6 @@ const steps = computed<GuideStep[]>(() => [
|
|||||||
before: () => uiStore.expandSidebar(),
|
before: () => uiStore.expandSidebar(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 模型配置在「设置」弹窗内:引导自动打开弹窗并高亮「添加模型」。
|
|
||||||
key: 'models',
|
key: 'models',
|
||||||
target: '[data-guide="settings-add-model"], [data-guide="settings-models"]',
|
target: '[data-guide="settings-add-model"], [data-guide="settings-models"]',
|
||||||
placement: 'left',
|
placement: 'left',
|
||||||
@@ -129,226 +55,6 @@ const steps = computed<GuideStep[]>(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const active = ref(false)
|
const active = ref(false)
|
||||||
const index = ref(0)
|
|
||||||
const vw = ref(window.innerWidth)
|
|
||||||
const vh = ref(window.innerHeight)
|
|
||||||
const targetRect = ref<DOMRect | null>(null)
|
|
||||||
const targetEl = ref<HTMLElement | null>(null)
|
|
||||||
const cardSize = ref({ width: CARD_WIDTH, height: 220 })
|
|
||||||
|
|
||||||
type HoleRect = { x: number; y: number; width: number; height: number }
|
|
||||||
|
|
||||||
/** 目标与相邻节点之间的可用间距(不含 margin 折叠到 border 外的部分) */
|
|
||||||
const measureNeighborGap = (el: HTMLElement, r: DOMRect) => {
|
|
||||||
let above = PAD
|
|
||||||
const prev = el.previousElementSibling
|
|
||||||
if (prev) {
|
|
||||||
above = Math.max(0, r.top - prev.getBoundingClientRect().bottom)
|
|
||||||
}
|
|
||||||
|
|
||||||
let below = PAD
|
|
||||||
const next = el.nextElementSibling
|
|
||||||
if (next) {
|
|
||||||
below = Math.max(0, next.getBoundingClientRect().top - r.bottom)
|
|
||||||
} else {
|
|
||||||
const mb = parseFloat(getComputedStyle(el).marginBottom) || 0
|
|
||||||
below = Math.max(0, PAD - mb)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { above, below }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 高亮框四边留白一致:受相邻项间距限制时同步缩小;
|
|
||||||
* 贴边 clamp 时同步收窄宽高,避免侧栏靠左时「左贴边、右留白」。
|
|
||||||
*/
|
|
||||||
const computeHighlightHole = (el: HTMLElement, r: DOMRect): HoleRect => {
|
|
||||||
const { above, below } = measureNeighborGap(el, r)
|
|
||||||
const inset = Math.min(PAD, above, below)
|
|
||||||
|
|
||||||
let x = r.left - inset
|
|
||||||
let y = r.top - inset
|
|
||||||
let width = r.width + inset * 2
|
|
||||||
let height = r.height + inset * 2
|
|
||||||
|
|
||||||
if (x < 0) {
|
|
||||||
width += x
|
|
||||||
x = 0
|
|
||||||
}
|
|
||||||
if (y < 0) {
|
|
||||||
height += y
|
|
||||||
y = 0
|
|
||||||
}
|
|
||||||
const rightOverflow = x + width - vw.value
|
|
||||||
if (rightOverflow > 0) {
|
|
||||||
width -= rightOverflow
|
|
||||||
}
|
|
||||||
const bottomOverflow = y + height - vh.value
|
|
||||||
if (bottomOverflow > 0) {
|
|
||||||
height -= bottomOverflow
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x, y, width, height }
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootRef = ref<HTMLElement | null>(null)
|
|
||||||
const cardRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const step = computed(() => steps.value[index.value] ?? steps.value[0])
|
|
||||||
const isLast = computed(() => index.value === steps.value.length - 1)
|
|
||||||
const stepTitle = computed(() => t(`newUserGuide.steps.${step.value.key}.title`))
|
|
||||||
const stepDesc = computed(() => t(`newUserGuide.steps.${step.value.key}.desc`))
|
|
||||||
|
|
||||||
const hole = computed(() => {
|
|
||||||
const el = targetEl.value
|
|
||||||
const r = targetRect.value
|
|
||||||
if (!el || !r) return null
|
|
||||||
return computeHighlightHole(el, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
const backdropPieces = computed(() => {
|
|
||||||
const h = hole.value
|
|
||||||
if (!h) return []
|
|
||||||
const w = vw.value
|
|
||||||
const v = vh.value
|
|
||||||
return [
|
|
||||||
// 上
|
|
||||||
{ top: '0px', left: '0px', width: `${w}px`, height: `${h.y}px` },
|
|
||||||
// 下
|
|
||||||
{ top: `${h.y + h.height}px`, left: '0px', width: `${w}px`, height: `${Math.max(0, v - h.y - h.height)}px` },
|
|
||||||
// 左
|
|
||||||
{ top: `${h.y}px`, left: '0px', width: `${h.x}px`, height: `${h.height}px` },
|
|
||||||
// 右
|
|
||||||
{ top: `${h.y}px`, left: `${h.x + h.width}px`, width: `${Math.max(0, w - h.x - h.width)}px`, height: `${h.height}px` },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const holeFrameStyle = computed(() => {
|
|
||||||
if (!hole.value) return {}
|
|
||||||
return {
|
|
||||||
left: `${hole.value.x}px`,
|
|
||||||
top: `${hole.value.y}px`,
|
|
||||||
width: `${hole.value.width}px`,
|
|
||||||
height: `${hole.value.height}px`,
|
|
||||||
borderRadius: `${holeRadius}px`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const spotStyle = computed(() => ({
|
|
||||||
...holeFrameStyle.value,
|
|
||||||
boxShadow: `0 0 0 9999px ${BACKDROP_COLOR}`,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const ringStyle = holeFrameStyle
|
|
||||||
|
|
||||||
const overlaps = (
|
|
||||||
a: { left: number; top: number; right: number; bottom: number },
|
|
||||||
b: { left: number; top: number; right: number; bottom: number },
|
|
||||||
) => !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom)
|
|
||||||
|
|
||||||
const cardStyle = computed(() => {
|
|
||||||
const w = Math.min(CARD_WIDTH, vw.value - EDGE * 2)
|
|
||||||
const h = cardSize.value.height
|
|
||||||
const h0 = hole.value
|
|
||||||
|
|
||||||
if (!h0) {
|
|
||||||
return {
|
|
||||||
width: `${w}px`,
|
|
||||||
left: `${(vw.value - w) / 2}px`,
|
|
||||||
top: `${Math.max(EDGE, vh.value * 0.32 - h / 2)}px`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const holeBox = { left: h0.x, top: h0.y, right: h0.x + h0.width, bottom: h0.y + h0.height }
|
|
||||||
const order: Placement[] = (() => {
|
|
||||||
const pref = step.value.placement ?? 'right'
|
|
||||||
const all: Placement[] = ['right', 'left', 'bottom', 'top']
|
|
||||||
return [pref, ...all.filter((p) => p !== pref)]
|
|
||||||
})()
|
|
||||||
|
|
||||||
const candidates: Record<Placement, { left: number; top: number }> = {
|
|
||||||
right: { left: holeBox.right + GAP, top: h0.y + h0.height / 2 - h / 2 },
|
|
||||||
left: { left: holeBox.left - w - GAP, top: h0.y + h0.height / 2 - h / 2 },
|
|
||||||
bottom: { left: h0.x + h0.width / 2 - w / 2, top: holeBox.bottom + GAP },
|
|
||||||
top: { left: h0.x + h0.width / 2 - w / 2, top: holeBox.top - h - GAP },
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const place of order) {
|
|
||||||
const c = candidates[place]
|
|
||||||
const left = Math.min(Math.max(EDGE, c.left), vw.value - w - EDGE)
|
|
||||||
const top = Math.min(Math.max(EDGE, c.top), vh.value - h - EDGE)
|
|
||||||
const cardBox = { left, top, right: left + w, bottom: top + h }
|
|
||||||
if (!overlaps(cardBox, holeBox)) {
|
|
||||||
return { width: `${w}px`, left: `${left}px`, top: `${top}px` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兜底:底部居中
|
|
||||||
const left = Math.min(Math.max(EDGE, (vw.value - w) / 2), vw.value - w - EDGE)
|
|
||||||
const top = Math.min(Math.max(EDGE, holeBox.bottom + GAP), vh.value - h - EDGE)
|
|
||||||
return { width: `${w}px`, left: `${left}px`, top: `${top}px` }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 支持以逗号分隔的多个候选选择器,按书写顺序优先匹配(用于「优先高亮按钮、
|
|
||||||
// 否则退化为标题」这类带降级的目标)。
|
|
||||||
const queryTarget = (selector?: string): HTMLElement | null => {
|
|
||||||
if (!selector) return null
|
|
||||||
for (const part of selector.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
||||||
const el = document.querySelector<HTMLElement>(part)
|
|
||||||
if (!el) continue
|
|
||||||
const r = el.getBoundingClientRect()
|
|
||||||
if (r.width > 2 && r.height > 2) return el
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const measureCard = async () => {
|
|
||||||
await nextTick()
|
|
||||||
if (cardRef.value) {
|
|
||||||
cardSize.value = {
|
|
||||||
width: cardRef.value.offsetWidth,
|
|
||||||
height: cardRef.value.offsetHeight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const locate = async (retry = 0) => {
|
|
||||||
vw.value = window.innerWidth
|
|
||||||
vh.value = window.innerHeight
|
|
||||||
|
|
||||||
const cur = step.value
|
|
||||||
if (!cur.target) {
|
|
||||||
targetEl.value = null
|
|
||||||
targetRect.value = null
|
|
||||||
await measureCard()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const el = queryTarget(cur.target)
|
|
||||||
if (!el) {
|
|
||||||
if (retry < 12) {
|
|
||||||
if (retryTimer) clearTimeout(retryTimer)
|
|
||||||
retryTimer = setTimeout(() => locate(retry + 1), 120)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 找不到目标:可选步骤跳过,否则退化为居中卡片
|
|
||||||
if (cur.optional) {
|
|
||||||
goTo(index.value + 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
targetEl.value = null
|
|
||||||
targetRect.value = null
|
|
||||||
await measureCard()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
|
||||||
targetEl.value = el
|
|
||||||
targetRect.value = el.getBoundingClientRect()
|
|
||||||
await measureCard()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeGuideSettings = () => {
|
const closeGuideSettings = () => {
|
||||||
if (settingsOpenedByGuide) {
|
if (settingsOpenedByGuide) {
|
||||||
@@ -357,56 +63,19 @@ const closeGuideSettings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goTo = async (i: number) => {
|
const onFinish = () => {
|
||||||
if (i < 0 || i >= steps.value.length) return
|
localStorage.setItem(GLOBAL_USER_GUIDE_KEY, '1')
|
||||||
if (retryTimer) {
|
closeGuideSettings()
|
||||||
clearTimeout(retryTimer)
|
}
|
||||||
retryTimer = null
|
|
||||||
}
|
const onStepChange = ({ toKey }: { toKey: string }) => {
|
||||||
index.value = i
|
if (toKey !== 'models') {
|
||||||
// 离开模型步骤时,关闭由引导自己打开的设置弹窗
|
|
||||||
if (step.value.key !== 'models') {
|
|
||||||
closeGuideSettings()
|
closeGuideSettings()
|
||||||
}
|
}
|
||||||
await step.value.before?.()
|
|
||||||
// 等路由/侧栏/弹窗过渡稳定
|
|
||||||
await new Promise((r) => setTimeout(r, step.value.before ? 280 : 0))
|
|
||||||
await locate()
|
|
||||||
await nextTick()
|
|
||||||
rootRef.value?.focus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = () => goTo(index.value + 1)
|
const open = () => {
|
||||||
const prev = () => goTo(index.value - 1)
|
|
||||||
|
|
||||||
const open = async () => {
|
|
||||||
active.value = true
|
active.value = true
|
||||||
index.value = 0
|
|
||||||
await nextTick()
|
|
||||||
await goTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
if (retryTimer) {
|
|
||||||
clearTimeout(retryTimer)
|
|
||||||
retryTimer = null
|
|
||||||
}
|
|
||||||
closeGuideSettings()
|
|
||||||
active.value = false
|
|
||||||
targetEl.value = null
|
|
||||||
targetRect.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, '1')
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const dismiss = () => finish()
|
|
||||||
|
|
||||||
const onViewportChange = () => {
|
|
||||||
if (!active.value) return
|
|
||||||
locate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenEvent = () => {
|
const handleOpenEvent = () => {
|
||||||
@@ -415,217 +84,18 @@ const handleOpenEvent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', onViewportChange)
|
|
||||||
window.addEventListener('scroll', onViewportChange, true)
|
|
||||||
window.addEventListener(OPEN_EVENT, handleOpenEvent)
|
window.addEventListener(OPEN_EVENT, handleOpenEvent)
|
||||||
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
if (localStorage.getItem(GLOBAL_USER_GUIDE_KEY) !== '1') {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (localStorage.getItem(STORAGE_KEY) !== '1') open()
|
if (localStorage.getItem(GLOBAL_USER_GUIDE_KEY) !== '1') {
|
||||||
|
open()
|
||||||
|
}
|
||||||
}, 700)
|
}, 700)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', onViewportChange)
|
|
||||||
window.removeEventListener('scroll', onViewportChange, true)
|
|
||||||
window.removeEventListener(OPEN_EVENT, handleOpenEvent)
|
window.removeEventListener(OPEN_EVENT, handleOpenEvent)
|
||||||
closeGuideSettings()
|
closeGuideSettings()
|
||||||
if (retryTimer) clearTimeout(retryTimer)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.guide {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 5000;
|
|
||||||
outline: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 四块透明挡板拦截点击;中间空洞无元素,点击穿透到下层控件。
|
|
||||||
.guide__backdrop {
|
|
||||||
position: fixed;
|
|
||||||
pointer-events: auto;
|
|
||||||
background: rgba(15, 18, 22, 0.58);
|
|
||||||
transition:
|
|
||||||
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
&--full {
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--hit {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅负责圆角遮罩绘制;不拦截指针(box-shadow 本身也不参与命中)。
|
|
||||||
.guide__spot {
|
|
||||||
position: fixed;
|
|
||||||
box-sizing: border-box;
|
|
||||||
pointer-events: none;
|
|
||||||
background: transparent;
|
|
||||||
transition:
|
|
||||||
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
height 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
border-radius 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__ring {
|
|
||||||
position: fixed;
|
|
||||||
box-sizing: border-box;
|
|
||||||
pointer-events: none;
|
|
||||||
border: 2px solid var(--td-brand-color);
|
|
||||||
box-shadow: 0 0 0 4px rgba(7, 192, 95, 0.18);
|
|
||||||
transition:
|
|
||||||
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__card {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 18px 18px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: var(--td-bg-color-container);
|
|
||||||
border: 1px solid var(--td-component-stroke);
|
|
||||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.18);
|
|
||||||
color: var(--td-text-color-primary);
|
|
||||||
max-height: calc(100vh - 32px);
|
|
||||||
overflow-y: auto;
|
|
||||||
transition:
|
|
||||||
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
&--center {
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__close {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--td-text-color-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--td-bg-color-container-hover);
|
|
||||||
color: var(--td-text-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__progress {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--td-bg-color-component);
|
|
||||||
transition: width 0.2s ease, background 0.2s ease;
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
width: 16px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--td-brand-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-done {
|
|
||||||
background: rgba(7, 192, 95, 0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__step-label {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--td-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__title {
|
|
||||||
margin: 0;
|
|
||||||
padding-right: 24px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__desc {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 22px;
|
|
||||||
color: var(--td-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--td-component-stroke);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__skip {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--td-text-color-placeholder);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--td-text-color-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide__actions-main {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-fade-enter-active,
|
|
||||||
.guide-fade-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-fade-enter-from,
|
|
||||||
.guide-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.guide__card {
|
|
||||||
left: 16px !important;
|
|
||||||
right: 16px;
|
|
||||||
width: auto !important;
|
|
||||||
top: auto !important;
|
|
||||||
bottom: 16px;
|
|
||||||
max-height: 56vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
600
frontend/src/components/SpotlightGuide.vue
Normal file
600
frontend/src/components/SpotlightGuide.vue
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="guide-fade">
|
||||||
|
<div v-if="active" class="guide" role="dialog" aria-modal="true" :aria-label="stepTitle"
|
||||||
|
@keydown.esc.prevent="dismiss" @keydown.left.prevent="prev" @keydown.right.prevent="next" tabindex="-1"
|
||||||
|
ref="rootRef">
|
||||||
|
<template v-if="hole">
|
||||||
|
<div class="guide__spot" :style="spotStyle" aria-hidden="true" />
|
||||||
|
<div v-for="(piece, i) in backdropPieces" :key="i" class="guide__backdrop guide__backdrop--hit"
|
||||||
|
:style="piece" />
|
||||||
|
</template>
|
||||||
|
<div v-else class="guide__backdrop guide__backdrop--full" />
|
||||||
|
|
||||||
|
<div v-if="hole" class="guide__ring" :style="ringStyle" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div ref="cardRef" class="guide__card" :class="{ 'guide__card--center': !hole }" :style="cardStyle">
|
||||||
|
<button type="button" class="guide__close" :aria-label="t(`${labelsPrefix}.skip`)" @click="dismiss">
|
||||||
|
<t-icon name="close" size="18px" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="guide__progress">
|
||||||
|
<span v-for="(s, i) in steps" :key="s.key" class="guide__dot"
|
||||||
|
:class="{ 'is-active': i === index, 'is-done': i < index }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="guide__step-label">{{ t(`${labelsPrefix}.stepOf`, { current: index + 1, total: steps.length }) }}
|
||||||
|
</p>
|
||||||
|
<h3 class="guide__title">{{ stepTitle }}</h3>
|
||||||
|
<p class="guide__desc">{{ stepDesc }}</p>
|
||||||
|
<p v-if="step.interact" class="guide__interact-hint">{{ t(`${labelsPrefix}.interactHint`) }}</p>
|
||||||
|
|
||||||
|
<div class="guide__actions">
|
||||||
|
<button type="button" class="guide__skip" @click="dismiss">{{ t(`${labelsPrefix}.skip`) }}</button>
|
||||||
|
<div v-if="!step.interact" class="guide__actions-main">
|
||||||
|
<t-button v-if="index > 0" size="small" variant="outline" @click="prev">
|
||||||
|
{{ t(`${labelsPrefix}.prev`) }}
|
||||||
|
</t-button>
|
||||||
|
<t-button v-if="!isLast" size="small" theme="primary" @click="next">
|
||||||
|
{{ t(`${labelsPrefix}.next`) }}
|
||||||
|
</t-button>
|
||||||
|
<t-button v-else size="small" theme="primary" @click="finish">
|
||||||
|
{{ t(`${labelsPrefix}.done`) }}
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { SpotlightGuideStep } from '@/types/spotlightGuide'
|
||||||
|
|
||||||
|
const CARD_WIDTH = 340
|
||||||
|
const GAP = 16
|
||||||
|
const EDGE = 16
|
||||||
|
const PAD = 8
|
||||||
|
const holeRadius = 8
|
||||||
|
const BACKDROP_COLOR = 'rgba(15, 18, 22, 0.58)'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active: boolean
|
||||||
|
steps: SpotlightGuideStep[]
|
||||||
|
/** i18n 前缀,步骤文案为 `${stepI18nPrefix}.${key}.title|desc` */
|
||||||
|
stepI18nPrefix: string
|
||||||
|
/** skip/prev/next/done/stepOf 所在前缀,默认 newUserGuide */
|
||||||
|
labelsPrefix?: string
|
||||||
|
/** 每步 before 执行后的等待毫秒数 */
|
||||||
|
beforeDelayMs?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
labelsPrefix: 'newUserGuide',
|
||||||
|
beforeDelayMs: 280,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:active': [value: boolean]
|
||||||
|
finish: []
|
||||||
|
dismiss: []
|
||||||
|
'step-change': [payload: { fromKey?: string; toKey: string; index: number }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const index = ref(0)
|
||||||
|
const vw = ref(window.innerWidth)
|
||||||
|
const vh = ref(window.innerHeight)
|
||||||
|
const targetRect = ref<DOMRect | null>(null)
|
||||||
|
const targetEl = ref<HTMLElement | null>(null)
|
||||||
|
const cardSize = ref({ width: CARD_WIDTH, height: 220 })
|
||||||
|
|
||||||
|
type HoleRect = { x: number; y: number; width: number; height: number }
|
||||||
|
|
||||||
|
const measureNeighborGap = (el: HTMLElement, r: DOMRect) => {
|
||||||
|
let above = PAD
|
||||||
|
const prev = el.previousElementSibling
|
||||||
|
if (prev) {
|
||||||
|
above = Math.max(0, r.top - prev.getBoundingClientRect().bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
let below = PAD
|
||||||
|
const next = el.nextElementSibling
|
||||||
|
if (next) {
|
||||||
|
below = Math.max(0, next.getBoundingClientRect().top - r.bottom)
|
||||||
|
} else {
|
||||||
|
const mb = parseFloat(getComputedStyle(el).marginBottom) || 0
|
||||||
|
below = Math.max(0, PAD - mb)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { above, below }
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeHighlightHole = (el: HTMLElement, r: DOMRect): HoleRect => {
|
||||||
|
const { above, below } = measureNeighborGap(el, r)
|
||||||
|
const inset = Math.min(PAD, above, below)
|
||||||
|
|
||||||
|
let x = r.left - inset
|
||||||
|
let y = r.top - inset
|
||||||
|
let width = r.width + inset * 2
|
||||||
|
let height = r.height + inset * 2
|
||||||
|
|
||||||
|
if (x < 0) {
|
||||||
|
width += x
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
if (y < 0) {
|
||||||
|
height += y
|
||||||
|
y = 0
|
||||||
|
}
|
||||||
|
const rightOverflow = x + width - vw.value
|
||||||
|
if (rightOverflow > 0) {
|
||||||
|
width -= rightOverflow
|
||||||
|
}
|
||||||
|
const bottomOverflow = y + height - vh.value
|
||||||
|
if (bottomOverflow > 0) {
|
||||||
|
height -= bottomOverflow
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x, y, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
const cardRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const step = computed(() => props.steps[index.value] ?? props.steps[0])
|
||||||
|
const isLast = computed(() => index.value === props.steps.length - 1)
|
||||||
|
const stepTitle = computed(() => t(`${props.stepI18nPrefix}.${step.value.key}.title`))
|
||||||
|
const stepDesc = computed(() => t(`${props.stepI18nPrefix}.${step.value.key}.desc`))
|
||||||
|
|
||||||
|
const hole = computed(() => {
|
||||||
|
const el = targetEl.value
|
||||||
|
const r = targetRect.value
|
||||||
|
if (!el || !r) return null
|
||||||
|
return computeHighlightHole(el, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
const backdropPieces = computed(() => {
|
||||||
|
const h = hole.value
|
||||||
|
if (!h) return []
|
||||||
|
const w = vw.value
|
||||||
|
const v = vh.value
|
||||||
|
return [
|
||||||
|
{ top: '0px', left: '0px', width: `${w}px`, height: `${h.y}px` },
|
||||||
|
{
|
||||||
|
top: `${h.y + h.height}px`,
|
||||||
|
left: '0px',
|
||||||
|
width: `${w}px`,
|
||||||
|
height: `${Math.max(0, v - h.y - h.height)}px`,
|
||||||
|
},
|
||||||
|
{ top: `${h.y}px`, left: '0px', width: `${h.x}px`, height: `${h.height}px` },
|
||||||
|
{
|
||||||
|
top: `${h.y}px`,
|
||||||
|
left: `${h.x + h.width}px`,
|
||||||
|
width: `${Math.max(0, w - h.x - h.width)}px`,
|
||||||
|
height: `${h.height}px`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const holeFrameStyle = computed(() => {
|
||||||
|
if (!hole.value) return {}
|
||||||
|
return {
|
||||||
|
left: `${hole.value.x}px`,
|
||||||
|
top: `${hole.value.y}px`,
|
||||||
|
width: `${hole.value.width}px`,
|
||||||
|
height: `${hole.value.height}px`,
|
||||||
|
borderRadius: `${holeRadius}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const spotStyle = computed(() => ({
|
||||||
|
...holeFrameStyle.value,
|
||||||
|
boxShadow: `0 0 0 9999px ${BACKDROP_COLOR}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ringStyle = holeFrameStyle
|
||||||
|
|
||||||
|
const overlaps = (
|
||||||
|
a: { left: number; top: number; right: number; bottom: number },
|
||||||
|
b: { left: number; top: number; right: number; bottom: number },
|
||||||
|
) => !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom)
|
||||||
|
|
||||||
|
const cardStyle = computed(() => {
|
||||||
|
const w = Math.min(CARD_WIDTH, vw.value - EDGE * 2)
|
||||||
|
const h = cardSize.value.height
|
||||||
|
const h0 = hole.value
|
||||||
|
|
||||||
|
if (!h0) {
|
||||||
|
return {
|
||||||
|
width: `${w}px`,
|
||||||
|
left: `${(vw.value - w) / 2}px`,
|
||||||
|
top: `${Math.max(EDGE, vh.value * 0.32 - h / 2)}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const holeBox = { left: h0.x, top: h0.y, right: h0.x + h0.width, bottom: h0.y + h0.height }
|
||||||
|
type Placement = 'right' | 'left' | 'bottom' | 'top'
|
||||||
|
const order: Placement[] = (() => {
|
||||||
|
const pref = step.value.placement ?? 'right'
|
||||||
|
const all: Placement[] = ['right', 'left', 'bottom', 'top']
|
||||||
|
return [pref, ...all.filter((p) => p !== pref)]
|
||||||
|
})()
|
||||||
|
|
||||||
|
const candidates: Record<Placement, { left: number; top: number }> = {
|
||||||
|
right: { left: holeBox.right + GAP, top: h0.y + h0.height / 2 - h / 2 },
|
||||||
|
left: { left: holeBox.left - w - GAP, top: h0.y + h0.height / 2 - h / 2 },
|
||||||
|
bottom: { left: h0.x + h0.width / 2 - w / 2, top: holeBox.bottom + GAP },
|
||||||
|
top: { left: h0.x + h0.width / 2 - w / 2, top: holeBox.top - h - GAP },
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const place of order) {
|
||||||
|
const c = candidates[place]
|
||||||
|
const left = Math.min(Math.max(EDGE, c.left), vw.value - w - EDGE)
|
||||||
|
const top = Math.min(Math.max(EDGE, c.top), vh.value - h - EDGE)
|
||||||
|
const cardBox = { left, top, right: left + w, bottom: top + h }
|
||||||
|
if (!overlaps(cardBox, holeBox)) {
|
||||||
|
return { width: `${w}px`, left: `${left}px`, top: `${top}px` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = Math.min(Math.max(EDGE, (vw.value - w) / 2), vw.value - w - EDGE)
|
||||||
|
const top = Math.min(Math.max(EDGE, holeBox.bottom + GAP), vh.value - h - EDGE)
|
||||||
|
return { width: `${w}px`, left: `${left}px`, top: `${top}px` }
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryTarget = (selector?: string): HTMLElement | null => {
|
||||||
|
if (!selector) return null
|
||||||
|
for (const part of selector.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const el = document.querySelector<HTMLElement>(part)
|
||||||
|
if (!el) continue
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
if (r.width > 2 && r.height > 2) return el
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureCard = async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (cardRef.value) {
|
||||||
|
cardSize.value = {
|
||||||
|
width: cardRef.value.offsetWidth,
|
||||||
|
height: cardRef.value.offsetHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locate = async (retry = 0) => {
|
||||||
|
vw.value = window.innerWidth
|
||||||
|
vh.value = window.innerHeight
|
||||||
|
|
||||||
|
const cur = step.value
|
||||||
|
if (!cur.target) {
|
||||||
|
targetEl.value = null
|
||||||
|
targetRect.value = null
|
||||||
|
await measureCard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = queryTarget(cur.target)
|
||||||
|
if (!el) {
|
||||||
|
if (retry < 12) {
|
||||||
|
if (retryTimer) clearTimeout(retryTimer)
|
||||||
|
retryTimer = setTimeout(() => locate(retry + 1), 120)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cur.optional) {
|
||||||
|
goTo(index.value + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetEl.value = null
|
||||||
|
targetRect.value = null
|
||||||
|
await measureCard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
|
||||||
|
targetEl.value = el
|
||||||
|
targetRect.value = el.getBoundingClientRect()
|
||||||
|
await measureCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goTo = async (i: number) => {
|
||||||
|
if (i < 0 || i >= props.steps.length) return
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
const fromKey = props.steps[index.value]?.key
|
||||||
|
index.value = i
|
||||||
|
emit('step-change', { fromKey, toKey: step.value.key, index: i })
|
||||||
|
await step.value.before?.()
|
||||||
|
const delay = step.value.before ? props.beforeDelayMs : 0
|
||||||
|
if (delay > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, delay))
|
||||||
|
}
|
||||||
|
await locate()
|
||||||
|
await nextTick()
|
||||||
|
rootRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = () => goTo(index.value + 1)
|
||||||
|
const prev = () => goTo(index.value - 1)
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
emit('update:active', false)
|
||||||
|
targetEl.value = null
|
||||||
|
targetRect.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
emit('finish')
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
emit('dismiss')
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onViewportChange = () => {
|
||||||
|
if (!props.active) return
|
||||||
|
locate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
index.value = 0
|
||||||
|
emit('update:active', true)
|
||||||
|
await nextTick()
|
||||||
|
await goTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(val, old) => {
|
||||||
|
if (val && !old) {
|
||||||
|
open()
|
||||||
|
} else if (!val && old) {
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', onViewportChange)
|
||||||
|
window.removeEventListener('scroll', onViewportChange, true)
|
||||||
|
if (retryTimer) clearTimeout(retryTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
window.addEventListener('resize', onViewportChange)
|
||||||
|
window.addEventListener('scroll', onViewportChange, true)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('resize', onViewportChange)
|
||||||
|
window.removeEventListener('scroll', onViewportChange, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ open, close })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.guide {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
outline: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(15, 18, 22, 0.58);
|
||||||
|
transition:
|
||||||
|
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hit {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__spot {
|
||||||
|
position: fixed;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent;
|
||||||
|
transition:
|
||||||
|
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-radius 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__ring {
|
||||||
|
position: fixed;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid var(--td-brand-color);
|
||||||
|
box-shadow: 0 0 0 4px rgba(7, 192, 95, 0.18);
|
||||||
|
transition:
|
||||||
|
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__card {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
border: 1px solid var(--td-component-stroke);
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.18);
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
transition:
|
||||||
|
top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&--center {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--td-bg-color-container-hover);
|
||||||
|
color: var(--td-text-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__progress {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--td-bg-color-component);
|
||||||
|
transition: width 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
width: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-done {
|
||||||
|
background: rgba(7, 192, 95, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__step-label {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__title {
|
||||||
|
margin: 0;
|
||||||
|
padding-right: 24px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__interact-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--td-component-stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__skip {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide__actions-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-fade-enter-active,
|
||||||
|
.guide-fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-fade-enter-from,
|
||||||
|
.guide-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.guide__card {
|
||||||
|
left: 16px !important;
|
||||||
|
right: 16px;
|
||||||
|
width: auto !important;
|
||||||
|
top: auto !important;
|
||||||
|
bottom: 16px;
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
127
frontend/src/components/TenantModelsGuide.vue
Normal file
127
frontend/src/components/TenantModelsGuide.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<SpotlightGuide v-model:active="active" :steps="steps" :step-i18n-prefix="stepI18nPrefix"
|
||||||
|
labels-prefix="contextualGuide" @finish="onFinish" @dismiss="onFinish" @step-change="onStepChange" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import SpotlightGuide from '@/components/SpotlightGuide.vue'
|
||||||
|
import {
|
||||||
|
isContextualGuideDone,
|
||||||
|
isGlobalUserGuideDone,
|
||||||
|
markContextualGuideDone,
|
||||||
|
} from '@/config/contextualGuides'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import type { SpotlightGuideStep } from '@/types/spotlightGuide'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
when: boolean
|
||||||
|
/** documentKb:需对话+Embedding;agent:仅需对话模型 */
|
||||||
|
variant?: 'documentKb' | 'agent'
|
||||||
|
}>(),
|
||||||
|
{ variant: 'documentKb' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const stepI18nPrefix = computed(() =>
|
||||||
|
props.variant === 'agent'
|
||||||
|
? 'contextualGuide.tenantModels.stepsAgent'
|
||||||
|
: 'contextualGuide.tenantModels.steps',
|
||||||
|
)
|
||||||
|
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
const active = ref(false)
|
||||||
|
let settingsOpenedByGuide = false
|
||||||
|
|
||||||
|
const steps: SpotlightGuideStep[] = [
|
||||||
|
{ key: 'intro' },
|
||||||
|
{
|
||||||
|
key: 'addModel',
|
||||||
|
target: '[data-guide="settings-add-model"], [data-guide="settings-models"]',
|
||||||
|
placement: 'left',
|
||||||
|
before: () => {
|
||||||
|
uiStore.openSettings('models')
|
||||||
|
settingsOpenedByGuide = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'done' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let openTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const closeGuideSettings = () => {
|
||||||
|
if (settingsOpenedByGuide) {
|
||||||
|
uiStore.closeSettings()
|
||||||
|
settingsOpenedByGuide = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFinish = () => {
|
||||||
|
markContextualGuideDone('tenantModels')
|
||||||
|
closeGuideSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStepChange = ({ toKey }: { toKey: string }) => {
|
||||||
|
if (toKey !== 'addModel') {
|
||||||
|
closeGuideSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let waitGlobalTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const tryOpen = () => {
|
||||||
|
if (active.value || !props.when || isContextualGuideDone('tenantModels')) return
|
||||||
|
openTimer = setTimeout(() => {
|
||||||
|
if (!props.when || isContextualGuideDone('tenantModels')) return
|
||||||
|
active.value = true
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleOpen = () => {
|
||||||
|
if (openTimer) {
|
||||||
|
clearTimeout(openTimer)
|
||||||
|
openTimer = null
|
||||||
|
}
|
||||||
|
if (waitGlobalTimer) {
|
||||||
|
clearTimeout(waitGlobalTimer)
|
||||||
|
waitGlobalTimer = null
|
||||||
|
}
|
||||||
|
if (!props.when || isContextualGuideDone('tenantModels')) return
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const poll = () => {
|
||||||
|
if (!props.when || isContextualGuideDone('tenantModels')) return
|
||||||
|
if (isGlobalUserGuideDone()) {
|
||||||
|
tryOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
waitGlobalTimer = setTimeout(poll, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.when,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
if (openTimer) clearTimeout(openTimer)
|
||||||
|
if (waitGlobalTimer) clearTimeout(waitGlobalTimer)
|
||||||
|
openTimer = null
|
||||||
|
waitGlobalTimer = null
|
||||||
|
active.value = false
|
||||||
|
closeGuideSettings()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleOpen()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (openTimer) clearTimeout(openTimer)
|
||||||
|
if (waitGlobalTimer) clearTimeout(waitGlobalTimer)
|
||||||
|
closeGuideSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
54
frontend/src/composables/useTenantModelReadiness.ts
Normal file
54
frontend/src/composables/useTenantModelReadiness.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
import {
|
||||||
|
fetchTenantModelReadiness,
|
||||||
|
type TenantModelReadiness,
|
||||||
|
} from '@/utils/tenantModelReadiness'
|
||||||
|
|
||||||
|
export function useTenantModelReadiness() {
|
||||||
|
const uiStore = useUIStore()
|
||||||
|
const readiness = ref<TenantModelReadiness | null>(null)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
readiness.value = await fetchTenantModelReadiness()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => uiStore.showSettingsModal,
|
||||||
|
(open, wasOpen) => {
|
||||||
|
if (wasOpen && !open) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const isReadyForDocumentKb = computed(
|
||||||
|
() => readiness.value?.isReadyForDocumentKb ?? false,
|
||||||
|
)
|
||||||
|
|
||||||
|
const isReadyForAgent = computed(() => readiness.value?.isReadyForAgent ?? false)
|
||||||
|
|
||||||
|
const hasChat = computed(() => readiness.value?.hasChat ?? false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
readiness,
|
||||||
|
loaded,
|
||||||
|
loading,
|
||||||
|
refresh,
|
||||||
|
isReadyForDocumentKb,
|
||||||
|
isReadyForAgent,
|
||||||
|
hasChat,
|
||||||
|
}
|
||||||
|
}
|
||||||
140
frontend/src/config/contextualGuides.ts
Normal file
140
frontend/src/config/contextualGuides.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { SpotlightGuideStep } from '@/types/spotlightGuide'
|
||||||
|
|
||||||
|
export const GLOBAL_USER_GUIDE_KEY = 'weknora:new-user-guide-done:v1'
|
||||||
|
|
||||||
|
export const KB_EDITOR_FOCUS_SECTION_EVENT = 'weknora:kb-editor-focus-section'
|
||||||
|
export const AGENT_EDITOR_FOCUS_SECTION_EVENT = 'weknora:agent-editor-focus-section'
|
||||||
|
|
||||||
|
export type ContextualGuideTourId =
|
||||||
|
| 'kbList'
|
||||||
|
| 'kbCreate'
|
||||||
|
| 'kbDetail'
|
||||||
|
| 'chat'
|
||||||
|
| 'tenantModels'
|
||||||
|
| 'agentList'
|
||||||
|
| 'agentCreate'
|
||||||
|
|
||||||
|
const focusKbEditorSection = (section: string) => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(KB_EDITOR_FOCUS_SECTION_EVENT, { detail: { section } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusKbEditorBasic = () => focusKbEditorSection('basic')
|
||||||
|
|
||||||
|
export interface ContextualGuideTourConfig {
|
||||||
|
storageKey: string
|
||||||
|
stepI18nPrefix: string
|
||||||
|
steps: SpotlightGuideStep[]
|
||||||
|
/** 首次展示前的延迟(毫秒) */
|
||||||
|
openDelayMs: number
|
||||||
|
/** 完成本引导时一并标记为已完成的其他引导 */
|
||||||
|
alsoCompleteTours?: ContextualGuideTourId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTEXTUAL_GUIDE_TOURS: Record<ContextualGuideTourId, ContextualGuideTourConfig> = {
|
||||||
|
kbList: {
|
||||||
|
storageKey: 'weknora:contextual-guide-kb-list:v2',
|
||||||
|
stepI18nPrefix: 'contextualGuide.kbList.steps',
|
||||||
|
openDelayMs: 500,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
// 空列表时优先高亮居中的主 CTA,否则退化为顶栏新建按钮
|
||||||
|
target: '.empty-state-btn[data-guide="kb-list-create"], [data-guide="kb-list-create"]',
|
||||||
|
placement: 'bottom',
|
||||||
|
interact: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 步骤由 KbCreateContextualGuide.vue 按文档库/FAQ 动态组装
|
||||||
|
kbCreate: {
|
||||||
|
storageKey: 'weknora:contextual-guide-kb-create:v3',
|
||||||
|
stepI18nPrefix: 'contextualGuide.kbCreate.steps',
|
||||||
|
openDelayMs: 450,
|
||||||
|
alsoCompleteTours: ['kbList'],
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
tenantModels: {
|
||||||
|
storageKey: 'weknora:contextual-guide-tenant-models:v1',
|
||||||
|
stepI18nPrefix: 'contextualGuide.tenantModels.steps',
|
||||||
|
openDelayMs: 500,
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
kbDetail: {
|
||||||
|
storageKey: 'weknora:contextual-guide-kb-detail:v1',
|
||||||
|
stepI18nPrefix: 'contextualGuide.kbDetail.steps',
|
||||||
|
openDelayMs: 600,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'intro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upload',
|
||||||
|
target: '[data-guide="kb-detail-add-doc"]',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{ key: 'done' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
storageKey: 'weknora:contextual-guide-chat:v1',
|
||||||
|
stepI18nPrefix: 'contextualGuide.chat.steps',
|
||||||
|
openDelayMs: 800,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'kb',
|
||||||
|
target: '[data-guide="chat-kb-mention"]',
|
||||||
|
placement: 'top',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'input',
|
||||||
|
target: '[data-guide="chat-input"]',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'send',
|
||||||
|
target: '[data-guide="chat-send"]',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{ key: 'done' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
agentList: {
|
||||||
|
storageKey: 'weknora:contextual-guide-agent-list:v1',
|
||||||
|
stepI18nPrefix: 'contextualGuide.agentList.steps',
|
||||||
|
openDelayMs: 500,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
target: '.empty-state-btn[data-guide="agent-list-create"], [data-guide="agent-list-create"]',
|
||||||
|
placement: 'bottom',
|
||||||
|
interact: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
agentCreate: {
|
||||||
|
storageKey: 'weknora:contextual-guide-agent-create:v1',
|
||||||
|
stepI18nPrefix: 'contextualGuide.agentCreate.steps',
|
||||||
|
openDelayMs: 450,
|
||||||
|
alsoCompleteTours: ['agentList'],
|
||||||
|
steps: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContextualGuideDone(tourId: ContextualGuideTourId): boolean {
|
||||||
|
return localStorage.getItem(CONTEXTUAL_GUIDE_TOURS[tourId].storageKey) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markContextualGuideDone(tourId: ContextualGuideTourId) {
|
||||||
|
const config = CONTEXTUAL_GUIDE_TOURS[tourId]
|
||||||
|
localStorage.setItem(config.storageKey, '1')
|
||||||
|
config.alsoCompleteTours?.forEach((id) => {
|
||||||
|
localStorage.setItem(CONTEXTUAL_GUIDE_TOURS[id].storageKey, '1')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGlobalUserGuideDone(): boolean {
|
||||||
|
return localStorage.getItem(GLOBAL_USER_GUIDE_KEY) === '1'
|
||||||
|
}
|
||||||
@@ -65,6 +65,210 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
contextualGuide: {
|
||||||
|
stepOf: '{current} / {total}',
|
||||||
|
skip: 'Skip',
|
||||||
|
prev: 'Back',
|
||||||
|
next: 'Next',
|
||||||
|
done: 'Got it',
|
||||||
|
interactHint: 'Click the highlighted area to continue',
|
||||||
|
kbList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: 'Create your first knowledge base',
|
||||||
|
desc: 'Knowledge bases hold documents and FAQs. Click the highlighted "New knowledge base" button below and we will walk you through the form.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tenantModels: {
|
||||||
|
needModelsFirst: 'Add a chat model and an Embedding model before creating a knowledge base.',
|
||||||
|
needChatModelFirst: 'Add a chat model (KnowledgeQA) before creating an agent.',
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: 'Configure models first',
|
||||||
|
desc: 'A document knowledge base needs at least one chat model (summaries and Q&A) and one Embedding model (vector search). Add them in system settings.',
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: 'Add models',
|
||||||
|
desc: 'Click "Add model" and configure KnowledgeQA (chat) and Embedding types. Lite users can pull local models via Ollama.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Then continue',
|
||||||
|
desc: 'After saving models, close settings and click "New knowledge base". The wizard will walk you through type, indexing, and model binding.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stepsAgent: {
|
||||||
|
intro: {
|
||||||
|
title: 'Configure a chat model first',
|
||||||
|
desc: 'Creating an agent requires at least one KnowledgeQA model. Add it in system settings (Embedding is only required for knowledge bases).',
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: 'Add a chat model',
|
||||||
|
desc: 'Click "Add model" and configure a KnowledgeQA type.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Then create an agent',
|
||||||
|
desc: 'After saving, close settings and click "Create agent". The wizard covers mode, knowledge bases, and multimodal options.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbCreate: {
|
||||||
|
steps: {
|
||||||
|
type: {
|
||||||
|
title: 'Choose a type',
|
||||||
|
desc: 'Document bases are for PDFs, Word files, and similar uploads. FAQ bases are for question–answer pairs. The type cannot be changed later.',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: 'Enter a name',
|
||||||
|
desc: 'Pick a clear name such as "Product manual" or "Support FAQ". Description is optional.',
|
||||||
|
},
|
||||||
|
indexing: {
|
||||||
|
title: 'Indexing capabilities',
|
||||||
|
desc: 'Vector and keyword search are on by default. You can also enable Wiki or knowledge-graph indexing. Keep at least one search mode enabled.',
|
||||||
|
},
|
||||||
|
navModels: {
|
||||||
|
title: 'Model setup (required)',
|
||||||
|
desc: 'Every knowledge base needs a chat model; retrieval also requires an Embedding model. Open "Model configuration" on the left.',
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
title: 'Chat / summary model',
|
||||||
|
desc: 'Used for summaries and answers. If the list is empty, use the dropdown to open settings and add a model.',
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
title: 'Embedding model',
|
||||||
|
desc: 'Turns text into vectors for semantic search. Works with vector/keyword indexing above.',
|
||||||
|
},
|
||||||
|
parser: {
|
||||||
|
title: 'Parser engine (optional)',
|
||||||
|
desc: 'How PDFs and Office files are parsed. Defaults work for most cases; adjust if you need OCR or special layouts.',
|
||||||
|
},
|
||||||
|
chunking: {
|
||||||
|
title: 'Chunking (optional)',
|
||||||
|
desc: 'How documents are split for retrieval. Default chunk sizes are tuned for RAG and rarely need changes.',
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
title: 'Storage (optional)',
|
||||||
|
desc: 'Where raw files are stored (local or object storage). The tenant default is usually fine.',
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: 'Multimodal / images (optional)',
|
||||||
|
desc: 'Enable this if documents contain charts, scans, or image-heavy content that needs vision understanding.',
|
||||||
|
},
|
||||||
|
multimodalToggle: {
|
||||||
|
title: 'Enable multimodal parsing',
|
||||||
|
desc: 'When on, image-bearing uploads are processed with a vision-language model for better retrieval.',
|
||||||
|
},
|
||||||
|
multimodalVllm: {
|
||||||
|
title: 'Choose a VLM model',
|
||||||
|
desc: 'Multimodal requires a VLM. Add one in system settings if the list is empty.',
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: 'FAQ indexing',
|
||||||
|
desc: 'Choose how Q&A pairs are indexed. You can add FAQ entries after creation.',
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: 'Create the knowledge base',
|
||||||
|
desc: 'When type, name, and models look correct, click the highlighted Create button. You will then be guided to upload your first document.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: 'Create your agent',
|
||||||
|
desc: 'Agents combine models, knowledge bases, tools, and prompts into reusable assistants. Click the highlighted "Create agent" button.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentCreate: {
|
||||||
|
steps: {
|
||||||
|
mode: {
|
||||||
|
title: 'Choose run mode',
|
||||||
|
desc: '"Quick answer" for straightforward Q&A; "Smart reasoning" uses tools and multi-step thinking for complex tasks.',
|
||||||
|
},
|
||||||
|
agentType: {
|
||||||
|
title: 'Choose an agent type',
|
||||||
|
desc: 'Presets fill in the system prompt, recommended tools, and knowledge scope (e.g. Wiki builder, data analysis). Switch by scenario—name and description update accordingly.',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: 'Name and description',
|
||||||
|
desc: 'Pick a recognizable name. Smart-reasoning mode may pre-fill a default you can edit.',
|
||||||
|
},
|
||||||
|
navModel: {
|
||||||
|
title: 'Bind a chat model',
|
||||||
|
desc: 'Every agent needs a KnowledgeQA model as its reasoning engine.',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
title: 'Select model',
|
||||||
|
desc: 'Choose from configured chat models, or add one in system settings first.',
|
||||||
|
},
|
||||||
|
navKnowledge: {
|
||||||
|
title: 'Link knowledge bases',
|
||||||
|
desc: 'Control which knowledge the agent can retrieve. Default is all knowledge bases.',
|
||||||
|
},
|
||||||
|
knowledge: {
|
||||||
|
title: 'Knowledge scope',
|
||||||
|
desc: '"All" for general assistants; "Selected" for a domain; "None" relies on the model alone or web search.',
|
||||||
|
},
|
||||||
|
navWebsearch: {
|
||||||
|
title: 'Web search (optional)',
|
||||||
|
desc: 'Allow the agent to call external search for up-to-date information.',
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: 'Image upload (optional)',
|
||||||
|
desc: 'Lets users send images in chat; requires a VLM model in system settings.',
|
||||||
|
},
|
||||||
|
multimodal: {
|
||||||
|
title: 'Enable image understanding',
|
||||||
|
desc: 'Turn on the switch and select a VLM below when enabled.',
|
||||||
|
},
|
||||||
|
navTools: {
|
||||||
|
title: 'Tools & MCP (optional)',
|
||||||
|
desc: 'In smart-reasoning mode, enable built-in tools and MCP services for search, code, and more.',
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: 'Save the agent',
|
||||||
|
desc: 'Click the highlighted confirm button to finish. You can then select this agent in chat.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbDetail: {
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: 'This knowledge base is empty',
|
||||||
|
desc: 'Add your first item so you can search and chat over it. You can also drag and drop supported file types.',
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: 'Add documents',
|
||||||
|
desc: 'Use this menu to upload files or folders, import a URL, or create content online.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Ready after parsing',
|
||||||
|
desc: 'Once documents are indexed, mention this knowledge base in chat with @ to get answers with citations.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
steps: {
|
||||||
|
kb: {
|
||||||
|
title: 'Choose knowledge scope',
|
||||||
|
desc: 'Click @ to pick one or more knowledge bases or files. Answers use only the selection; otherwise the current agent settings apply.',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
title: 'Type your question',
|
||||||
|
desc: 'Describe what you want to know, or click a suggested question above to get started quickly.',
|
||||||
|
},
|
||||||
|
send: {
|
||||||
|
title: 'Send to start chatting',
|
||||||
|
desc: 'Sending creates a new session. The AI answers using your knowledge base and shows cited passages.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'You are ready to explore',
|
||||||
|
desc: 'Try a question related to your uploaded documents and see grounded answers with references.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
batchManage: {
|
batchManage: {
|
||||||
title: 'Manage Conversations',
|
title: 'Manage Conversations',
|
||||||
selectAll: 'Select All',
|
selectAll: 'Select All',
|
||||||
|
|||||||
@@ -65,6 +65,210 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
contextualGuide: {
|
||||||
|
stepOf: "{current} / {total}",
|
||||||
|
skip: "건너뛰기",
|
||||||
|
prev: "이전",
|
||||||
|
next: "다음",
|
||||||
|
done: "확인",
|
||||||
|
interactHint: "강조된 영역을 클릭하여 계속하세요",
|
||||||
|
kbList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: "첫 지식 베이스 만들기",
|
||||||
|
desc: "지식 베이스에 문서와 FAQ를 보관합니다. 아래에 강조된 「새 지식 베이스」 버튼을 누르면 양식 작성을 안내합니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tenantModels: {
|
||||||
|
needModelsFirst: "지식 베이스를 만들기 전에 대화 모델과 Embedding 모델을 추가하세요.",
|
||||||
|
needChatModelFirst: "에이전트를 만들기 전에 대화 모델(KnowledgeQA)을 추가하세요.",
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: "먼저 모델을 구성하세요",
|
||||||
|
desc: "문서 지식 베이스에는 대화 모델(요약·Q&A)과 Embedding 모델(벡터 검색)이 각각 하나 이상 필요합니다. 시스템 설정에서 추가하세요.",
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: "모델 추가",
|
||||||
|
desc: "「모델 추가」를 눌러 KnowledgeQA(대화)와 Embedding 유형을 구성하세요. Lite 사용자는 Ollama로 로컬 모델을 받을 수 있습니다.",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "추가 후 계속",
|
||||||
|
desc: "모델을 저장하고 설정을 닫은 뒤 「새 지식 베이스」를 클릭하세요. 마법사가 유형·색인·모델 연결을 안내합니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stepsAgent: {
|
||||||
|
intro: {
|
||||||
|
title: "먼저 대화 모델을 구성하세요",
|
||||||
|
desc: "에이전트 생성에는 KnowledgeQA 모델이 최소 하나 필요합니다. 시스템 설정에서 추가하세요(Embedding은 지식 베이스에만 필요).",
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: "대화 모델 추가",
|
||||||
|
desc: "「모델 추가」를 눌러 KnowledgeQA 유형을 구성하세요.",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "이후 에이전트 생성",
|
||||||
|
desc: "저장 후 설정을 닫고 「에이전트 생성」을 클릭하세요. 마법사가 모드·지식 베이스·멀티모달 옵션을 안내합니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbCreate: {
|
||||||
|
steps: {
|
||||||
|
type: {
|
||||||
|
title: "유형 선택",
|
||||||
|
desc: "문서 라이브러리는 PDF·Word 등 파일용, FAQ 라이브러리는 질문·답변 쌍용입니다. 생성 후 유형은 변경할 수 없습니다.",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: "이름 입력",
|
||||||
|
desc: "「제품 매뉴얼」「고객 FAQ」처럼 알아보기 쉬운 이름을 입력하세요. 설명은 선택 사항입니다.",
|
||||||
|
},
|
||||||
|
indexing: {
|
||||||
|
title: "색인 기능",
|
||||||
|
desc: "벡터·키워드 검색이 기본으로 켜져 있습니다. Wiki·지식 그래프도 선택할 수 있습니다. 검색 방식은 하나 이상 유지하세요.",
|
||||||
|
},
|
||||||
|
navModels: {
|
||||||
|
title: "모델 구성(필수)",
|
||||||
|
desc: "모든 지식 베이스에 대화 모델이 필요하며, 검색을 쓰려면 Embedding 모델도 필요합니다. 왼쪽 「모델 구성」을 여세요.",
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
title: "대화/요약 모델",
|
||||||
|
desc: "요약·답변 생성에 사용됩니다. 목록이 비어 있으면 드롭다운에서 설정으로 이동해 모델을 추가하세요.",
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
title: "Embedding 모델",
|
||||||
|
desc: "텍스트를 벡터로 바꿔 의미 검색을 지원합니다. 위의 벡터/키워드 색인과 함께 사용합니다.",
|
||||||
|
},
|
||||||
|
parser: {
|
||||||
|
title: "파서 엔진(선택)",
|
||||||
|
desc: "PDF·Office 파일 파싱 방식을 제어합니다. 기본값으로 대부분 충분하며 OCR이 필요할 때 조정하세요.",
|
||||||
|
},
|
||||||
|
chunking: {
|
||||||
|
title: "청킹(선택)",
|
||||||
|
desc: "문서를 검색 단위로 나누는 방식입니다. RAG에 맞춘 기본값을 그대로 쓰면 됩니다.",
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
title: "스토리지(선택)",
|
||||||
|
desc: "원본 파일 저장 위치(로컬 또는 객체 스토리지)입니다. 테넌트 기본값을 따르면 됩니다.",
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: "멀티모달/이미지(선택)",
|
||||||
|
desc: "차트·스캔·이미지가 많은 문서를 시각 이해가 필요할 때 활성화하세요.",
|
||||||
|
},
|
||||||
|
multimodalToggle: {
|
||||||
|
title: "멀티모달 파싱 켜기",
|
||||||
|
desc: "켜면 이미지가 포함된 업로드를 VLM으로 처리해 검색 품질을 높입니다.",
|
||||||
|
},
|
||||||
|
multimodalVllm: {
|
||||||
|
title: "VLM 모델 선택",
|
||||||
|
desc: "멀티모달에는 VLM이 필요합니다. 목록이 비어 있으면 시스템 설정에서 추가하세요.",
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: "FAQ 색인",
|
||||||
|
desc: "Q&A 쌍의 색인 방식을 선택합니다. 생성 후 이 페이지에서 FAQ 항목을 추가할 수 있습니다.",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: "지식 베이스 생성",
|
||||||
|
desc: "유형·이름·모델을 확인한 뒤 강조된 「생성」을 클릭하세요. 이후 첫 문서 업로드를 안내합니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: "에이전트 만들기",
|
||||||
|
desc: "에이전트는 모델·지식 베이스·도구·프롬프트를 묶은 재사용 가능한 어시스턴트입니다. 강조된 「에이전트 생성」을 클릭하세요.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentCreate: {
|
||||||
|
steps: {
|
||||||
|
mode: {
|
||||||
|
title: "실행 모드 선택",
|
||||||
|
desc: "「빠른 답변」은 단순 Q&A용, 「스마트 추론」은 도구와 다단계 사고로 복잡한 작업에 적합합니다.",
|
||||||
|
},
|
||||||
|
agentType: {
|
||||||
|
title: "에이전트 유형 선택",
|
||||||
|
desc: "프리셋은 시스템 프롬프트, 권장 도구, 지식 범위(Wiki 빌드, 데이터 분석 등)를 자동으로 채웁니다. 시나리오에 맞게 바꾸면 이름·설명도 함께 갱신됩니다.",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: "이름과 설명",
|
||||||
|
desc: "알아보기 쉬운 이름을 입력하세요. 스마트 추론 모드는 기본값이 채워질 수 있습니다.",
|
||||||
|
},
|
||||||
|
navModel: {
|
||||||
|
title: "대화 모델 연결",
|
||||||
|
desc: "모든 에이전트에 KnowledgeQA 모델이 추론 엔진으로 필요합니다.",
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
title: "모델 선택",
|
||||||
|
desc: "구성된 대화 모델 중 선택하거나, 먼저 시스템 설정에서 추가하세요.",
|
||||||
|
},
|
||||||
|
navKnowledge: {
|
||||||
|
title: "지식 베이스 연결",
|
||||||
|
desc: "에이전트가 검색할 지식 범위를 설정합니다. 기본값은 전체 지식 베이스입니다.",
|
||||||
|
},
|
||||||
|
knowledge: {
|
||||||
|
title: "지식 범위",
|
||||||
|
desc: "「전체」는 범용, 「선택」은 특정 도메인, 「없음」은 모델만 또는 웹 검색에 의존합니다.",
|
||||||
|
},
|
||||||
|
navWebsearch: {
|
||||||
|
title: "웹 검색(선택)",
|
||||||
|
desc: "최신 정보를 위해 외부 검색을 호출할 수 있게 합니다.",
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: "이미지 업로드(선택)",
|
||||||
|
desc: "채팅에서 이미지 전송을 허용합니다. 시스템 설정에 VLM이 필요합니다.",
|
||||||
|
},
|
||||||
|
multimodal: {
|
||||||
|
title: "이미지 이해 켜기",
|
||||||
|
desc: "스위치를 켜고 아래에서 VLM을 선택하세요.",
|
||||||
|
},
|
||||||
|
navTools: {
|
||||||
|
title: "도구 및 MCP(선택)",
|
||||||
|
desc: "스마트 추론 모드에서 내장 도구와 MCP로 검색·코드 등을 사용할 수 있습니다.",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: "에이전트 저장",
|
||||||
|
desc: "강조된 확인 버튼을 눌러 완료하세요. 이후 채팅에서 이 에이전트를 선택할 수 있습니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbDetail: {
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: "지식 베이스가 비어 있습니다",
|
||||||
|
desc: "첫 자료를 추가해야 검색과 대화에 사용할 수 있습니다. 지원 형식은 드래그 앤 드롭으로도 업로드할 수 있습니다.",
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: "문서 추가",
|
||||||
|
desc: "여기에서 파일·폴더 업로드, URL 가져오기, 온라인 편집 콘텐츠 생성을 할 수 있습니다.",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "분석 후 사용 가능",
|
||||||
|
desc: "문서가 색인되면 대화에서 @로 이 지식 베이스를 지정해 출처가 포함된 답변을 받을 수 있습니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
steps: {
|
||||||
|
kb: {
|
||||||
|
title: "지식 범위 선택",
|
||||||
|
desc: "@를 눌러 지식 베이스나 파일을 선택하세요. 선택한 범위만 사용해 답변합니다. 선택하지 않으면 현재 에이전트 설정이 적용됩니다.",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
title: "질문 입력",
|
||||||
|
desc: "알고 싶은 내용을 입력하거나, 위의 추천 질문을 눌러 빠르게 시작하세요.",
|
||||||
|
},
|
||||||
|
send: {
|
||||||
|
title: "보내기로 대화 시작",
|
||||||
|
desc: "전송하면 새 세션이 만들어지고, AI가 지식 베이스를 바탕으로 인용과 함께 답변합니다.",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "이제 탐색해 보세요",
|
||||||
|
desc: "업로드한 문서와 관련된 질문을 해 보고, 인용이 포함된 답변을 확인해 보세요.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
batchManage: {
|
batchManage: {
|
||||||
title: "대화 관리",
|
title: "대화 관리",
|
||||||
selectAll: "전체 선택",
|
selectAll: "전체 선택",
|
||||||
|
|||||||
@@ -65,6 +65,210 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
contextualGuide: {
|
||||||
|
stepOf: '{current} / {total}',
|
||||||
|
skip: 'Пропустить',
|
||||||
|
prev: 'Назад',
|
||||||
|
next: 'Далее',
|
||||||
|
done: 'Понятно',
|
||||||
|
interactHint: 'Нажмите на выделенную область, чтобы продолжить',
|
||||||
|
kbList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: 'Создайте первую базу знаний',
|
||||||
|
desc: 'В базах знаний хранятся документы и FAQ. Нажмите выделенную кнопку «Новая база знаний» ниже — мы проведём вас по форме.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tenantModels: {
|
||||||
|
needModelsFirst: 'Сначала добавьте модель чата и Embedding, затем создавайте базу знаний.',
|
||||||
|
needChatModelFirst: 'Перед созданием агента добавьте модель чата (KnowledgeQA).',
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: 'Сначала настройте модели',
|
||||||
|
desc: 'Для документной базы нужна модель чата (сводки и ответы) и модель Embedding (векторный поиск). Добавьте их в системных настройках.',
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: 'Добавить модели',
|
||||||
|
desc: 'Нажмите «Добавить модель» и настройте типы KnowledgeQA (чат) и Embedding. В Lite можно загрузить локальные модели через Ollama.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Затем продолжите',
|
||||||
|
desc: 'После сохранения моделей закройте настройки и нажмите «Новая база знаний». Мастер проведёт через тип, индексацию и привязку моделей.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stepsAgent: {
|
||||||
|
intro: {
|
||||||
|
title: 'Сначала настройте модель чата',
|
||||||
|
desc: 'Для создания агента нужна хотя бы одна модель KnowledgeQA. Добавьте её в системных настройках (Embedding нужен только для баз знаний).',
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: 'Добавить модель чата',
|
||||||
|
desc: 'Нажмите «Добавить модель» и настройте тип KnowledgeQA.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Затем создайте агента',
|
||||||
|
desc: 'После сохранения закройте настройки и нажмите «Создать агента». Мастер охватит режим, базы знаний и мультимодальные опции.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbCreate: {
|
||||||
|
steps: {
|
||||||
|
type: {
|
||||||
|
title: 'Выберите тип',
|
||||||
|
desc: 'Для PDF, Word и похожих файлов — база документов. Для пар вопрос–ответ — база FAQ. Тип после создания изменить нельзя.',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: 'Введите название',
|
||||||
|
desc: 'Например «Руководство продукта» или «FAQ поддержки». Описание необязательно.',
|
||||||
|
},
|
||||||
|
indexing: {
|
||||||
|
title: 'Возможности индексации',
|
||||||
|
desc: 'По умолчанию включены векторный и ключевой поиск. Можно включить Wiki или граф знаний. Оставьте хотя бы один режим поиска.',
|
||||||
|
},
|
||||||
|
navModels: {
|
||||||
|
title: 'Модели (обязательно)',
|
||||||
|
desc: 'Каждой базе нужна модель чата; для поиска также нужен Embedding. Откройте «Конфигурация моделей» слева.',
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
title: 'Модель чата / сводки',
|
||||||
|
desc: 'Для сводок и ответов. Если список пуст, через выпадающий список откройте настройки и добавьте модель.',
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
title: 'Модель Embedding',
|
||||||
|
desc: 'Преобразует текст в векторы для семантического поиска. Работает с векторным/ключевым индексом выше.',
|
||||||
|
},
|
||||||
|
parser: {
|
||||||
|
title: 'Парсер (необязательно)',
|
||||||
|
desc: 'Как разбираются PDF и Office. По умолчанию подходит в большинстве случаев; меняйте при необходимости OCR.',
|
||||||
|
},
|
||||||
|
chunking: {
|
||||||
|
title: 'Разбиение (необязательно)',
|
||||||
|
desc: 'Как документ делится на фрагменты для поиска. Размеры по умолчанию подобраны для RAG.',
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
title: 'Хранилище (необязательно)',
|
||||||
|
desc: 'Где хранятся исходные файлы (локально или объектное хранилище). Обычно достаточно значения арендатора.',
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: 'Мультимодальность / изображения (необязательно)',
|
||||||
|
desc: 'Включите, если в документах много диаграмм, сканов или изображений, требующих визуального понимания.',
|
||||||
|
},
|
||||||
|
multimodalToggle: {
|
||||||
|
title: 'Включить мультимодальный разбор',
|
||||||
|
desc: 'При включении загрузки с изображениями обрабатываются VLM для лучшего поиска.',
|
||||||
|
},
|
||||||
|
multimodalVllm: {
|
||||||
|
title: 'Выберите модель VLM',
|
||||||
|
desc: 'Для мультимодальности нужна VLM. Если список пуст, добавьте модель в настройках.',
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: 'Индексация FAQ',
|
||||||
|
desc: 'Выберите режим индексации пар вопрос–ответ. Записи FAQ можно добавить после создания.',
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: 'Создать базу знаний',
|
||||||
|
desc: 'Проверьте тип, название и модели, затем нажмите выделенную кнопку «Создать». Далее подскажем загрузить первый документ.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: 'Создайте агента',
|
||||||
|
desc: 'Агенты объединяют модели, базы знаний, инструменты и промпты в переиспользуемых помощников. Нажмите выделенную кнопку «Создать агента».',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentCreate: {
|
||||||
|
steps: {
|
||||||
|
mode: {
|
||||||
|
title: 'Выберите режим работы',
|
||||||
|
desc: '«Быстрый ответ» для простого Q&A; «Умное рассуждение» — инструменты и многошаговое мышление для сложных задач.',
|
||||||
|
},
|
||||||
|
agentType: {
|
||||||
|
title: 'Выберите тип агента',
|
||||||
|
desc: 'Пресеты подставляют системный промпт, рекомендуемые инструменты и область знаний (например, построение Wiki, анализ данных). При смене типа обновляются имя и описание.',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: 'Название и описание',
|
||||||
|
desc: 'Укажите узнаваемое имя. В режиме умного рассуждения может подставиться значение по умолчанию.',
|
||||||
|
},
|
||||||
|
navModel: {
|
||||||
|
title: 'Привязать модель чата',
|
||||||
|
desc: 'Каждому агенту нужна модель KnowledgeQA как движок рассуждений.',
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
title: 'Выбор модели',
|
||||||
|
desc: 'Выберите из настроенных моделей чата или сначала добавьте в системных настройках.',
|
||||||
|
},
|
||||||
|
navKnowledge: {
|
||||||
|
title: 'Связать базы знаний',
|
||||||
|
desc: 'Определите, к каким знаниям агент может обращаться. По умолчанию — все базы.',
|
||||||
|
},
|
||||||
|
knowledge: {
|
||||||
|
title: 'Область знаний',
|
||||||
|
desc: '«Все» — универсальный помощник; «Выбранные» — домен; «Нет» — только модель или веб-поиск.',
|
||||||
|
},
|
||||||
|
navWebsearch: {
|
||||||
|
title: 'Веб-поиск (необязательно)',
|
||||||
|
desc: 'Разрешить агенту вызывать внешний поиск для актуальной информации.',
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: 'Загрузка изображений (необязательно)',
|
||||||
|
desc: 'Позволяет отправлять изображения в чате; нужна VLM в настройках.',
|
||||||
|
},
|
||||||
|
multimodal: {
|
||||||
|
title: 'Включить понимание изображений',
|
||||||
|
desc: 'Включите переключатель и выберите VLM ниже.',
|
||||||
|
},
|
||||||
|
navTools: {
|
||||||
|
title: 'Инструменты и MCP (необязательно)',
|
||||||
|
desc: 'В режиме умного рассуждения включите встроенные инструменты и MCP для поиска, кода и др.',
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: 'Сохранить агента',
|
||||||
|
desc: 'Нажмите выделенную кнопку подтверждения. Затем выберите этого агента в чате.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbDetail: {
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: 'База знаний пуста',
|
||||||
|
desc: 'Добавьте первый материал, чтобы искать по нему и общаться в чате. Поддерживаемые файлы можно перетащить мышью.',
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: 'Добавить документы',
|
||||||
|
desc: 'Здесь можно загрузить файлы или папки, импортировать URL или создать материал онлайн.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Готово после индексации',
|
||||||
|
desc: 'После обработки документов укажите эту базу через @ в чате и получайте ответы со ссылками на источники.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
steps: {
|
||||||
|
kb: {
|
||||||
|
title: 'Выберите область знаний',
|
||||||
|
desc: 'Нажмите @, чтобы выбрать одну или несколько баз знаний или файлов. Иначе используются настройки текущего агента.',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
title: 'Введите вопрос',
|
||||||
|
desc: 'Опишите, что хотите узнать, или нажмите на рекомендуемый вопрос выше.',
|
||||||
|
},
|
||||||
|
send: {
|
||||||
|
title: 'Отправить, чтобы начать чат',
|
||||||
|
desc: 'После отправки создаётся новая сессия. ИИ отвечает на основе базы знаний и показывает цитаты.',
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: 'Можно исследовать',
|
||||||
|
desc: 'Задайте вопрос по загруженным документам и посмотрите ответы со ссылками на источники.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
batchManage: {
|
batchManage: {
|
||||||
title: 'Управление диалогами',
|
title: 'Управление диалогами',
|
||||||
selectAll: 'Выбрать все',
|
selectAll: 'Выбрать все',
|
||||||
|
|||||||
@@ -65,6 +65,210 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
contextualGuide: {
|
||||||
|
stepOf: "{current} / {total}",
|
||||||
|
skip: "跳过",
|
||||||
|
prev: "上一步",
|
||||||
|
next: "下一步",
|
||||||
|
done: "知道了",
|
||||||
|
interactHint: "请直接点击高亮区域继续",
|
||||||
|
kbList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: "创建第一个知识库",
|
||||||
|
desc: "知识库用来存放文档与 FAQ。点击下方高亮的「新建知识库」按钮,我们会带你完成表单填写。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tenantModels: {
|
||||||
|
needModelsFirst: "请先添加对话模型与 Embedding 模型,再创建知识库。",
|
||||||
|
needChatModelFirst: "请先添加对话模型(KnowledgeQA),再创建智能体。",
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: "需要先配置模型",
|
||||||
|
desc: "创建文档知识库至少需要:一个对话模型(用于摘要与问答)和一个 Embedding 模型(用于向量检索)。请先在系统设置中添加。",
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: "添加模型",
|
||||||
|
desc: "点击「添加模型」,分别配置 KnowledgeQA(对话)与 Embedding 类型。Lite 用户可使用 Ollama 拉取本地模型。",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "添加完成后继续",
|
||||||
|
desc: "模型保存后关闭设置页,即可点击「新建知识库」;创建向导会引导你完成类型、索引与模型绑定。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stepsAgent: {
|
||||||
|
intro: {
|
||||||
|
title: "需要先配置对话模型",
|
||||||
|
desc: "创建智能体至少需要一种 KnowledgeQA(对话)模型。请先在系统设置中添加;Embedding 模型仅创建知识库时需要。",
|
||||||
|
},
|
||||||
|
addModel: {
|
||||||
|
title: "添加对话模型",
|
||||||
|
desc: "点击「添加模型」,选择 KnowledgeQA 类型并填写接入信息。",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "然后创建智能体",
|
||||||
|
desc: "保存并关闭设置后,点击「创建智能体」,向导会带你配置模式、知识库与多模态等选项。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbCreate: {
|
||||||
|
steps: {
|
||||||
|
type: {
|
||||||
|
title: "选择知识库类型",
|
||||||
|
desc: "文档库适合上传 PDF、Word 等文件;FAQ 库适合问答对。创建后类型不可更改,请按需选择。",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: "填写名称",
|
||||||
|
desc: "取一个便于识别的名称,例如「产品手册」或「客服 FAQ」。描述可选填。",
|
||||||
|
},
|
||||||
|
indexing: {
|
||||||
|
title: "选择索引能力",
|
||||||
|
desc: "默认已开启向量与关键词检索,可按需开启 Wiki 结构化索引或知识图谱。至少保留一种检索方式。",
|
||||||
|
},
|
||||||
|
navModels: {
|
||||||
|
title: "配置模型(必填)",
|
||||||
|
desc: "知识库必须绑定对话模型;开启检索时还需 Embedding 模型。点击左侧「模型配置」进入。",
|
||||||
|
},
|
||||||
|
llm: {
|
||||||
|
title: "对话 / 摘要模型",
|
||||||
|
desc: "用于文档摘要、问答生成等。若列表为空,请通过下拉菜单前往系统设置添加模型。",
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
title: "Embedding 模型",
|
||||||
|
desc: "将文本转为向量以支持语义检索。与上方索引策略中的「向量/关键词检索」配合使用。",
|
||||||
|
},
|
||||||
|
parser: {
|
||||||
|
title: "解析引擎(可选)",
|
||||||
|
desc: "控制 PDF、Office 等文件的解析方式。默认配置适用于大多数场景,有 OCR 需求时可在此调整。",
|
||||||
|
},
|
||||||
|
chunking: {
|
||||||
|
title: "分块策略(可选)",
|
||||||
|
desc: "决定文档如何切分为检索片段。默认分块大小已针对 RAG 优化,一般无需修改。",
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
title: "存储引擎(可选)",
|
||||||
|
desc: "原始文件存放位置(本地或对象存储)。默认跟随租户设置即可。",
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: "多模态 / 图片理解(可选)",
|
||||||
|
desc: "若文档含大量图表、扫描件或需理解图片内容,可在此开启多模态并选择 VLM 模型。",
|
||||||
|
},
|
||||||
|
multimodalToggle: {
|
||||||
|
title: "开启多模态解析",
|
||||||
|
desc: "启用后,上传的图片类文档将使用视觉语言模型提取内容,便于检索与问答。",
|
||||||
|
},
|
||||||
|
multimodalVllm: {
|
||||||
|
title: "选择 VLM 模型",
|
||||||
|
desc: "开启多模态后需指定 VLM(视觉语言模型)。若列表为空,请先在系统设置中添加 VLLM 类型模型。",
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: "FAQ 索引方式",
|
||||||
|
desc: "配置问答对的索引模式。创建后可在本页继续添加 FAQ 条目。",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: "创建知识库",
|
||||||
|
desc: "确认类型、名称与模型已选好后,点击高亮的「创建」按钮。创建成功后将引导你上传第一份文档。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentList: {
|
||||||
|
steps: {
|
||||||
|
create: {
|
||||||
|
title: "创建你的智能体",
|
||||||
|
desc: "智能体把模型、知识库、工具与提示词组合成可复用的对话助手。点击下方高亮的「创建智能体」开始配置。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentCreate: {
|
||||||
|
steps: {
|
||||||
|
mode: {
|
||||||
|
title: "选择运行模式",
|
||||||
|
desc: "「普通模式」适合固定流程的快速问答;「智能推理」可调用工具、多步思考,适合复杂任务。",
|
||||||
|
},
|
||||||
|
agentType: {
|
||||||
|
title: "选择智能体类型",
|
||||||
|
desc: "预设类型会自动填充系统提示词、推荐工具与知识库范围(如 Wiki 构建、数据分析等)。可按场景切换,名称与描述会随之更新。",
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
title: "命名与描述",
|
||||||
|
desc: "取一个易识别的名称。智能推理模式下系统可能已预填默认名称,可按需修改。",
|
||||||
|
},
|
||||||
|
navModel: {
|
||||||
|
title: "绑定对话模型",
|
||||||
|
desc: "每个智能体必须指定一个 KnowledgeQA 模型作为推理引擎。",
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
title: "选择模型",
|
||||||
|
desc: "从下拉列表选择已配置的对话模型;没有合适模型时请先到系统设置添加。",
|
||||||
|
},
|
||||||
|
navKnowledge: {
|
||||||
|
title: "关联知识库",
|
||||||
|
desc: "决定智能体可检索哪些知识。默认「全部知识库」,也可改为指定库或暂不关联。",
|
||||||
|
},
|
||||||
|
knowledge: {
|
||||||
|
title: "知识库范围",
|
||||||
|
desc: "「全部」适合通用助手;「指定」可限定专业领域;「不关联」则仅依赖模型自身能力或联网搜索。",
|
||||||
|
},
|
||||||
|
navWebsearch: {
|
||||||
|
title: "联网搜索(可选)",
|
||||||
|
desc: "配置是否允许智能体调用外部搜索引擎补充实时信息。",
|
||||||
|
},
|
||||||
|
navMultimodal: {
|
||||||
|
title: "图片上传(可选)",
|
||||||
|
desc: "开启后,对话中可上传图片并由 VLM 理解;需先在系统设置中配置 VLLM 模型。",
|
||||||
|
},
|
||||||
|
multimodal: {
|
||||||
|
title: "启用图片理解",
|
||||||
|
desc: "打开开关后,记得在下方选择 VLM 模型(开启时必填)。",
|
||||||
|
},
|
||||||
|
navTools: {
|
||||||
|
title: "工具与 MCP(可选)",
|
||||||
|
desc: "智能推理模式下可勾选内置工具、MCP 服务等,扩展搜索、计算等能力。",
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: "保存智能体",
|
||||||
|
desc: "确认配置后点击高亮的「确定」完成创建,即可在对话中选择该智能体。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kbDetail: {
|
||||||
|
steps: {
|
||||||
|
intro: {
|
||||||
|
title: "知识库还是空的",
|
||||||
|
desc: "添加第一份资料后,才能基于它进行检索与对话。支持拖拽上传多种文档格式。",
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: "添加文档",
|
||||||
|
desc: "点击此处上传文件、文件夹,或导入网页与在线编辑内容。",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "解析完成后即可使用",
|
||||||
|
desc: "文档解析入库后,可在对话中 @ 本知识库提问,回答会附带引用来源。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
steps: {
|
||||||
|
kb: {
|
||||||
|
title: "选择知识范围",
|
||||||
|
desc: "点击 @ 可指定一个或多个知识库/文件,仅基于选中内容回答;不选则按当前智能体配置检索。",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
title: "输入你的问题",
|
||||||
|
desc: "直接描述你想了解的内容;也可点击上方推荐问题快速开始。",
|
||||||
|
},
|
||||||
|
send: {
|
||||||
|
title: "发送开始对话",
|
||||||
|
desc: "发送后将创建新会话,AI 会结合知识库内容作答,并标注引用片段。",
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
title: "开始探索吧",
|
||||||
|
desc: "试试提一个与已上传文档相关的问题,体验带引用的精准回答。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
batchManage: {
|
batchManage: {
|
||||||
title: "管理对话记录",
|
title: "管理对话记录",
|
||||||
selectAll: "全选",
|
selectAll: "全选",
|
||||||
|
|||||||
13
frontend/src/types/spotlightGuide.ts
Normal file
13
frontend/src/types/spotlightGuide.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type GuidePlacement = 'right' | 'left' | 'bottom' | 'top'
|
||||||
|
|
||||||
|
export interface SpotlightGuideStep {
|
||||||
|
key: string
|
||||||
|
/** 高亮目标的 CSS 选择器;缺省表示居中卡片 */
|
||||||
|
target?: string
|
||||||
|
placement?: GuidePlacement
|
||||||
|
before?: () => void | Promise<void>
|
||||||
|
/** 目标不存在时是否跳过该步骤 */
|
||||||
|
optional?: boolean
|
||||||
|
/** 为 true 时引导用户直接点击高亮区域,不展示「下一步」 */
|
||||||
|
interact?: boolean
|
||||||
|
}
|
||||||
36
frontend/src/utils/tenantModelReadiness.ts
Normal file
36
frontend/src/utils/tenantModelReadiness.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { listModels, type ModelConfig } from '@/api/model'
|
||||||
|
|
||||||
|
export interface TenantModelReadiness {
|
||||||
|
chatCount: number
|
||||||
|
embeddingCount: number
|
||||||
|
hasChat: boolean
|
||||||
|
hasEmbedding: boolean
|
||||||
|
/** 文档库默认开启向量/关键词检索时所需的模型是否齐备 */
|
||||||
|
isReadyForDocumentKb: boolean
|
||||||
|
/** 创建智能体至少需要对话模型 */
|
||||||
|
isReadyForAgent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateTenantModelReadiness(models: ModelConfig[]): TenantModelReadiness {
|
||||||
|
const chatCount = models.filter((m) => m.type === 'KnowledgeQA').length
|
||||||
|
const embeddingCount = models.filter((m) => m.type === 'Embedding').length
|
||||||
|
const hasChat = chatCount > 0
|
||||||
|
const hasEmbedding = embeddingCount > 0
|
||||||
|
return {
|
||||||
|
chatCount,
|
||||||
|
embeddingCount,
|
||||||
|
hasChat,
|
||||||
|
hasEmbedding,
|
||||||
|
isReadyForDocumentKb: hasChat && hasEmbedding,
|
||||||
|
isReadyForAgent: hasChat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantModelReadiness(): Promise<TenantModelReadiness> {
|
||||||
|
try {
|
||||||
|
const models = await listModels()
|
||||||
|
return evaluateTenantModelReadiness(models || [])
|
||||||
|
} catch {
|
||||||
|
return evaluateTenantModelReadiness([])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,10 @@
|
|||||||
<h2 class="sidebar-title">{{ mode === 'create' ? $t('agent.editor.createTitle') :
|
<h2 class="sidebar-title">{{ mode === 'create' ? $t('agent.editor.createTitle') :
|
||||||
$t('agent.editor.editTitle') }}</h2>
|
$t('agent.editor.editTitle') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-nav">
|
<div class="settings-nav" data-guide="agent-editor-sidebar">
|
||||||
<div v-for="(item, index) in navItems" :key="index"
|
<div v-for="(item, index) in navItems" :key="index"
|
||||||
:class="['nav-item', { 'active': currentSection === item.key }]" @click="currentSection = item.key">
|
:class="['nav-item', { 'active': currentSection === item.key }]"
|
||||||
|
:data-guide="`agent-editor-nav-${item.key}`" @click="currentSection = item.key">
|
||||||
<t-icon :name="item.icon" class="nav-icon" />
|
<t-icon :name="item.icon" class="nav-icon" />
|
||||||
<span class="nav-label">{{ item.label }}</span>
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
$t('agent.editor.normalDesc') }}</p>
|
$t('agent.editor.normalDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<t-radio-group v-model="agentMode" :disabled="isBuiltinAgent">
|
<t-radio-group v-model="agentMode" :disabled="isBuiltinAgent" data-guide="agent-create-mode">
|
||||||
<t-radio-button value="quick-answer">
|
<t-radio-button value="quick-answer">
|
||||||
{{ $t('agent.type.normal') }}
|
{{ $t('agent.type.normal') }}
|
||||||
</t-radio-button>
|
</t-radio-button>
|
||||||
@@ -84,7 +85,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 智能体类型(仅智能推理模式下显示) -->
|
<!-- 智能体类型(仅智能推理模式下显示) -->
|
||||||
<div v-if="isAgentMode && agentTypePresets.length > 0" class="setting-row setting-row--emphasize">
|
<div v-if="isAgentMode && agentTypePresets.length > 0" class="setting-row setting-row--emphasize"
|
||||||
|
data-guide="agent-create-agent-type">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('agentEditor.agentType.label') }}</label>
|
<label>{{ $t('agentEditor.agentType.label') }}</label>
|
||||||
<p class="desc">{{ $t('agentEditor.agentType.desc') }}</p>
|
<p class="desc">{{ $t('agentEditor.agentType.desc') }}</p>
|
||||||
@@ -106,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 名称 -->
|
<!-- 名称 -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" data-guide="agent-create-name">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('agent.editor.name') }} <span v-if="!isBuiltinAgent"
|
<label>{{ $t('agent.editor.name') }} <span v-if="!isBuiltinAgent"
|
||||||
class="required">*</span></label>
|
class="required">*</span></label>
|
||||||
@@ -329,7 +331,7 @@
|
|||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<!-- 模型选择 -->
|
<!-- 模型选择 -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" data-guide="agent-create-model">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('agent.editor.model') }} <span class="required">*</span></label>
|
<label>{{ $t('agent.editor.model') }} <span class="required">*</span></label>
|
||||||
<p class="desc">{{ $t('agentEditor.desc.model') }}</p>
|
<p class="desc">{{ $t('agentEditor.desc.model') }}</p>
|
||||||
@@ -391,7 +393,7 @@
|
|||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<!-- 图片上传(多模态) -->
|
<!-- 图片上传(多模态) -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" data-guide="agent-create-multimodal">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('agentEditor.imageUpload.label') }}</label>
|
<label>{{ $t('agentEditor.imageUpload.label') }}</label>
|
||||||
<p class="desc">{{ $t('agentEditor.imageUpload.desc') }}</p>
|
<p class="desc">{{ $t('agentEditor.imageUpload.desc') }}</p>
|
||||||
@@ -846,7 +848,7 @@
|
|||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<!-- 关联知识库 -->
|
<!-- 关联知识库 -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" data-guide="agent-create-knowledge">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('agent.editor.knowledgeBases') }}</label>
|
<label>{{ $t('agent.editor.knowledgeBases') }}</label>
|
||||||
<p class="desc">{{ $t('agentEditor.desc.kbScope') }}</p>
|
<p class="desc">{{ $t('agentEditor.desc.kbScope') }}</p>
|
||||||
@@ -1300,7 +1302,8 @@
|
|||||||
<t-button variant="outline" @click="handleClose">{{ props.readOnly ? $t('common.close') :
|
<t-button variant="outline" @click="handleClose">{{ props.readOnly ? $t('common.close') :
|
||||||
$t('common.cancel')
|
$t('common.cancel')
|
||||||
}}</t-button>
|
}}</t-button>
|
||||||
<t-button v-if="!props.readOnly" theme="primary" :loading="saving" @click="handleSave">{{
|
<t-button v-if="!props.readOnly" theme="primary" data-guide="agent-create-submit" :loading="saving"
|
||||||
|
@click="handleSave">{{
|
||||||
$t('common.confirm')
|
$t('common.confirm')
|
||||||
}}</t-button>
|
}}</t-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1310,10 +1313,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<AgentCreateContextualGuide :when="visible && mode === 'create'" :is-agent-mode="isAgentMode" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import AgentCreateContextualGuide from '@/components/AgentCreateContextualGuide.vue';
|
||||||
|
import {
|
||||||
|
AGENT_EDITOR_FOCUS_SECTION_EVENT,
|
||||||
|
markContextualGuideDone,
|
||||||
|
} from '@/config/contextualGuides';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { MessagePlugin } from 'tdesign-vue-next';
|
import { MessagePlugin } from 'tdesign-vue-next';
|
||||||
import {
|
import {
|
||||||
@@ -1400,6 +1410,22 @@ const copyAgentId = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentSection = ref(props.initialSection || 'basic');
|
const currentSection = ref(props.initialSection || 'basic');
|
||||||
|
|
||||||
|
const onAgentEditorFocusSection = (event: Event) => {
|
||||||
|
const section = (event as CustomEvent<{ section?: string }>).detail?.section
|
||||||
|
if (section) {
|
||||||
|
currentSection.value = section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(AGENT_EDITOR_FOCUS_SECTION_EVENT, onAgentEditorFocusSection)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener(AGENT_EDITOR_FOCUS_SECTION_EVENT, onAgentEditorFocusSection)
|
||||||
|
})
|
||||||
|
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const allModels = ref<ModelConfig[]>([]);
|
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 }[]>([]);
|
const kbOptions = ref<{ label: string; value: string; type?: 'document' | 'faq'; count?: number; shared?: boolean; orgName?: string; ragEnabled?: boolean; wikiEnabled?: boolean; capabilities?: KBCapabilities }[]>([]);
|
||||||
@@ -1863,6 +1889,17 @@ const defaultFormData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formData = ref(JSON.parse(JSON.stringify(defaultFormData)));
|
const formData = ref(JSON.parse(JSON.stringify(defaultFormData)));
|
||||||
|
|
||||||
|
const applyDefaultChatModelIfEmpty = () => {
|
||||||
|
if (props.mode !== 'create' || !formData.value) return
|
||||||
|
const chat =
|
||||||
|
allModels.value.find((m) => m.type === 'KnowledgeQA' && m.is_default)
|
||||||
|
|| allModels.value.find((m) => m.type === 'KnowledgeQA')
|
||||||
|
if (!formData.value.config.model_id && chat?.id) {
|
||||||
|
formData.value.config.model_id = chat.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const agentMode = computed({
|
const agentMode = computed({
|
||||||
get: () => formData.value.config.agent_mode,
|
get: () => formData.value.config.agent_mode,
|
||||||
set: (val: 'quick-answer' | 'smart-reasoning') => { formData.value.config.agent_mode = val; }
|
set: (val: 'quick-answer' | 'smart-reasoning') => { formData.value.config.agent_mode = val; }
|
||||||
@@ -2358,6 +2395,7 @@ watch(() => props.visible, async (val) => {
|
|||||||
formData.value.description = getPresetDefaultDescription(preset);
|
formData.value.description = getPresetDefaultDescription(preset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applyDefaultChatModelIfEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3673,6 +3711,7 @@ const handleSave = async () => {
|
|||||||
try {
|
try {
|
||||||
if (props.mode === 'create') {
|
if (props.mode === 'create') {
|
||||||
await createAgent(formData.value);
|
await createAgent(formData.value);
|
||||||
|
markContextualGuideDone('agentCreate')
|
||||||
MessagePlugin.success(t('agent.messages.created'));
|
MessagePlugin.success(t('agent.messages.created'));
|
||||||
} else {
|
} else {
|
||||||
await updateAgent(formData.value.id, formData.value);
|
await updateAgent(formData.value.id, formData.value);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<h2 style="--wails-draggable: drag">{{ $t('agent.title') }}</h2>
|
<h2 style="--wails-draggable: drag">{{ $t('agent.title') }}</h2>
|
||||||
<t-tooltip v-if="authStore.hasRole('contributor')" :content="$t('agent.createAgent')" placement="bottom">
|
<t-tooltip v-if="authStore.hasRole('contributor')" :content="$t('agent.createAgent')" placement="bottom">
|
||||||
<t-button variant="text" theme="default" size="small" class="header-action-btn"
|
<t-button variant="text" theme="default" size="small" class="header-action-btn"
|
||||||
style="--wails-draggable: no-drag" @click="handleCreateAgent">
|
data-guide="agent-list-create" style="--wails-draggable: no-drag" @click="handleCreateAgent">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<span class="btn-icon-wrapper">
|
<span class="btn-icon-wrapper">
|
||||||
<svg class="sparkles-icon" width="19" height="19" viewBox="0 0 20 20" fill="none"
|
<svg class="sparkles-icon" width="19" height="19" viewBox="0 0 20 20" fill="none"
|
||||||
@@ -647,7 +647,7 @@
|
|||||||
<span class="empty-txt">{{ $t('agent.empty.title') }}</span>
|
<span class="empty-txt">{{ $t('agent.empty.title') }}</span>
|
||||||
<span class="empty-desc">{{ $t('agent.empty.description') }}</span>
|
<span class="empty-desc">{{ $t('agent.empty.description') }}</span>
|
||||||
<t-button v-if="authStore.hasRole('contributor')" class="agent-create-btn empty-state-btn"
|
<t-button v-if="authStore.hasRole('contributor')" class="agent-create-btn empty-state-btn"
|
||||||
@click="handleCreateAgent">
|
data-guide="agent-list-create" @click="handleCreateAgent">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<span class="btn-icon-wrapper">
|
<span class="btn-icon-wrapper">
|
||||||
<svg class="sparkles-icon" width="18" height="18" viewBox="0 0 20 20" fill="none"
|
<svg class="sparkles-icon" width="18" height="18" viewBox="0 0 20 20" fill="none"
|
||||||
@@ -809,6 +809,9 @@
|
|||||||
:initialSection="editorInitialSection"
|
:initialSection="editorInitialSection"
|
||||||
:readOnly="editorMode === 'edit' && editingAgent != null && !canManageAgent(editingAgent as AgentWithUI)"
|
:readOnly="editorMode === 'edit' && editingAgent != null && !canManageAgent(editingAgent as AgentWithUI)"
|
||||||
@update:visible="editorVisible = $event" @success="handleEditorSuccess" />
|
@update:visible="editorVisible = $event" @success="handleEditorSuccess" />
|
||||||
|
|
||||||
|
<TenantModelsGuide :when="showAgentTenantModelsGuide" variant="agent" />
|
||||||
|
<ContextualGuide tour="agentList" :when="showAgentListContextualGuide" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -826,6 +829,11 @@ import { useSettingsStore } from '@/stores/settings'
|
|||||||
import { useMenuStore } from '@/stores/menu'
|
import { useMenuStore } from '@/stores/menu'
|
||||||
import type { SharedAgentInfo, OrganizationSharedAgentItem } from '@/api/organization'
|
import type { SharedAgentInfo, OrganizationSharedAgentItem } from '@/api/organization'
|
||||||
import AgentEditorModal from './AgentEditorModal.vue'
|
import AgentEditorModal from './AgentEditorModal.vue'
|
||||||
|
import ContextualGuide from '@/components/ContextualGuide.vue'
|
||||||
|
import TenantModelsGuide from '@/components/TenantModelsGuide.vue'
|
||||||
|
import { markContextualGuideDone } from '@/config/contextualGuides'
|
||||||
|
import { useTenantModelReadiness } from '@/composables/useTenantModelReadiness'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import AgentAvatar from '@/components/AgentAvatar.vue'
|
import AgentAvatar from '@/components/AgentAvatar.vue'
|
||||||
import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue'
|
import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue'
|
||||||
import ResourceOriginBadge from '@/components/ResourceOriginBadge.vue'
|
import ResourceOriginBadge from '@/components/ResourceOriginBadge.vue'
|
||||||
@@ -837,7 +845,9 @@ const { t } = useI18n()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const uiStore = useUIStore()
|
||||||
const orgStore = useOrganizationStore()
|
const orgStore = useOrganizationStore()
|
||||||
|
const { loaded: modelsReadyLoaded, isReadyForAgent } = useTenantModelReadiness()
|
||||||
|
|
||||||
interface AgentWithUI extends CustomAgent {
|
interface AgentWithUI extends CustomAgent {
|
||||||
showMore?: boolean
|
showMore?: boolean
|
||||||
@@ -1074,6 +1084,22 @@ const editorInitialSection = ref<string>('basic')
|
|||||||
/** 当前打开三点菜单的卡片 agent.id(用于受控弹出层,避免 computed 项无持久引用导致菜单不响应) */
|
/** 当前打开三点菜单的卡片 agent.id(用于受控弹出层,避免 computed 项无持久引用导致菜单不响应) */
|
||||||
const openMoreAgentId = ref<string | null>(null)
|
const openMoreAgentId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const showAgentListEmpty = computed(() => {
|
||||||
|
if (loading.value) return false
|
||||||
|
if (!authStore.hasRole('contributor')) return false
|
||||||
|
if (spaceSelection.value === 'all' && filteredAgents.value.length === 0) return true
|
||||||
|
if (spaceSelection.value === 'mine' && agents.value.length === 0) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showAgentTenantModelsGuide = computed(
|
||||||
|
() => modelsReadyLoaded.value && showAgentListEmpty.value && !isReadyForAgent.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showAgentListContextualGuide = computed(
|
||||||
|
() => showAgentListEmpty.value && isReadyForAgent.value && !editorVisible.value,
|
||||||
|
)
|
||||||
|
|
||||||
const fetchList = () => {
|
const fetchList = () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
@@ -1494,6 +1520,12 @@ const openCreateModal = () => {
|
|||||||
|
|
||||||
// 创建智能体
|
// 创建智能体
|
||||||
const handleCreateAgent = () => {
|
const handleCreateAgent = () => {
|
||||||
|
if (!isReadyForAgent.value) {
|
||||||
|
MessagePlugin.warning(t('contextualGuide.tenantModels.needChatModelFirst'))
|
||||||
|
uiStore.openSettings('models')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markContextualGuideDone('agentList')
|
||||||
openCreateModal()
|
openCreateModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContextualGuide tour="chat" :when="showChatContextualGuide" />
|
||||||
|
|
||||||
<!-- 知识库编辑器(创建/编辑统一组件) -->
|
<!-- 知识库编辑器(创建/编辑统一组件) -->
|
||||||
<KnowledgeBaseEditorModal
|
<KnowledgeBaseEditorModal
|
||||||
:visible="uiStore.showKBEditorModal"
|
:visible="uiStore.showKBEditorModal"
|
||||||
@@ -54,7 +56,8 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, nextTick } from 'vue';
|
import { ref, watch, onMounted, nextTick, computed } from 'vue';
|
||||||
|
import ContextualGuide from '@/components/ContextualGuide.vue';
|
||||||
import InputField from '@/components/Input-field.vue';
|
import InputField from '@/components/Input-field.vue';
|
||||||
import { createSessions } from "@/api/chat/index";
|
import { createSessions } from "@/api/chat/index";
|
||||||
import { getSuggestedQuestions } from "@/api/agent/index";
|
import { getSuggestedQuestions } from "@/api/agent/index";
|
||||||
@@ -76,6 +79,10 @@ const uiStore = useUIStore();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { navigateToKnowledgeBaseList } = useKnowledgeBaseCreationNavigation();
|
const { navigateToKnowledgeBaseList } = useKnowledgeBaseCreationNavigation();
|
||||||
|
|
||||||
|
const showChatContextualGuide = computed(() => {
|
||||||
|
return route.name === 'globalCreatChat' || route.name === 'kbCreatChat';
|
||||||
|
});
|
||||||
|
|
||||||
// ===== 推荐问题 =====
|
// ===== 推荐问题 =====
|
||||||
const suggestedQuestions = ref<SuggestedQuestion[]>([]);
|
const suggestedQuestions = ref<SuggestedQuestion[]>([]);
|
||||||
const sqLoading = ref(true);
|
const sqLoading = ref(true);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import KnowledgeProcessingTimeline from "@/components/knowledge-processing-timel
|
|||||||
import useKnowledgeBase from '@/hooks/useKnowledgeBase';
|
import useKnowledgeBase from '@/hooks/useKnowledgeBase';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import EmptyKnowledge from '@/components/empty-knowledge.vue';
|
import EmptyKnowledge from '@/components/empty-knowledge.vue';
|
||||||
|
import ContextualGuide from '@/components/ContextualGuide.vue';
|
||||||
import KBInfoPopover from '@/components/KBInfoPopover.vue';
|
import KBInfoPopover from '@/components/KBInfoPopover.vue';
|
||||||
import KBSwitcherDropdown from '@/components/KBSwitcherDropdown.vue';
|
import KBSwitcherDropdown from '@/components/KBSwitcherDropdown.vue';
|
||||||
import { getSessionsList, createSessions, generateSessionsTitle } from "@/api/chat/index";
|
import { getSessionsList, createSessions, generateSessionsTitle } from "@/api/chat/index";
|
||||||
@@ -288,6 +289,15 @@ const effectiveKBPermission = computed(() => orgStore.getKBPermission(kbId.value
|
|||||||
|
|
||||||
const knowledgeList = ref<Array<{ id: string; name: string; type?: string }>>([]);
|
const knowledgeList = ref<Array<{ id: string; name: string; type?: string }>>([]);
|
||||||
let { cardList, total, moreIndex, details, getKnowled, delKnowledge, openMore, onVisibleChange: _onVisibleChange, getCardDetails, getfDetails } = useKnowledgeBase(kbId.value)
|
let { cardList, total, moreIndex, details, getKnowled, delKnowledge, openMore, onVisibleChange: _onVisibleChange, getCardDetails, getfDetails } = useKnowledgeBase(kbId.value)
|
||||||
|
|
||||||
|
const showKbDetailContextualGuide = computed(() => {
|
||||||
|
return Boolean(kbId.value)
|
||||||
|
&& !isFAQ.value
|
||||||
|
&& canEdit.value
|
||||||
|
&& !docListLoading.value
|
||||||
|
&& cardList.value.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
const onVisibleChange = (visible: boolean) => {
|
const onVisibleChange = (visible: boolean) => {
|
||||||
_onVisibleChange(visible);
|
_onVisibleChange(visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
@@ -2331,7 +2341,8 @@ async function createNewSession(value: string): Promise<void> {
|
|||||||
<t-tooltip :content="$t('knowledgeBase.addDocument')" placement="top">
|
<t-tooltip :content="$t('knowledgeBase.addDocument')" placement="top">
|
||||||
<t-dropdown :options="documentActionOptions" trigger="click" placement="bottom-right"
|
<t-dropdown :options="documentActionOptions" trigger="click" placement="bottom-right"
|
||||||
@click="handleDocumentActionSelect">
|
@click="handleDocumentActionSelect">
|
||||||
<t-button variant="text" theme="default" class="content-bar-icon-btn" size="small">
|
<t-button variant="text" theme="default" class="content-bar-icon-btn" size="small"
|
||||||
|
data-guide="kb-detail-add-doc">
|
||||||
<template #icon><t-icon name="file-add" size="16px" /></template>
|
<template #icon><t-icon name="file-add" size="16px" /></template>
|
||||||
</t-button>
|
</t-button>
|
||||||
</t-dropdown>
|
</t-dropdown>
|
||||||
@@ -2741,6 +2752,8 @@ async function createNewSession(value: string): Promise<void> {
|
|||||||
<KnowledgeBaseEditorModal :visible="uiStore.showKBEditorModal" :mode="uiStore.kbEditorMode"
|
<KnowledgeBaseEditorModal :visible="uiStore.showKBEditorModal" :mode="uiStore.kbEditorMode"
|
||||||
:kb-id="uiStore.currentKBId || undefined" :initial-type="uiStore.kbEditorType"
|
:kb-id="uiStore.currentKBId || undefined" :initial-type="uiStore.kbEditorType"
|
||||||
@update:visible="(val) => val ? null : uiStore.closeKBEditor()" @success="handleKBEditorSuccess" />
|
@update:visible="(val) => val ? null : uiStore.closeKBEditor()" @success="handleKBEditorSuccess" />
|
||||||
|
|
||||||
|
<ContextualGuide tour="kbDetail" :when="showKbDetailContextualGuide" />
|
||||||
</template>
|
</template>
|
||||||
<style>
|
<style>
|
||||||
/* 下拉菜单容器样式已统一至 @/assets/dropdown-menu.less */
|
/* 下拉菜单容器样式已统一至 @/assets/dropdown-menu.less */
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2 class="sidebar-title">{{ mode === 'create' ? $t('knowledgeEditor.titleCreate') : $t('knowledgeEditor.titleEdit') }}</h2>
|
<h2 class="sidebar-title">{{ mode === 'create' ? $t('knowledgeEditor.titleCreate') : $t('knowledgeEditor.titleEdit') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-nav">
|
<div class="settings-nav" data-guide="kb-editor-sidebar">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in navItems"
|
v-for="(item, index) in navItems"
|
||||||
:key="index"
|
:key="index"
|
||||||
:class="['nav-item', { 'active': currentSection === item.key }]"
|
:class="['nav-item', { 'active': currentSection === item.key }]"
|
||||||
|
:data-guide="`kb-editor-nav-${item.key}`"
|
||||||
@click="currentSection = item.key"
|
@click="currentSection = item.key"
|
||||||
>
|
>
|
||||||
<t-icon :name="item.icon" class="nav-icon" />
|
<t-icon :name="item.icon" class="nav-icon" />
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
<t-radio-group
|
<t-radio-group
|
||||||
v-model="formData.type"
|
v-model="formData.type"
|
||||||
:disabled="mode === 'edit'"
|
:disabled="mode === 'edit'"
|
||||||
|
data-guide="kb-create-type"
|
||||||
>
|
>
|
||||||
<t-radio-button value="document">{{ $t('knowledgeEditor.basic.typeDocument') }}</t-radio-button>
|
<t-radio-button value="document">{{ $t('knowledgeEditor.basic.typeDocument') }}</t-radio-button>
|
||||||
<t-radio-button value="faq">{{ $t('knowledgeEditor.basic.typeFAQ') }}</t-radio-button>
|
<t-radio-button value="faq">{{ $t('knowledgeEditor.basic.typeFAQ') }}</t-radio-button>
|
||||||
@@ -71,7 +73,8 @@
|
|||||||
<div v-if="!isFAQ" class="form-item">
|
<div v-if="!isFAQ" class="form-item">
|
||||||
<label class="form-label required">{{ $t('knowledgeEditor.indexing.title') }}</label>
|
<label class="form-label required">{{ $t('knowledgeEditor.indexing.title') }}</label>
|
||||||
<p class="form-tip">{{ $t('knowledgeEditor.indexing.description') }}</p>
|
<p class="form-tip">{{ $t('knowledgeEditor.indexing.description') }}</p>
|
||||||
<div class="indexing-checks" :class="{ 'is-locked': isIndexingLocked }">
|
<div class="indexing-checks" :class="{ 'is-locked': isIndexingLocked }"
|
||||||
|
data-guide="kb-create-indexing">
|
||||||
<div
|
<div
|
||||||
class="indexing-check-item"
|
class="indexing-check-item"
|
||||||
:class="{ 'is-checked': formData.indexingStrategy.vectorEnabled, 'is-disabled': isIndexingLocked }"
|
:class="{ 'is-checked': formData.indexingStrategy.vectorEnabled, 'is-disabled': isIndexingLocked }"
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
<p class="form-tip granularity-hint">{{ granularityHint }}</p>
|
<p class="form-tip granularity-hint">{{ granularityHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-item">
|
<div class="form-item" data-guide="kb-create-name">
|
||||||
<label class="form-label required">{{ $t('knowledgeEditor.basic.nameLabel') }}</label>
|
<label class="form-label required">{{ $t('knowledgeEditor.basic.nameLabel') }}</label>
|
||||||
<t-input
|
<t-input
|
||||||
v-model="formData.name"
|
v-model="formData.name"
|
||||||
@@ -251,7 +254,7 @@
|
|||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<!-- 多模态开关 -->
|
<!-- 多模态开关 -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" data-guide="kb-create-multimodal-toggle">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('knowledgeEditor.advanced.multimodal.label') }}</label>
|
<label>{{ $t('knowledgeEditor.advanced.multimodal.label') }}</label>
|
||||||
<p class="desc">{{ $t('knowledgeEditor.advanced.multimodal.description') }}</p>
|
<p class="desc">{{ $t('knowledgeEditor.advanced.multimodal.description') }}</p>
|
||||||
@@ -266,7 +269,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VLLM 模型选择(多模态启用时) -->
|
<!-- VLLM 模型选择(多模态启用时) -->
|
||||||
<div v-if="formData.multimodalConfig.enabled" class="setting-row">
|
<div v-if="formData.multimodalConfig.enabled" class="setting-row"
|
||||||
|
data-guide="kb-create-multimodal-vllm">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('knowledgeEditor.advanced.multimodal.vllmLabel') }} <span class="required">*</span></label>
|
<label>{{ $t('knowledgeEditor.advanced.multimodal.vllmLabel') }} <span class="required">*</span></label>
|
||||||
<p class="desc">{{ $t('knowledgeEditor.advanced.multimodal.vllmDescription') }}</p>
|
<p class="desc">{{ $t('knowledgeEditor.advanced.multimodal.vllmDescription') }}</p>
|
||||||
@@ -369,7 +373,7 @@
|
|||||||
<t-button theme="default" variant="outline" @click="handleClose">
|
<t-button theme="default" variant="outline" @click="handleClose">
|
||||||
{{ $t('common.cancel') }}
|
{{ $t('common.cancel') }}
|
||||||
</t-button>
|
</t-button>
|
||||||
<t-button theme="primary" @click="handleSubmit" :loading="saving">
|
<t-button theme="primary" data-guide="kb-create-submit" @click="handleSubmit" :loading="saving">
|
||||||
{{ mode === 'create' ? $t('knowledgeEditor.buttons.create') : $t('knowledgeEditor.buttons.save') }}
|
{{ mode === 'create' ? $t('knowledgeEditor.buttons.create') : $t('knowledgeEditor.buttons.save') }}
|
||||||
</t-button>
|
</t-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,14 +383,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<KbCreateContextualGuide :when="visible && mode === 'create'" :is-faq="isFAQ"
|
||||||
|
:needs-embedding="kbCreateNeedsEmbedding" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import KbCreateContextualGuide from '@/components/KbCreateContextualGuide.vue'
|
||||||
|
import { KB_EDITOR_FOCUS_SECTION_EVENT, markContextualGuideDone } from '@/config/contextualGuides'
|
||||||
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
|
||||||
import { createKnowledgeBase, getKnowledgeBaseById, listKnowledgeFiles, updateKnowledgeBase, rebuildKBIndex } from '@/api/knowledge-base'
|
import { createKnowledgeBase, getKnowledgeBaseById, listKnowledgeFiles, updateKnowledgeBase, rebuildKBIndex } from '@/api/knowledge-base'
|
||||||
import { updateKBConfig, type KBModelConfigRequest } from '@/api/initialization'
|
import { updateKBConfig, type KBModelConfigRequest } from '@/api/initialization'
|
||||||
import { listModels } from '@/api/model'
|
import { listModels, type ModelConfig } from '@/api/model'
|
||||||
import { getStorageEngineConfig } from '@/api/system'
|
import { getStorageEngineConfig } from '@/api/system'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -445,6 +454,21 @@ const copyKbId = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentSection = ref<string>('basic')
|
const currentSection = ref<string>('basic')
|
||||||
|
|
||||||
|
const onKbEditorFocusSection = (event: Event) => {
|
||||||
|
const section = (event as CustomEvent<{ section?: string }>).detail?.section
|
||||||
|
if (section) {
|
||||||
|
currentSection.value = section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(KB_EDITOR_FOCUS_SECTION_EVENT, onKbEditorFocusSection)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener(KB_EDITOR_FOCUS_SECTION_EVENT, onKbEditorFocusSection)
|
||||||
|
})
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const allModels = ref<any[]>([])
|
const allModels = ref<any[]>([])
|
||||||
@@ -530,6 +554,28 @@ const advancedSettingsRef = ref<InstanceType<typeof KBAdvancedSettings>>()
|
|||||||
const formData = ref<any>(null)
|
const formData = ref<any>(null)
|
||||||
const isFAQ = computed(() => formData.value?.type === 'faq')
|
const isFAQ = computed(() => formData.value?.type === 'faq')
|
||||||
|
|
||||||
|
const kbCreateNeedsEmbedding = computed(() => {
|
||||||
|
if (!formData.value || formData.value.type === 'faq') return false
|
||||||
|
const s = formData.value.indexingStrategy
|
||||||
|
return Boolean(s?.vectorEnabled || s?.keywordEnabled)
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyDefaultModelsIfEmpty = () => {
|
||||||
|
if (!formData.value || props.mode !== 'create') return
|
||||||
|
const pick = (type: ModelConfig['type']) => {
|
||||||
|
const list = allModels.value.filter((m) => m.type === type)
|
||||||
|
return list.find((m) => m.is_default) || list[0]
|
||||||
|
}
|
||||||
|
const chat = pick('KnowledgeQA')
|
||||||
|
const embedding = pick('Embedding')
|
||||||
|
if (!formData.value.modelConfig.llmModelId && chat?.id) {
|
||||||
|
formData.value.modelConfig.llmModelId = chat.id
|
||||||
|
}
|
||||||
|
if (!formData.value.modelConfig.embeddingModelId && embedding?.id) {
|
||||||
|
formData.value.modelConfig.embeddingModelId = embedding.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => formData.value?.type,
|
() => formData.value?.type,
|
||||||
(newType, oldType) => {
|
(newType, oldType) => {
|
||||||
@@ -1133,6 +1179,7 @@ const doSubmit = async () => {
|
|||||||
throw new Error(result.message || t('knowledgeEditor.messages.createFailed'))
|
throw new Error(result.message || t('knowledgeEditor.messages.createFailed'))
|
||||||
}
|
}
|
||||||
MessagePlugin.success(t('knowledgeEditor.messages.createSuccess'))
|
MessagePlugin.success(t('knowledgeEditor.messages.createSuccess'))
|
||||||
|
markContextualGuideDone('kbCreate')
|
||||||
emit('success', result.data.id)
|
emit('success', result.data.id)
|
||||||
} else {
|
} else {
|
||||||
// 编辑模式:分别更新基本信息和配置
|
// 编辑模式:分别更新基本信息和配置
|
||||||
@@ -1316,6 +1363,7 @@ watch(() => props.visible, async (newVal) => {
|
|||||||
formData.value = initFormData(props.initialType || 'document')
|
formData.value = initFormData(props.initialType || 'document')
|
||||||
formData.value.storageProvider = tenantDefaultStorageProvider.value
|
formData.value.storageProvider = tenantDefaultStorageProvider.value
|
||||||
hasFiles.value = false
|
hasFiles.value = false
|
||||||
|
applyDefaultModelsIfEmpty()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 关闭弹窗时,延迟重置状态(等待动画结束)
|
// 关闭弹窗时,延迟重置状态(等待动画结束)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<h2 style="--wails-draggable: drag">{{ $t('knowledgeBase.title') }}</h2>
|
<h2 style="--wails-draggable: drag">{{ $t('knowledgeBase.title') }}</h2>
|
||||||
<t-tooltip v-if="authStore.hasRole('contributor')" :content="$t('knowledgeList.create')" placement="bottom">
|
<t-tooltip v-if="authStore.hasRole('contributor')" :content="$t('knowledgeList.create')" placement="bottom">
|
||||||
<t-button variant="text" theme="default" size="small" class="header-action-btn"
|
<t-button variant="text" theme="default" size="small" class="header-action-btn"
|
||||||
style="--wails-draggable: no-drag" @click="handleCreateKnowledgeBase">
|
data-guide="kb-list-create" style="--wails-draggable: no-drag" @click="handleCreateKnowledgeBase">
|
||||||
<template #icon><t-icon name="folder-add" size="16px" /></template>
|
<template #icon><t-icon name="folder-add" size="16px" /></template>
|
||||||
</t-button>
|
</t-button>
|
||||||
</t-tooltip>
|
</t-tooltip>
|
||||||
@@ -624,7 +624,7 @@
|
|||||||
<span class="empty-txt">{{ $t('knowledgeList.empty.title') }}</span>
|
<span class="empty-txt">{{ $t('knowledgeList.empty.title') }}</span>
|
||||||
<span class="empty-desc">{{ $t('knowledgeList.empty.description') }}</span>
|
<span class="empty-desc">{{ $t('knowledgeList.empty.description') }}</span>
|
||||||
<t-button v-if="authStore.hasRole('contributor')" class="kb-create-btn empty-state-btn"
|
<t-button v-if="authStore.hasRole('contributor')" class="kb-create-btn empty-state-btn"
|
||||||
@click="handleCreateKnowledgeBase">
|
data-guide="kb-list-create" @click="handleCreateKnowledgeBase">
|
||||||
<template #icon><t-icon name="folder-add" /></template>
|
<template #icon><t-icon name="folder-add" /></template>
|
||||||
{{ $t('knowledgeList.create') }}
|
{{ $t('knowledgeList.create') }}
|
||||||
</t-button>
|
</t-button>
|
||||||
@@ -652,7 +652,7 @@
|
|||||||
<span class="empty-txt">{{ $t('knowledgeList.empty.title') }}</span>
|
<span class="empty-txt">{{ $t('knowledgeList.empty.title') }}</span>
|
||||||
<span class="empty-desc">{{ $t('knowledgeList.empty.description') }}</span>
|
<span class="empty-desc">{{ $t('knowledgeList.empty.description') }}</span>
|
||||||
<t-button v-if="authStore.hasRole('contributor')" class="kb-create-btn empty-state-btn"
|
<t-button v-if="authStore.hasRole('contributor')" class="kb-create-btn empty-state-btn"
|
||||||
@click="handleCreateKnowledgeBase">
|
data-guide="kb-list-create" @click="handleCreateKnowledgeBase">
|
||||||
<template #icon><t-icon name="folder-add" /></template>
|
<template #icon><t-icon name="folder-add" /></template>
|
||||||
{{ $t('knowledgeList.create') }}
|
{{ $t('knowledgeList.create') }}
|
||||||
</t-button>
|
</t-button>
|
||||||
@@ -763,6 +763,8 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<TenantModelsGuide :when="showTenantModelsGuide" />
|
||||||
|
<ContextualGuide tour="kbList" :when="showKbListContextualGuide" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -780,6 +782,10 @@ import KnowledgeBaseEditorModal from './KnowledgeBaseEditorModal.vue'
|
|||||||
import ShareKnowledgeBaseDialog from '@/components/ShareKnowledgeBaseDialog.vue'
|
import ShareKnowledgeBaseDialog from '@/components/ShareKnowledgeBaseDialog.vue'
|
||||||
import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue'
|
import ListSpaceSidebar from '@/components/ListSpaceSidebar.vue'
|
||||||
import ResourceOriginBadge from '@/components/ResourceOriginBadge.vue'
|
import ResourceOriginBadge from '@/components/ResourceOriginBadge.vue'
|
||||||
|
import ContextualGuide from '@/components/ContextualGuide.vue'
|
||||||
|
import TenantModelsGuide from '@/components/TenantModelsGuide.vue'
|
||||||
|
import { isContextualGuideDone, markContextualGuideDone } from '@/config/contextualGuides'
|
||||||
|
import { useTenantModelReadiness } from '@/composables/useTenantModelReadiness'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useListUrlState } from '@/composables/useListUrlState'
|
import { useListUrlState } from '@/composables/useListUrlState'
|
||||||
import { useResourcePins } from '@/composables/useResourcePins'
|
import { useResourcePins } from '@/composables/useResourcePins'
|
||||||
@@ -788,6 +794,7 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const { loaded: modelsReadyLoaded, isReadyForDocumentKb } = useTenantModelReadiness()
|
||||||
const orgStore = useOrganizationStore()
|
const orgStore = useOrganizationStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -1192,6 +1199,22 @@ const filteredKnowledgeBases = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showKbListEmpty = computed(() => {
|
||||||
|
if (loading.value) return false
|
||||||
|
if (!authStore.hasRole('contributor')) return false
|
||||||
|
if (spaceSelection.value === 'all' && filteredKnowledgeBases.value.length === 0) return true
|
||||||
|
if (spaceSelection.value === 'mine' && kbs.value.length === 0) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showTenantModelsGuide = computed(
|
||||||
|
() => modelsReadyLoaded.value && showKbListEmpty.value && !isReadyForDocumentKb.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showKbListContextualGuide = computed(
|
||||||
|
() => showKbListEmpty.value && isReadyForDocumentKb.value && !uiStore.showKBEditorModal,
|
||||||
|
)
|
||||||
|
|
||||||
interface UploadTaskState {
|
interface UploadTaskState {
|
||||||
uploadId: string
|
uploadId: string
|
||||||
kbId: string
|
kbId: string
|
||||||
@@ -1647,13 +1670,23 @@ const goSettings = (id: string) => {
|
|||||||
|
|
||||||
// 创建知识库
|
// 创建知识库
|
||||||
const handleCreateKnowledgeBase = () => {
|
const handleCreateKnowledgeBase = () => {
|
||||||
|
if (!isReadyForDocumentKb.value) {
|
||||||
|
MessagePlugin.warning(t('contextualGuide.tenantModels.needModelsFirst'))
|
||||||
|
uiStore.openSettings('models')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markContextualGuideDone('kbList')
|
||||||
uiStore.openCreateKB()
|
uiStore.openCreateKB()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 知识库编辑器成功回调(创建或编辑成功)
|
// 知识库编辑器成功回调(创建或编辑成功)
|
||||||
const handleKBEditorSuccess = (kbId: string) => {
|
const handleKBEditorSuccess = (kbId: string) => {
|
||||||
console.log('[KnowledgeBaseList] knowledge operation success:', kbId)
|
console.log('[KnowledgeBaseList] knowledge operation success:', kbId)
|
||||||
|
const shouldOpenDetailForUploadGuide = !isContextualGuideDone('kbDetail')
|
||||||
fetchList().then(() => {
|
fetchList().then(() => {
|
||||||
|
if (shouldOpenDetailForUploadGuide && kbId) {
|
||||||
|
goDetail(kbId)
|
||||||
|
}
|
||||||
// 如果是从路由参数中获取的高亮ID,触发闪烁效果
|
// 如果是从路由参数中获取的高亮ID,触发闪烁效果
|
||||||
if (route.query.highlightKbId === kbId) {
|
if (route.query.highlightKbId === kbId) {
|
||||||
triggerHighlightFlash(kbId)
|
triggerHighlightFlash(kbId)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<!-- LLM 大语言模型 -->
|
<!-- LLM 大语言模型 -->
|
||||||
<div class="setting-row">
|
<div class="setting-row" data-guide="kb-create-llm">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('knowledgeEditor.models.llmLabel') }} <span class="required">*</span></label>
|
<label>{{ $t('knowledgeEditor.models.llmLabel') }} <span class="required">*</span></label>
|
||||||
<p class="desc">{{ $t('knowledgeEditor.models.llmDesc') }}</p>
|
<p class="desc">{{ $t('knowledgeEditor.models.llmDesc') }}</p>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Embedding 嵌入模型 (仅 RAG 检索启用时必填) -->
|
<!-- Embedding 嵌入模型 (仅 RAG 检索启用时必填) -->
|
||||||
<div v-if="ragEnabled !== false" class="setting-row">
|
<div v-if="ragEnabled !== false" class="setting-row" data-guide="kb-create-embedding">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>{{ $t('knowledgeEditor.models.embeddingLabel') }} <span v-if="ragEnabled" class="required">*</span></label>
|
<label>{{ $t('knowledgeEditor.models.embeddingLabel') }} <span v-if="ragEnabled" class="required">*</span></label>
|
||||||
<p class="desc">{{ $t('knowledgeEditor.models.embeddingDesc') }}</p>
|
<p class="desc">{{ $t('knowledgeEditor.models.embeddingDesc') }}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user