@movk/nuxt-docs 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -24
- package/app/components/DocsAsideLeftBody.vue +13 -0
- package/app/components/DocsAsideLeftTop.vue +6 -0
- package/app/components/DocsAsideRightBottom.vue +29 -0
- package/app/components/OgImage/Nuxt.vue +40 -16
- package/app/components/PageHeaderLinks.vue +35 -9
- package/app/components/content/CommitChangelog.vue +34 -10
- package/app/components/content/ComponentEmits.vue +2 -2
- package/app/components/content/ComponentExample.vue +10 -10
- package/app/components/content/ComponentProps.vue +5 -3
- package/app/components/content/ComponentPropsSchema.vue +5 -1
- package/app/components/content/ComponentSlots.vue +2 -2
- package/app/components/content/HighlightInlineType.vue +1 -1
- package/app/components/content/PageLastCommit.vue +12 -8
- package/app/components/footer/Footer.vue +22 -0
- package/app/components/footer/FooterLeft.vue +7 -0
- package/app/components/footer/FooterRight.vue +14 -0
- package/app/components/header/Header.vue +4 -7
- package/app/components/header/HeaderCTA.vue +18 -0
- package/app/components/header/HeaderCenter.vue +7 -0
- package/app/components/theme-picker/ThemePicker.vue +24 -201
- package/app/composables/useFaq.ts +21 -0
- package/app/composables/useHighlighter.ts +22 -0
- package/app/composables/useTheme.ts +223 -0
- package/app/layouts/docs.vue +2 -15
- package/app/pages/docs/[...slug].vue +1 -1
- package/app/types/index.d.ts +6 -0
- package/app/utils/shiki-transformer-icon-highlight.ts +1 -1
- package/app/utils/unicode.ts +12 -0
- package/content.config.ts +2 -1
- package/modules/ai-chat/index.ts +90 -0
- package/modules/ai-chat/runtime/components/AiChat.vue +22 -0
- package/modules/ai-chat/runtime/components/AiChatFloatingInput.vue +85 -0
- package/modules/ai-chat/runtime/components/AiChatModelSelect.vue +24 -0
- package/modules/ai-chat/runtime/components/AiChatPreStream.vue +58 -0
- package/modules/ai-chat/runtime/components/AiChatReasoning.vue +49 -0
- package/modules/ai-chat/runtime/components/AiChatSlideover.vue +245 -0
- package/modules/ai-chat/runtime/components/AiChatSlideoverFaq.vue +41 -0
- package/modules/ai-chat/runtime/components/AiChatToolCall.vue +31 -0
- package/modules/ai-chat/runtime/composables/useAIChat.ts +45 -0
- package/modules/ai-chat/runtime/composables/useModels.ts +58 -0
- package/modules/ai-chat/runtime/composables/useTools.ts +31 -0
- package/modules/ai-chat/runtime/server/api/search.ts +84 -0
- package/modules/ai-chat/runtime/server/utils/docs_agent.ts +49 -0
- package/modules/ai-chat/runtime/server/utils/getModel.ts +25 -0
- package/modules/css.ts +2 -0
- package/nuxt.config.ts +27 -39
- package/package.json +16 -6
- package/server/mcp/tools/get-page.ts +60 -0
- package/server/mcp/tools/list-pages.ts +49 -0
- package/app/components/AdsCarbon.vue +0 -3
- package/app/components/Footer.vue +0 -32
- package/modules/llms.ts +0 -27
package/app/layouts/docs.vue
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { ContentNavigationItem } from '@nuxt/content'
|
|
3
|
-
|
|
4
|
-
const route = useRoute()
|
|
5
|
-
|
|
6
|
-
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
|
7
|
-
|
|
8
|
-
const { navigationByCategory } = useNavigation(navigation!)
|
|
9
|
-
</script>
|
|
10
|
-
|
|
11
1
|
<template>
|
|
12
2
|
<UMain class="relative">
|
|
13
3
|
<HeroBackground />
|
|
@@ -16,11 +6,8 @@ const { navigationByCategory } = useNavigation(navigation!)
|
|
|
16
6
|
<UPage>
|
|
17
7
|
<template #left>
|
|
18
8
|
<UPageAside>
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
:navigation="navigationByCategory"
|
|
22
|
-
highlight
|
|
23
|
-
/>
|
|
9
|
+
<DocsAsideLeftTop />
|
|
10
|
+
<DocsAsideLeftBody />
|
|
24
11
|
</UPageAside>
|
|
25
12
|
</template>
|
|
26
13
|
|
package/app/types/index.d.ts
CHANGED
|
@@ -46,6 +46,12 @@ declare module 'nuxt/schema' {
|
|
|
46
46
|
per_page: number
|
|
47
47
|
until: string
|
|
48
48
|
author: string
|
|
49
|
+
/**
|
|
50
|
+
* 文件命名格式
|
|
51
|
+
* @default 'auto'
|
|
52
|
+
* @example 'kebab' | 'camel' | 'pascal' | 'auto'
|
|
53
|
+
*/
|
|
54
|
+
casing: 'auto' | 'kebab' | 'camel' | 'pascal'
|
|
49
55
|
/**
|
|
50
56
|
* 日期格式化配置
|
|
51
57
|
* @example { locale: 'zh-CN', options: { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' } }
|
|
@@ -76,7 +76,7 @@ export function transformerIconHighlight(options: TransformerIconHighlightOption
|
|
|
76
76
|
tagName: 'i',
|
|
77
77
|
properties: {
|
|
78
78
|
class: 'shiki-icon-highlight',
|
|
79
|
-
style: `--shiki-icon-url: url(
|
|
79
|
+
style: `--shiki-icon-url: url(${iconUrl})`
|
|
80
80
|
},
|
|
81
81
|
children: []
|
|
82
82
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解码字符串中的 Unicode 转义序列
|
|
3
|
+
* @param str 包含 Unicode 转义序列的字符串(如 '\u5411 AI \u63D0\u95EE')
|
|
4
|
+
* @returns 解码后的字符串(如 '向 AI 提问')
|
|
5
|
+
* @example
|
|
6
|
+
* decodeUnicodeEscapes("'\\u5411 AI \\u63D0\\u95EE'") // => "'向 AI 提问'"
|
|
7
|
+
*/
|
|
8
|
+
export function decodeUnicodeEscapes(str: string): string {
|
|
9
|
+
return str.replace(/\\u[\dA-Fa-f]{4}/g, (match) => {
|
|
10
|
+
return String.fromCharCode(Number.parseInt(match.replace('\\u', ''), 16))
|
|
11
|
+
})
|
|
12
|
+
}
|
package/content.config.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { defineCollection, defineContentConfig
|
|
1
|
+
import { defineCollection, defineContentConfig } from '@nuxt/content'
|
|
2
2
|
import { useNuxt } from '@nuxt/kit'
|
|
3
3
|
import { asSeoCollection } from '@nuxtjs/seo/content'
|
|
4
4
|
import { joinURL } from 'ufo'
|
|
5
|
+
import { z } from 'zod/v4'
|
|
5
6
|
|
|
6
7
|
const { options } = useNuxt()
|
|
7
8
|
const cwd = joinURL(options.rootDir, 'content')
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { addComponentsDir, addImportsDir, addServerHandler, createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
2
|
+
|
|
3
|
+
export interface AiChatModuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* 是否启用 AI 聊天功能
|
|
6
|
+
* @default import.meta.env.AI_GATEWAY_API_KEY || import.meta.env.OPENROUTER_API_KEY
|
|
7
|
+
*/
|
|
8
|
+
enable?: boolean
|
|
9
|
+
/**
|
|
10
|
+
* 聊天 API 端点路径
|
|
11
|
+
* @default '/api/ai-chat'
|
|
12
|
+
*/
|
|
13
|
+
apiPath?: string
|
|
14
|
+
/**
|
|
15
|
+
* MCP 服务器连接路径
|
|
16
|
+
* @default '/mcp'
|
|
17
|
+
*/
|
|
18
|
+
mcpPath?: string
|
|
19
|
+
/**
|
|
20
|
+
* 通过 AI SDK Gateway 、OpenRouter 使用的 AI 模型
|
|
21
|
+
*/
|
|
22
|
+
model?: string
|
|
23
|
+
/**
|
|
24
|
+
* 可用模型列表
|
|
25
|
+
* - 格式为 "provider/model" 或 "model"
|
|
26
|
+
*/
|
|
27
|
+
models?: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default defineNuxtModule<AiChatModuleOptions>({
|
|
31
|
+
meta: {
|
|
32
|
+
name: 'ai-chat',
|
|
33
|
+
configKey: 'aiChat'
|
|
34
|
+
},
|
|
35
|
+
defaults: {
|
|
36
|
+
enable: !!(
|
|
37
|
+
import.meta.env.AI_GATEWAY_API_KEY
|
|
38
|
+
|| import.meta.env.OPENROUTER_API_KEY
|
|
39
|
+
),
|
|
40
|
+
apiPath: '/api/ai-chat',
|
|
41
|
+
mcpPath: '/mcp',
|
|
42
|
+
model: '',
|
|
43
|
+
models: []
|
|
44
|
+
},
|
|
45
|
+
setup(options, nuxt) {
|
|
46
|
+
const { resolve } = createResolver(import.meta.url)
|
|
47
|
+
|
|
48
|
+
nuxt.options.runtimeConfig.public.aiChat = {
|
|
49
|
+
enable: options.enable!,
|
|
50
|
+
apiPath: options.apiPath!,
|
|
51
|
+
model: options.model!,
|
|
52
|
+
models: options.models!
|
|
53
|
+
}
|
|
54
|
+
nuxt.options.runtimeConfig.aiChat = {
|
|
55
|
+
mcpPath: options.mcpPath!
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!options.enable) {
|
|
59
|
+
console.info('[ai-chat] Module disabled: no AI_GATEWAY_API_KEY or OPENROUTER_API_KEY found')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
addComponentsDir({
|
|
63
|
+
path: resolve('runtime/components')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
addImportsDir(resolve('runtime/composables'))
|
|
67
|
+
|
|
68
|
+
const routePath = options.apiPath!.replace(/^\//, '')
|
|
69
|
+
addServerHandler({
|
|
70
|
+
route: `/${routePath}`,
|
|
71
|
+
handler: resolve('./runtime/server/api/search')
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
declare module 'nuxt/schema' {
|
|
77
|
+
interface PublicRuntimeConfig {
|
|
78
|
+
aiChat: {
|
|
79
|
+
enable: boolean
|
|
80
|
+
apiPath: string
|
|
81
|
+
model: string
|
|
82
|
+
models: string[]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
interface RuntimeConfig {
|
|
86
|
+
aiChat: {
|
|
87
|
+
mcpPath: string
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { tooltipText = '向 AI 提问' } = defineProps<{
|
|
3
|
+
/**
|
|
4
|
+
* 鼠标悬停时的提示文本
|
|
5
|
+
* @defaultValue 向 AI 提问
|
|
6
|
+
*/
|
|
7
|
+
tooltipText?: string
|
|
8
|
+
}>()
|
|
9
|
+
|
|
10
|
+
const { toggle } = useAIChat()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<UTooltip :text="tooltipText">
|
|
15
|
+
<UButton
|
|
16
|
+
icon="i-lucide-sparkles"
|
|
17
|
+
variant="ghost"
|
|
18
|
+
class="rounded-full"
|
|
19
|
+
@click="toggle"
|
|
20
|
+
/>
|
|
21
|
+
</UTooltip>
|
|
22
|
+
</template>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { sleep } from '@movk/core'
|
|
3
|
+
import { AnimatePresence, motion } from 'motion-v'
|
|
4
|
+
|
|
5
|
+
const { open, isOpen } = useAIChat()
|
|
6
|
+
const input = ref('')
|
|
7
|
+
const isVisible = ref(true)
|
|
8
|
+
const inputRef = ref<{ inputRef: HTMLInputElement } | null>(null)
|
|
9
|
+
|
|
10
|
+
function handleSubmit() {
|
|
11
|
+
if (!input.value.trim()) return
|
|
12
|
+
|
|
13
|
+
const message = input.value
|
|
14
|
+
isVisible.value = false
|
|
15
|
+
|
|
16
|
+
sleep(200).then(() => {
|
|
17
|
+
open(message, true)
|
|
18
|
+
input.value = ''
|
|
19
|
+
isVisible.value = true
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
defineShortcuts({
|
|
24
|
+
meta_i: {
|
|
25
|
+
usingInput: true,
|
|
26
|
+
handler: () => {
|
|
27
|
+
inputRef.value?.inputRef?.focus()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
escape: {
|
|
31
|
+
usingInput: true,
|
|
32
|
+
handler: () => {
|
|
33
|
+
inputRef.value?.inputRef?.blur()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<AnimatePresence>
|
|
41
|
+
<motion.div
|
|
42
|
+
v-if="isVisible && !isOpen"
|
|
43
|
+
key="floating-input"
|
|
44
|
+
:initial="{ y: 20, opacity: 0 }"
|
|
45
|
+
:animate="{ y: 0, opacity: 1 }"
|
|
46
|
+
:exit="{ y: 100, opacity: 0 }"
|
|
47
|
+
:transition="{ duration: 0.2, ease: 'easeOut' }"
|
|
48
|
+
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4"
|
|
49
|
+
style="will-change: transform"
|
|
50
|
+
>
|
|
51
|
+
<UForm @submit="handleSubmit">
|
|
52
|
+
<UInput
|
|
53
|
+
ref="inputRef"
|
|
54
|
+
v-model="input"
|
|
55
|
+
placeholder="输入你的问题..."
|
|
56
|
+
size="lg"
|
|
57
|
+
:ui="{
|
|
58
|
+
root: 'w-72 py-0.5 focus-within:w-96 transition-all duration-300 ease-out',
|
|
59
|
+
base: 'bg-default/80 backdrop-blur-xl shadow-lg',
|
|
60
|
+
trailing: 'pe-2'
|
|
61
|
+
}"
|
|
62
|
+
@keydown.enter.exact.prevent="handleSubmit"
|
|
63
|
+
>
|
|
64
|
+
<template #trailing>
|
|
65
|
+
<div class="flex items-center gap-2">
|
|
66
|
+
<div class="hidden sm:!flex items-center gap-1">
|
|
67
|
+
<UKbd value="meta" />
|
|
68
|
+
<UKbd value="I" />
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<UButton
|
|
72
|
+
type="submit"
|
|
73
|
+
icon="i-lucide-arrow-up"
|
|
74
|
+
color="primary"
|
|
75
|
+
size="xs"
|
|
76
|
+
:disabled="!input.trim()"
|
|
77
|
+
class="rounded-lg"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
</UInput>
|
|
82
|
+
</UForm>
|
|
83
|
+
</motion.div>
|
|
84
|
+
</AnimatePresence>
|
|
85
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { model, models, formatModelName, getModelIcon } = useModels()
|
|
3
|
+
|
|
4
|
+
const items = computed(() => models.map(model => ({
|
|
5
|
+
label: formatModelName(model),
|
|
6
|
+
value: model,
|
|
7
|
+
icon: getModelIcon(model)
|
|
8
|
+
})))
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<USelectMenu
|
|
13
|
+
v-model="model"
|
|
14
|
+
:items="items"
|
|
15
|
+
size="sm"
|
|
16
|
+
:icon="getModelIcon(model)"
|
|
17
|
+
variant="ghost"
|
|
18
|
+
value-key="value"
|
|
19
|
+
class="hover:bg-default focus:bg-default data-[state=open]:bg-default"
|
|
20
|
+
:ui="{
|
|
21
|
+
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
|
|
22
|
+
}"
|
|
23
|
+
/>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ShikiCachedRenderer } from 'shiki-stream/vue'
|
|
3
|
+
|
|
4
|
+
const colorMode = useColorMode()
|
|
5
|
+
const highlighter = await useHighlighter()
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
/**
|
|
8
|
+
* 要高亮显示的代码内容
|
|
9
|
+
*/
|
|
10
|
+
code: string
|
|
11
|
+
/**
|
|
12
|
+
* 代码语言(如 'vue'、'javascript'、'typescript')
|
|
13
|
+
*/
|
|
14
|
+
language: string
|
|
15
|
+
/**
|
|
16
|
+
* 自定义 CSS 类名
|
|
17
|
+
*/
|
|
18
|
+
class?: string
|
|
19
|
+
/**
|
|
20
|
+
* 代码块元数据
|
|
21
|
+
*/
|
|
22
|
+
meta?: string
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
const trimmedCode = computed(() => {
|
|
26
|
+
return props.code.trim().replace(/`+$/, '')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const lang = computed(() => {
|
|
30
|
+
switch (props.language) {
|
|
31
|
+
case 'vue':
|
|
32
|
+
return 'vue'
|
|
33
|
+
case 'javascript':
|
|
34
|
+
return 'js'
|
|
35
|
+
case 'typescript':
|
|
36
|
+
return 'ts'
|
|
37
|
+
case 'css':
|
|
38
|
+
return 'css'
|
|
39
|
+
default:
|
|
40
|
+
return props.language
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
const key = computed(() => {
|
|
44
|
+
return `${lang.value}-${colorMode.value}`
|
|
45
|
+
})
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<ProsePre v-bind="props">
|
|
50
|
+
<ShikiCachedRenderer
|
|
51
|
+
:key="key"
|
|
52
|
+
:highlighter="highlighter"
|
|
53
|
+
:code="trimmedCode"
|
|
54
|
+
:lang="lang"
|
|
55
|
+
:theme="colorMode.value === 'dark' ? 'material-theme-palenight' : 'material-theme-lighter'"
|
|
56
|
+
/>
|
|
57
|
+
</ProsePre>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { isStreaming = false } = defineProps<{
|
|
3
|
+
/**
|
|
4
|
+
* 思考过程的文本内容
|
|
5
|
+
*/
|
|
6
|
+
text: string
|
|
7
|
+
/**
|
|
8
|
+
* 是否正在流式接收思考内容
|
|
9
|
+
* @defaultValue false
|
|
10
|
+
*/
|
|
11
|
+
isStreaming?: boolean
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const open = ref(false)
|
|
15
|
+
const { ui } = useAppConfig()
|
|
16
|
+
|
|
17
|
+
watch(() => isStreaming, () => {
|
|
18
|
+
open.value = isStreaming
|
|
19
|
+
}, { immediate: true })
|
|
20
|
+
|
|
21
|
+
function cleanMarkdown(text: string): string {
|
|
22
|
+
return text
|
|
23
|
+
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
|
|
24
|
+
.replace(/\*(.+?)\*/g, '$1') // Remove italic
|
|
25
|
+
.replace(/`(.+?)`/g, '$1') // Remove inline code
|
|
26
|
+
.replace(/^#+\s+/gm, '') // Remove headers
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<UCollapsible v-model:open="open" class="flex flex-col gap-1 my-5">
|
|
32
|
+
<UButton
|
|
33
|
+
class="p-0 group"
|
|
34
|
+
color="neutral"
|
|
35
|
+
variant="link"
|
|
36
|
+
:trailing-icon="ui.icons.chevronDown"
|
|
37
|
+
:ui="{
|
|
38
|
+
trailingIcon: text.length > 0 ? 'group-data-[state=open]:rotate-180 transition-transform duration-200' : 'hidden'
|
|
39
|
+
}"
|
|
40
|
+
:label="isStreaming ? '思考中...' : '思考过程'"
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
<template #content>
|
|
44
|
+
<div v-for="(value, index) in cleanMarkdown(text).split('\n').filter(Boolean)" :key="index">
|
|
45
|
+
<span class="whitespace-pre-wrap text-sm text-muted font-normal">{{ value }}</span>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
</UCollapsible>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DefineComponent } from 'vue'
|
|
3
|
+
import { Chat } from '@ai-sdk/vue'
|
|
4
|
+
import { DefaultChatTransport } from 'ai'
|
|
5
|
+
import AiChatPreStream from './AiChatPreStream.vue'
|
|
6
|
+
import type { FaqCategory } from './AiChatSlideoverFaq.vue'
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
title = 'AI 助手',
|
|
10
|
+
description = ' ',
|
|
11
|
+
placeholder = '输入你的问题...',
|
|
12
|
+
faqQuestions = []
|
|
13
|
+
} = defineProps<{
|
|
14
|
+
/**
|
|
15
|
+
* 标题栏显示的标题
|
|
16
|
+
* @defaultValue AI 助手
|
|
17
|
+
*/
|
|
18
|
+
title?: string
|
|
19
|
+
/**
|
|
20
|
+
* 标题栏显示的描述
|
|
21
|
+
* @defaultValue ' '
|
|
22
|
+
*/
|
|
23
|
+
description?: string
|
|
24
|
+
/**
|
|
25
|
+
* 输入框占位符文本
|
|
26
|
+
* @defaultValue 输入你的问题...
|
|
27
|
+
*/
|
|
28
|
+
placeholder?: string
|
|
29
|
+
/**
|
|
30
|
+
* 聊天为空时显示的常见问题分类
|
|
31
|
+
* @defaultValue []
|
|
32
|
+
*/
|
|
33
|
+
faqQuestions?: FaqCategory[]
|
|
34
|
+
}>()
|
|
35
|
+
|
|
36
|
+
const components = {
|
|
37
|
+
pre: AiChatPreStream as unknown as DefineComponent
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { messages, isOpen, pendingMessage, clearPending } = useAIChat()
|
|
41
|
+
const { apiPath } = useRuntimeConfig().public.aiChat
|
|
42
|
+
|
|
43
|
+
const { getToolLabel } = useTools()
|
|
44
|
+
const { model } = useModels()
|
|
45
|
+
|
|
46
|
+
const input = ref('')
|
|
47
|
+
|
|
48
|
+
watch(pendingMessage, (message) => {
|
|
49
|
+
if (message) {
|
|
50
|
+
if (messages.value.length === 0 && chat.messages.length > 0) {
|
|
51
|
+
chat.messages.length = 0
|
|
52
|
+
}
|
|
53
|
+
chat.sendMessage({
|
|
54
|
+
text: message
|
|
55
|
+
})
|
|
56
|
+
clearPending()
|
|
57
|
+
}
|
|
58
|
+
}, { immediate: true })
|
|
59
|
+
|
|
60
|
+
watch(messages, (newMessages) => {
|
|
61
|
+
if (newMessages.length === 0 && chat.messages.length > 0) {
|
|
62
|
+
chat.messages.length = 0
|
|
63
|
+
}
|
|
64
|
+
}, { deep: true })
|
|
65
|
+
|
|
66
|
+
const toast = useToast()
|
|
67
|
+
const lastMessage = computed(() => chat.messages.at(-1))
|
|
68
|
+
|
|
69
|
+
const chat = new Chat({
|
|
70
|
+
messages: messages.value,
|
|
71
|
+
transport: new DefaultChatTransport({
|
|
72
|
+
api: apiPath,
|
|
73
|
+
body: () => ({ model: model.value })
|
|
74
|
+
}),
|
|
75
|
+
onError(error) {
|
|
76
|
+
const { message } = typeof error.message === 'string' && error.message[0] === '{' ? JSON.parse(error.message) : error
|
|
77
|
+
toast.add({
|
|
78
|
+
description: message,
|
|
79
|
+
icon: 'i-lucide-circle-alert',
|
|
80
|
+
color: 'error',
|
|
81
|
+
duration: 0
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
onFinish: () => {
|
|
85
|
+
messages.value = chat.messages
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
function handleSubmit(event?: Event) {
|
|
90
|
+
event?.preventDefault()
|
|
91
|
+
|
|
92
|
+
if (!input.value.trim()) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
chat.sendMessage({
|
|
97
|
+
text: input.value
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
input.value = ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function askQuestion(question: string) {
|
|
104
|
+
chat.sendMessage({
|
|
105
|
+
text: question
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resetChat() {
|
|
110
|
+
chat.stop()
|
|
111
|
+
messages.value = []
|
|
112
|
+
chat.messages.length = 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onMounted(() => {
|
|
116
|
+
if (pendingMessage.value) {
|
|
117
|
+
chat.sendMessage({
|
|
118
|
+
text: pendingMessage.value
|
|
119
|
+
})
|
|
120
|
+
clearPending()
|
|
121
|
+
} else if (chat.lastMessage?.role === 'user') {
|
|
122
|
+
chat.regenerate()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<USlideover
|
|
129
|
+
v-model:open="isOpen"
|
|
130
|
+
:description="description"
|
|
131
|
+
:close="{ size: 'sm' }"
|
|
132
|
+
:ui="{
|
|
133
|
+
body: 'flex p-4!',
|
|
134
|
+
title: 'flex w-100 pr-6',
|
|
135
|
+
overlay: 'bg-default/60 backdrop-blur-sm',
|
|
136
|
+
content: 'w-full sm:max-w-md bg-default/95 backdrop-blur-xl shadow-2xl'
|
|
137
|
+
}"
|
|
138
|
+
>
|
|
139
|
+
<template #title>
|
|
140
|
+
<div class="flex items-center gap-2 flex-1">
|
|
141
|
+
<UBadge icon="i-lucide-sparkles" variant="soft" square />
|
|
142
|
+
<span class="font-medium text-highlighted">{{ title }}</span>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div class="flex items-center gap-2">
|
|
146
|
+
<UTooltip v-if="chat.messages.length > 0" text="清空聊天">
|
|
147
|
+
<UButton
|
|
148
|
+
icon="i-lucide-trash-2"
|
|
149
|
+
color="neutral"
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="sm"
|
|
152
|
+
@click="resetChat"
|
|
153
|
+
/>
|
|
154
|
+
</UTooltip>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|
|
157
|
+
|
|
158
|
+
<template #body>
|
|
159
|
+
<UChatPalette class="flex-1" :ui="{ prompt: 'border-0 px-2.5' }">
|
|
160
|
+
<UChatMessages
|
|
161
|
+
v-if="chat.messages.length > 0"
|
|
162
|
+
should-auto-scroll
|
|
163
|
+
:messages="chat.messages"
|
|
164
|
+
compact
|
|
165
|
+
:status="chat.status"
|
|
166
|
+
:user="{ ui: { container: 'pb-2', content: 'text-sm' } }"
|
|
167
|
+
:ui="{ indicator: '*:bg-accented' }"
|
|
168
|
+
>
|
|
169
|
+
<template #content="{ message }">
|
|
170
|
+
<div class="flex flex-col gap-2">
|
|
171
|
+
<template
|
|
172
|
+
v-for="(part, index) in message.parts"
|
|
173
|
+
:key="`${message.id}-${part.type}-${index}${'state' in part ? `-${part.state}` : ''}`"
|
|
174
|
+
>
|
|
175
|
+
<AiChatReasoning v-if="part.type === 'reasoning'" :text="part.text" :is-streaming="part.state !== 'done'" />
|
|
176
|
+
<MDCCached
|
|
177
|
+
v-else-if="part.type === 'text'"
|
|
178
|
+
:value="part.text"
|
|
179
|
+
:cache-key="`${message.id}-${index}`"
|
|
180
|
+
:components="components"
|
|
181
|
+
:parser-options="{ highlight: false }"
|
|
182
|
+
class="*:first:mt-0 *:last:mb-0"
|
|
183
|
+
/>
|
|
184
|
+
<template v-else-if="part.type === 'data-tool-calls'">
|
|
185
|
+
<AiChatToolCall
|
|
186
|
+
v-for="tool in (part as any).data.tools"
|
|
187
|
+
:key="tool.toolCallId"
|
|
188
|
+
:text="getToolLabel(tool.toolName, tool.input)"
|
|
189
|
+
:is-loading="false"
|
|
190
|
+
/>
|
|
191
|
+
</template>
|
|
192
|
+
</template>
|
|
193
|
+
<UButton
|
|
194
|
+
v-if="chat.status === 'streaming' && message.id === lastMessage?.id"
|
|
195
|
+
class="px-0"
|
|
196
|
+
color="neutral"
|
|
197
|
+
variant="link"
|
|
198
|
+
size="sm"
|
|
199
|
+
label="Loading..."
|
|
200
|
+
loading
|
|
201
|
+
loading-icon="i-lucide-loader"
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
</template>
|
|
205
|
+
</UChatMessages>
|
|
206
|
+
<div v-else class="flex-1 overflow-y-auto px-4 py-4">
|
|
207
|
+
<p class="text-sm font-medium text-muted mb-4">
|
|
208
|
+
FAQ 建议
|
|
209
|
+
</p>
|
|
210
|
+
<AiChatSlideoverFaq :faq-questions="faqQuestions" @ask-question="askQuestion" />
|
|
211
|
+
</div>
|
|
212
|
+
<template #prompt>
|
|
213
|
+
<UChatPrompt
|
|
214
|
+
v-model="input"
|
|
215
|
+
:error="chat.error"
|
|
216
|
+
:placeholder="placeholder"
|
|
217
|
+
variant="subtle"
|
|
218
|
+
class="[view-transition-name:chat-prompt]"
|
|
219
|
+
:ui="{ base: 'px-1.5 text-sm' }"
|
|
220
|
+
@submit="handleSubmit"
|
|
221
|
+
>
|
|
222
|
+
<template #footer>
|
|
223
|
+
<div class="flex items-center gap-1">
|
|
224
|
+
<AiChatModelSelect v-model="model" />
|
|
225
|
+
<div class="flex gap-1 justify-between items-center px-1 text-xs text-dimmed">
|
|
226
|
+
换行
|
|
227
|
+
<UKbd value="shift" />
|
|
228
|
+
<UKbd value="enter" />
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<UChatPromptSubmit
|
|
233
|
+
:status="chat.status"
|
|
234
|
+
color="neutral"
|
|
235
|
+
size="sm"
|
|
236
|
+
@stop="chat.stop()"
|
|
237
|
+
@reload="chat.regenerate()"
|
|
238
|
+
/>
|
|
239
|
+
</template>
|
|
240
|
+
</UChatPrompt>
|
|
241
|
+
</template>
|
|
242
|
+
</UChatPalette>
|
|
243
|
+
</template>
|
|
244
|
+
</USlideover>
|
|
245
|
+
</template>
|