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:
wizardchen
2026-06-03 14:41:12 +08:00
committed by lyingbug
parent 291d72565f
commit f4af9cca97
22 changed files with 2483 additions and 579 deletions

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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_item4px+ 内边距协调,略大于目标圆角即可 */
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>

View 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>

View 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需对话+Embeddingagent仅需对话模型 */
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>

View 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,
}
}

View 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'
}

View File

@@ -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 questionanswer 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',

View File

@@ -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: "전체 선택",

View File

@@ -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: 'Выбрать все',

View File

@@ -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: "全选",

View 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
}

View 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([])
}
}

View File

@@ -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);

View File

@@ -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()
} }

View File

@@ -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);

View File

@@ -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 */

View File

@@ -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 {
// 关闭弹窗时,延迟重置状态(等待动画结束) // 关闭弹窗时,延迟重置状态(等待动画结束)

View File

@@ -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)

View File

@@ -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>