@movk/nuxt-docs 1.6.2 → 1.7.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/app/app.config.ts +37 -0
- package/app/app.vue +8 -3
- package/app/components/DocsAsideRightBottom.vue +17 -22
- package/app/components/PageHeaderLinks.vue +6 -1
- package/app/components/content/PageLastCommit.vue +5 -5
- package/app/components/header/Header.vue +1 -1
- package/app/components/header/HeaderBody.vue +12 -2
- package/app/components/header/HeaderBottom.vue +1 -0
- package/app/components/header/HeaderCTA.vue +2 -2
- package/app/components/header/HeaderCenter.vue +1 -1
- package/app/components/header/HeaderLogo.vue +1 -1
- package/app/layouts/default.vue +3 -1
- package/app/layouts/docs.vue +1 -1
- package/app/pages/docs/[...slug].vue +3 -2
- package/app/templates/releases.vue +98 -0
- package/app/types/index.d.ts +149 -0
- package/content.config.ts +24 -2
- package/modules/ai-chat/index.ts +53 -21
- package/modules/ai-chat/runtime/components/AiChat.vue +4 -10
- package/modules/ai-chat/runtime/components/AiChatDisabled.vue +3 -0
- package/modules/ai-chat/runtime/components/AiChatFloatingInput.vue +24 -9
- package/modules/ai-chat/runtime/components/AiChatModelSelect.vue +2 -0
- package/modules/ai-chat/runtime/components/AiChatPanel.vue +318 -0
- package/modules/ai-chat/runtime/components/AiChatPreStream.vue +1 -0
- package/modules/ai-chat/runtime/components/AiChatReasoning.vue +3 -3
- package/modules/ai-chat/runtime/components/AiChatSlideoverFaq.vue +2 -5
- package/modules/ai-chat/runtime/composables/useAIChat.ts +48 -0
- package/modules/ai-chat/runtime/composables/useModels.ts +3 -6
- package/modules/ai-chat/runtime/server/api/ai-chat.ts +40 -32
- package/modules/ai-chat/runtime/server/utils/docs_agent.ts +23 -15
- package/modules/ai-chat/runtime/types.ts +6 -0
- package/modules/css.ts +3 -2
- package/modules/routing.ts +26 -0
- package/nuxt.config.ts +2 -0
- package/nuxt.schema.ts +493 -0
- package/package.json +11 -9
- package/app/composables/useFaq.ts +0 -21
- package/modules/ai-chat/runtime/components/AiChatSlideover.vue +0 -255
- /package/{app → modules/ai-chat/runtime}/composables/useHighlighter.ts +0 -0
package/modules/ai-chat/index.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
addComponent,
|
|
5
|
+
addComponentsDir,
|
|
6
|
+
addImports,
|
|
7
|
+
addServerHandler,
|
|
8
|
+
createResolver,
|
|
9
|
+
defineNuxtModule,
|
|
10
|
+
logger
|
|
11
|
+
} from '@nuxt/kit'
|
|
4
12
|
|
|
5
13
|
export interface AiChatModuleOptions {
|
|
6
|
-
/**
|
|
7
|
-
* 是否启用 AI 聊天功能
|
|
8
|
-
* @default import.meta.env.AI_GATEWAY_API_KEY || import.meta.env.OPENROUTER_API_KEY
|
|
9
|
-
*/
|
|
10
|
-
enable?: boolean
|
|
11
14
|
/**
|
|
12
15
|
* 聊天 API 端点路径
|
|
13
16
|
* @default '/api/ai-chat'
|
|
@@ -29,43 +32,72 @@ export interface AiChatModuleOptions {
|
|
|
29
32
|
models?: string[]
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
const log = logger.withTag('docus:ai-assistant')
|
|
36
|
+
|
|
32
37
|
export default defineNuxtModule<AiChatModuleOptions>({
|
|
33
38
|
meta: {
|
|
34
39
|
name: 'ai-chat',
|
|
35
40
|
configKey: 'aiChat'
|
|
36
41
|
},
|
|
37
42
|
defaults: {
|
|
38
|
-
enable: !!(
|
|
39
|
-
import.meta.env.AI_GATEWAY_API_KEY
|
|
40
|
-
|| import.meta.env.OPENROUTER_API_KEY
|
|
41
|
-
),
|
|
42
43
|
apiPath: '/api/ai-chat',
|
|
43
44
|
mcpPath: '/mcp',
|
|
44
45
|
model: '',
|
|
45
46
|
models: []
|
|
46
47
|
},
|
|
47
48
|
setup(options, nuxt) {
|
|
49
|
+
const hasApiKey = !!(process.env.AI_GATEWAY_API_KEY || process.env.OPENROUTER_API_KEY)
|
|
50
|
+
|
|
48
51
|
const { resolve } = createResolver(import.meta.url)
|
|
49
52
|
|
|
50
53
|
nuxt.options.runtimeConfig.public.aiChat = {
|
|
51
|
-
|
|
54
|
+
enabled: hasApiKey,
|
|
52
55
|
apiPath: options.apiPath!,
|
|
53
56
|
model: options.model!,
|
|
54
57
|
models: options.models!
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
|
|
60
|
+
addImports([
|
|
61
|
+
{
|
|
62
|
+
name: 'useAIChat',
|
|
63
|
+
from: resolve('./runtime/composables/useAIChat')
|
|
64
|
+
}
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
if (hasApiKey) {
|
|
68
|
+
addComponentsDir({
|
|
69
|
+
path: resolve('./runtime/components'),
|
|
70
|
+
ignore: ['AiChatDisabled']
|
|
71
|
+
})
|
|
72
|
+
} else {
|
|
73
|
+
addComponent({
|
|
74
|
+
name: 'AiChatDisabled',
|
|
75
|
+
filePath: resolve('./runtime/components/AiChatDisabled.vue')
|
|
76
|
+
})
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
if (!
|
|
61
|
-
|
|
79
|
+
if (!hasApiKey) {
|
|
80
|
+
log.warn('[ai-chat] Module disabled: no AI_GATEWAY_API_KEY or OPENROUTER_API_KEY found')
|
|
81
|
+
return
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
84
|
+
nuxt.options.runtimeConfig.aiChat = {
|
|
85
|
+
mcpPath: options.mcpPath!
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
addImports([
|
|
89
|
+
{
|
|
90
|
+
name: 'useHighlighter',
|
|
91
|
+
from: resolve('./runtime/composables/useHighlighter')
|
|
92
|
+
}
|
|
93
|
+
])
|
|
67
94
|
|
|
68
|
-
|
|
95
|
+
addImports([
|
|
96
|
+
{
|
|
97
|
+
name: 'useModels',
|
|
98
|
+
from: resolve('./runtime/composables/useModels')
|
|
99
|
+
}
|
|
100
|
+
])
|
|
69
101
|
|
|
70
102
|
/**
|
|
71
103
|
* 检查用户项目中是否存在自定义 handler
|
|
@@ -87,7 +119,7 @@ export default defineNuxtModule<AiChatModuleOptions>({
|
|
|
87
119
|
handler: resolve('./runtime/server/api/ai-chat')
|
|
88
120
|
})
|
|
89
121
|
} else {
|
|
90
|
-
|
|
122
|
+
log.info(`[ai-chat] Using custom handler, skipping default handler registration`)
|
|
91
123
|
}
|
|
92
124
|
}
|
|
93
125
|
})
|
|
@@ -95,7 +127,7 @@ export default defineNuxtModule<AiChatModuleOptions>({
|
|
|
95
127
|
declare module 'nuxt/schema' {
|
|
96
128
|
interface PublicRuntimeConfig {
|
|
97
129
|
aiChat: {
|
|
98
|
-
|
|
130
|
+
enabled: boolean
|
|
99
131
|
apiPath: string
|
|
100
132
|
model: string
|
|
101
133
|
models: string[]
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
const {
|
|
3
|
-
/**
|
|
4
|
-
* 鼠标悬停时的提示文本
|
|
5
|
-
* @defaultValue 向 AI 提问
|
|
6
|
-
*/
|
|
7
|
-
tooltipText?: string
|
|
8
|
-
}>()
|
|
9
|
-
|
|
2
|
+
const { aiChat } = useAppConfig()
|
|
10
3
|
const { toggle } = useAIChat()
|
|
11
4
|
</script>
|
|
12
5
|
|
|
13
6
|
<template>
|
|
14
|
-
<UTooltip :text="
|
|
7
|
+
<UTooltip :text="aiChat.texts.trigger">
|
|
15
8
|
<UButton
|
|
16
|
-
icon="
|
|
9
|
+
:icon="aiChat.icons.trigger"
|
|
17
10
|
variant="ghost"
|
|
18
11
|
class="rounded-full"
|
|
12
|
+
aria-label="AI Chat Trigger"
|
|
19
13
|
@click="toggle"
|
|
20
14
|
/>
|
|
21
15
|
</UTooltip>
|
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
import { sleep } from '@movk/core'
|
|
3
3
|
import { AnimatePresence, motion } from 'motion-v'
|
|
4
4
|
|
|
5
|
+
const route = useRoute()
|
|
6
|
+
const { aiChat } = useAppConfig()
|
|
5
7
|
const { open, isOpen } = useAIChat()
|
|
8
|
+
|
|
6
9
|
const input = ref('')
|
|
7
10
|
const isVisible = ref(true)
|
|
8
11
|
const inputRef = ref<{ inputRef: HTMLInputElement } | null>(null)
|
|
9
12
|
|
|
13
|
+
const isDocsRoute = computed(() => route.meta.layout === 'docs')
|
|
14
|
+
const isFloatingInputEnabled = computed(() => aiChat.floatingInput !== false)
|
|
15
|
+
const focusInputShortcut = computed(() => aiChat.shortcuts.focusInput)
|
|
16
|
+
|
|
17
|
+
const shortcutDisplayKeys = computed(() => {
|
|
18
|
+
const shortcut = focusInputShortcut.value
|
|
19
|
+
const parts = shortcut.split('_')
|
|
20
|
+
return parts.map(part => part === 'meta' ? 'meta' : part.toUpperCase())
|
|
21
|
+
})
|
|
22
|
+
|
|
10
23
|
function handleSubmit() {
|
|
11
24
|
if (!input.value.trim()) return
|
|
12
25
|
|
|
@@ -20,10 +33,11 @@ function handleSubmit() {
|
|
|
20
33
|
})
|
|
21
34
|
}
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
const shortcuts = computed(() => ({
|
|
37
|
+
[focusInputShortcut.value]: {
|
|
25
38
|
usingInput: true,
|
|
26
39
|
handler: () => {
|
|
40
|
+
if (!isDocsRoute.value || !isFloatingInputEnabled.value) return
|
|
27
41
|
inputRef.value?.inputRef?.focus()
|
|
28
42
|
}
|
|
29
43
|
},
|
|
@@ -33,13 +47,15 @@ defineShortcuts({
|
|
|
33
47
|
inputRef.value?.inputRef?.blur()
|
|
34
48
|
}
|
|
35
49
|
}
|
|
36
|
-
})
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
defineShortcuts(shortcuts)
|
|
37
53
|
</script>
|
|
38
54
|
|
|
39
55
|
<template>
|
|
40
56
|
<AnimatePresence>
|
|
41
57
|
<motion.div
|
|
42
|
-
v-if="isVisible && !isOpen"
|
|
58
|
+
v-if="isFloatingInputEnabled && isDocsRoute && isVisible && !isOpen"
|
|
43
59
|
key="floating-input"
|
|
44
60
|
:initial="{ y: 20, opacity: 0 }"
|
|
45
61
|
:animate="{ y: 0, opacity: 1 }"
|
|
@@ -48,11 +64,11 @@ defineShortcuts({
|
|
|
48
64
|
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4"
|
|
49
65
|
style="will-change: transform"
|
|
50
66
|
>
|
|
51
|
-
<UForm @submit="handleSubmit">
|
|
67
|
+
<UForm @submit.prevent="handleSubmit">
|
|
52
68
|
<UInput
|
|
53
69
|
ref="inputRef"
|
|
54
70
|
v-model="input"
|
|
55
|
-
placeholder="
|
|
71
|
+
:placeholder="aiChat.texts.placeholder"
|
|
56
72
|
size="lg"
|
|
57
73
|
:ui="{
|
|
58
74
|
root: 'w-72 py-0.5 focus-within:w-96 transition-all duration-300 ease-out',
|
|
@@ -64,17 +80,16 @@ defineShortcuts({
|
|
|
64
80
|
<template #trailing>
|
|
65
81
|
<div class="flex items-center gap-2">
|
|
66
82
|
<div class="hidden sm:!flex items-center gap-1">
|
|
67
|
-
<UKbd value="
|
|
68
|
-
<UKbd value="I" />
|
|
83
|
+
<UKbd v-for="key in shortcutDisplayKeys" :key="key" :value="key" />
|
|
69
84
|
</div>
|
|
70
85
|
|
|
71
86
|
<UButton
|
|
87
|
+
aria-label="Send Message"
|
|
72
88
|
type="submit"
|
|
73
89
|
icon="i-lucide-arrow-up"
|
|
74
90
|
color="primary"
|
|
75
91
|
size="xs"
|
|
76
92
|
:disabled="!input.trim()"
|
|
77
|
-
class="rounded-lg"
|
|
78
93
|
/>
|
|
79
94
|
</div>
|
|
80
95
|
</template>
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DefineComponent } from 'vue'
|
|
3
|
+
import type { UIMessage } from 'ai'
|
|
4
|
+
import { Chat } from '@ai-sdk/vue'
|
|
5
|
+
import { DefaultChatTransport } from 'ai'
|
|
6
|
+
import { createReusableTemplate } from '@vueuse/core'
|
|
7
|
+
import AiChatPreStream from './AiChatPreStream.vue'
|
|
8
|
+
import { useModels } from '../composables/useModels'
|
|
9
|
+
|
|
10
|
+
const components = {
|
|
11
|
+
pre: AiChatPreStream as unknown as DefineComponent
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const [DefineChatContent, ReuseChatContent] = createReusableTemplate<{ showExpandButton?: boolean }>()
|
|
15
|
+
|
|
16
|
+
const { isOpen, isExpanded, isMobile, panelWidth, toggleExpanded, messages, pendingMessage, clearPending, faqQuestions } = useAIChat()
|
|
17
|
+
const config = useRuntimeConfig()
|
|
18
|
+
const { aiChat } = useAppConfig()
|
|
19
|
+
const toast = useToast()
|
|
20
|
+
const { model } = useModels()
|
|
21
|
+
|
|
22
|
+
const input = ref('')
|
|
23
|
+
|
|
24
|
+
const chat = new Chat({
|
|
25
|
+
messages: messages.value,
|
|
26
|
+
transport: new DefaultChatTransport({
|
|
27
|
+
api: config.public.aiChat.apiPath,
|
|
28
|
+
body: () => ({ model: model.value })
|
|
29
|
+
}),
|
|
30
|
+
onError: (error: Error) => {
|
|
31
|
+
const message = (() => {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(error.message)
|
|
34
|
+
return parsed?.message || error.message
|
|
35
|
+
} catch {
|
|
36
|
+
return error.message
|
|
37
|
+
}
|
|
38
|
+
})()
|
|
39
|
+
|
|
40
|
+
toast.add({
|
|
41
|
+
description: message,
|
|
42
|
+
icon: 'i-lucide-circle-alert',
|
|
43
|
+
color: 'error',
|
|
44
|
+
duration: 0
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
onFinish: () => {
|
|
48
|
+
messages.value = chat.messages
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
watch(pendingMessage, (message: string | undefined) => {
|
|
53
|
+
if (message) {
|
|
54
|
+
if (messages.value.length === 0 && chat.messages.length > 0) {
|
|
55
|
+
chat.messages.length = 0
|
|
56
|
+
}
|
|
57
|
+
chat.sendMessage({
|
|
58
|
+
text: message
|
|
59
|
+
})
|
|
60
|
+
clearPending()
|
|
61
|
+
}
|
|
62
|
+
}, { immediate: true })
|
|
63
|
+
|
|
64
|
+
watch(messages, (newMessages: UIMessage[]) => {
|
|
65
|
+
if (newMessages.length === 0 && chat.messages.length > 0) {
|
|
66
|
+
chat.messages.length = 0
|
|
67
|
+
}
|
|
68
|
+
}, { deep: true })
|
|
69
|
+
|
|
70
|
+
const lastMessage = computed(() => chat.messages.at(-1))
|
|
71
|
+
|
|
72
|
+
const { tools } = useToolCall()
|
|
73
|
+
function getToolLabel(toolName: string, args?: any): string {
|
|
74
|
+
const label = tools[toolName]
|
|
75
|
+
|
|
76
|
+
if (!label) {
|
|
77
|
+
return toolName
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return typeof label === 'function' ? label(args) : label
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleSubmit(event?: Event) {
|
|
84
|
+
event?.preventDefault()
|
|
85
|
+
|
|
86
|
+
if (!input.value.trim()) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
chat.sendMessage({
|
|
91
|
+
text: input.value
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
input.value = ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function askQuestion(question: string) {
|
|
98
|
+
chat.sendMessage({
|
|
99
|
+
text: question
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resetChat() {
|
|
104
|
+
chat.stop()
|
|
105
|
+
messages.value = []
|
|
106
|
+
chat.messages.length = 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onMounted(() => {
|
|
110
|
+
if (pendingMessage.value) {
|
|
111
|
+
chat.sendMessage({
|
|
112
|
+
text: pendingMessage.value
|
|
113
|
+
})
|
|
114
|
+
clearPending()
|
|
115
|
+
} else if (chat.lastMessage?.role === 'user') {
|
|
116
|
+
chat.regenerate()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<DefineChatContent v-slot="{ showExpandButton = true }">
|
|
123
|
+
<div class="flex h-full flex-col">
|
|
124
|
+
<div class="flex h-16 shrink-0 items-center justify-between border-b border-muted/50 px-4">
|
|
125
|
+
<span class="font-medium text-highlighted">{{ aiChat.texts.title }}</span>
|
|
126
|
+
<div class="flex items-center gap-1">
|
|
127
|
+
<UTooltip
|
|
128
|
+
v-if="showExpandButton"
|
|
129
|
+
:text="isExpanded ? aiChat.texts.collapse : aiChat.texts.expand"
|
|
130
|
+
>
|
|
131
|
+
<UButton
|
|
132
|
+
:icon="isExpanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
|
|
133
|
+
color="neutral"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="sm"
|
|
136
|
+
class="text-muted hover:text-highlighted"
|
|
137
|
+
aria-label="Toggle Expand Chat Panel"
|
|
138
|
+
@click="toggleExpanded"
|
|
139
|
+
/>
|
|
140
|
+
</UTooltip>
|
|
141
|
+
<UTooltip
|
|
142
|
+
v-if="chat.messages.length > 0"
|
|
143
|
+
:text="aiChat.texts.clearChat"
|
|
144
|
+
>
|
|
145
|
+
<UButton
|
|
146
|
+
:icon="aiChat.icons.clearChat"
|
|
147
|
+
color="neutral"
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="sm"
|
|
150
|
+
class="text-muted hover:text-highlighted"
|
|
151
|
+
aria-label="Clear Chat"
|
|
152
|
+
@click="resetChat"
|
|
153
|
+
/>
|
|
154
|
+
</UTooltip>
|
|
155
|
+
<UTooltip :text="aiChat.texts.close">
|
|
156
|
+
<UButton
|
|
157
|
+
:icon="aiChat.icons.close"
|
|
158
|
+
color="neutral"
|
|
159
|
+
variant="ghost"
|
|
160
|
+
size="sm"
|
|
161
|
+
class="text-muted hover:text-highlighted"
|
|
162
|
+
aria-label="Close Chat Panel"
|
|
163
|
+
@click="isOpen = false"
|
|
164
|
+
/>
|
|
165
|
+
</UTooltip>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
170
|
+
<UChatMessages
|
|
171
|
+
v-if="chat.messages.length > 0"
|
|
172
|
+
should-auto-scroll
|
|
173
|
+
:messages="chat.messages"
|
|
174
|
+
compact
|
|
175
|
+
:status="chat.status"
|
|
176
|
+
:user="{ ui: { container: 'pb-2', content: 'text-sm' } }"
|
|
177
|
+
:ui="{ indicator: '*:bg-accented', root: 'h-auto!' }"
|
|
178
|
+
class="px-4 py-4"
|
|
179
|
+
>
|
|
180
|
+
<template #content="{ message }">
|
|
181
|
+
<div class="flex flex-col gap-2">
|
|
182
|
+
<template
|
|
183
|
+
v-for="(part, index) in message.parts"
|
|
184
|
+
:key="`${message.id}-${part.type}-${index}${'state' in part ? `-${part.state}` : ''}`"
|
|
185
|
+
>
|
|
186
|
+
<AiChatReasoning v-if="part.type === 'reasoning'" :text="part.text" :is-streaming="part.state !== 'done'" />
|
|
187
|
+
|
|
188
|
+
<MDCCached
|
|
189
|
+
v-else-if="part.type === 'text'"
|
|
190
|
+
:value="part.text"
|
|
191
|
+
:cache-key="`${message.id}-${index}`"
|
|
192
|
+
:components="components"
|
|
193
|
+
:parser-options="{ highlight: false }"
|
|
194
|
+
class="*:first:mt-0 *:last:mb-0"
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
<template v-else-if="part.type === 'data-tool-calls'">
|
|
198
|
+
<AiChatToolCall
|
|
199
|
+
v-for="tool in (part as any).data.tools"
|
|
200
|
+
:key="`${tool.toolCallId}-${JSON.stringify(tool.args)}`"
|
|
201
|
+
:text="getToolLabel(tool.toolName, tool.args)"
|
|
202
|
+
:is-loading="false"
|
|
203
|
+
/>
|
|
204
|
+
</template>
|
|
205
|
+
</template>
|
|
206
|
+
<UButton
|
|
207
|
+
v-if="chat.status === 'streaming' && message.id === lastMessage?.id"
|
|
208
|
+
class="px-0"
|
|
209
|
+
color="neutral"
|
|
210
|
+
variant="link"
|
|
211
|
+
size="sm"
|
|
212
|
+
:label="aiChat.texts.loading"
|
|
213
|
+
loading
|
|
214
|
+
:loading-icon="aiChat.icons.loading"
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</template>
|
|
218
|
+
</UChatMessages>
|
|
219
|
+
|
|
220
|
+
<div
|
|
221
|
+
v-else
|
|
222
|
+
class="p-4"
|
|
223
|
+
>
|
|
224
|
+
<div
|
|
225
|
+
v-if="!faqQuestions?.length"
|
|
226
|
+
class="flex h-full flex-col items-center justify-center py-12 text-center"
|
|
227
|
+
>
|
|
228
|
+
<div class="mb-4 flex size-12 items-center justify-center rounded-full bg-primary/10">
|
|
229
|
+
<UIcon
|
|
230
|
+
name="i-lucide-message-circle-question"
|
|
231
|
+
class="size-6 text-primary"
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
<h3 class="mb-2 text-base font-medium text-highlighted">
|
|
235
|
+
{{ aiChat.texts.askAnything }}
|
|
236
|
+
</h3>
|
|
237
|
+
<p class="max-w-xs text-sm text-muted">
|
|
238
|
+
{{ aiChat.texts.askMeAnythingDescription }}
|
|
239
|
+
</p>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<template v-else>
|
|
243
|
+
<p class="mb-4 text-sm font-medium text-muted">
|
|
244
|
+
{{ aiChat.texts.faq }}
|
|
245
|
+
</p>
|
|
246
|
+
|
|
247
|
+
<AiChatSlideoverFaq :faq-questions="faqQuestions" @ask-question="askQuestion" />
|
|
248
|
+
</template>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<div class="w-full shrink-0 p-3">
|
|
253
|
+
<UChatPrompt
|
|
254
|
+
v-model="input"
|
|
255
|
+
:rows="2"
|
|
256
|
+
class="text-sm"
|
|
257
|
+
variant="subtle"
|
|
258
|
+
:placeholder="aiChat.texts.placeholder"
|
|
259
|
+
:ui="{
|
|
260
|
+
root: 'shadow-none!',
|
|
261
|
+
body: '*:p-0! *:rounded-none!'
|
|
262
|
+
}"
|
|
263
|
+
@submit="handleSubmit"
|
|
264
|
+
>
|
|
265
|
+
<template #footer>
|
|
266
|
+
<div class="hidden items-center divide-x divide-muted/50 sm:flex">
|
|
267
|
+
<AiChatModelSelect v-model="model" />
|
|
268
|
+
<div class="flex gap-1 justify-between items-center px-1 text-xs text-muted">
|
|
269
|
+
<span>{{ aiChat.texts.lineBreak }}</span>
|
|
270
|
+
<UKbd value="shift" />
|
|
271
|
+
<UKbd value="enter" />
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<UChatPromptSubmit
|
|
276
|
+
class="ml-auto"
|
|
277
|
+
size="xs"
|
|
278
|
+
:status="chat.status"
|
|
279
|
+
@stop="chat.stop()"
|
|
280
|
+
@reload="chat.regenerate()"
|
|
281
|
+
/>
|
|
282
|
+
</template>
|
|
283
|
+
</UChatPrompt>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</DefineChatContent>
|
|
287
|
+
|
|
288
|
+
<aside
|
|
289
|
+
v-if="!isMobile"
|
|
290
|
+
class="fixed top-0 z-50 h-dvh overflow-hidden border-l border-muted/50 bg-default/95 backdrop-blur-xl transition-[right,width] duration-200 ease-linear will-change-[right,width]"
|
|
291
|
+
:style="{
|
|
292
|
+
width: `${panelWidth}px`,
|
|
293
|
+
right: isOpen ? '0' : `-${panelWidth}px`
|
|
294
|
+
}"
|
|
295
|
+
>
|
|
296
|
+
<div
|
|
297
|
+
class="h-full transition-[width] duration-200 ease-linear"
|
|
298
|
+
:style="{ width: `${panelWidth}px` }"
|
|
299
|
+
>
|
|
300
|
+
<ReuseChatContent :show-expand-button="true" />
|
|
301
|
+
</div>
|
|
302
|
+
</aside>
|
|
303
|
+
|
|
304
|
+
<USlideover
|
|
305
|
+
v-else
|
|
306
|
+
v-model:open="isOpen"
|
|
307
|
+
title=" "
|
|
308
|
+
description=" "
|
|
309
|
+
side="right"
|
|
310
|
+
:ui="{
|
|
311
|
+
content: 'ring-0 bg-default'
|
|
312
|
+
}"
|
|
313
|
+
>
|
|
314
|
+
<template #content>
|
|
315
|
+
<ReuseChatContent :show-expand-button="false" />
|
|
316
|
+
</template>
|
|
317
|
+
</USlideover>
|
|
318
|
+
</template>
|
|
@@ -12,7 +12,7 @@ const { isStreaming = false } = defineProps<{
|
|
|
12
12
|
}>()
|
|
13
13
|
|
|
14
14
|
const open = ref(false)
|
|
15
|
-
const { ui } = useAppConfig()
|
|
15
|
+
const { ui, aiChat } = useAppConfig()
|
|
16
16
|
|
|
17
17
|
watch(() => isStreaming, () => {
|
|
18
18
|
open.value = isStreaming
|
|
@@ -33,11 +33,11 @@ function cleanMarkdown(text: string): string {
|
|
|
33
33
|
class="p-0 group"
|
|
34
34
|
color="neutral"
|
|
35
35
|
variant="link"
|
|
36
|
-
:trailing-icon="ui.icons.chevronDown"
|
|
36
|
+
:trailing-icon="aiChat.icons.streaming || ui.icons.chevronDown"
|
|
37
37
|
:ui="{
|
|
38
38
|
trailingIcon: text.length > 0 ? 'group-data-[state=open]:rotate-180 transition-transform duration-200' : 'hidden'
|
|
39
39
|
}"
|
|
40
|
-
:label="isStreaming ?
|
|
40
|
+
:label="isStreaming ? aiChat.texts.streaming : aiChat.texts.streamed"
|
|
41
41
|
/>
|
|
42
42
|
|
|
43
43
|
<template #content>
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
|
|
3
|
-
category: string
|
|
4
|
-
items: string[]
|
|
5
|
-
}
|
|
2
|
+
import type { FaqCategory } from '../types'
|
|
6
3
|
|
|
7
4
|
defineProps<{
|
|
8
5
|
/**
|
|
@@ -23,7 +20,7 @@ defineEmits<{
|
|
|
23
20
|
:key="category.category"
|
|
24
21
|
class="flex flex-col gap-1.5"
|
|
25
22
|
>
|
|
26
|
-
<h4 class="text-xs font-medium text-
|
|
23
|
+
<h4 class="text-xs font-medium text-muted uppercase tracking-wide">
|
|
27
24
|
{{ category.category }}
|
|
28
25
|
</h4>
|
|
29
26
|
<div class="flex flex-col">
|
|
@@ -1,10 +1,47 @@
|
|
|
1
1
|
import type { UIMessage } from 'ai'
|
|
2
|
+
import { useMediaQuery } from '@vueuse/core'
|
|
3
|
+
import type { FaqCategory, FaqQuestions } from '../types'
|
|
4
|
+
|
|
5
|
+
function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
|
|
6
|
+
if (!questions || (Array.isArray(questions) && questions.length === 0)) {
|
|
7
|
+
return []
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof questions[0] === 'string') {
|
|
11
|
+
return [{
|
|
12
|
+
category: '问题',
|
|
13
|
+
items: questions as string[]
|
|
14
|
+
}]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return questions as FaqCategory[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PANEL_WIDTH_COMPACT = 360
|
|
21
|
+
const PANEL_WIDTH_EXPANDED = 520
|
|
2
22
|
|
|
3
23
|
export function useAIChat() {
|
|
24
|
+
const config = useRuntimeConfig()
|
|
25
|
+
const appConfig = useAppConfig()
|
|
26
|
+
const isEnabled = computed(() => config.public.aiChat?.enabled ?? false)
|
|
27
|
+
|
|
4
28
|
const isOpen = useState('ai-chat-open', () => false)
|
|
29
|
+
const isExpanded = useState('ai-chat-expanded', () => false)
|
|
5
30
|
const messages = useState<UIMessage[]>('ai-chat-messages', () => [])
|
|
6
31
|
const pendingMessage = useState<string | undefined>('ai-chat-pending', () => undefined)
|
|
7
32
|
|
|
33
|
+
const isMobile = useMediaQuery('(max-width: 767px)')
|
|
34
|
+
const panelWidth = computed(() => isExpanded.value ? PANEL_WIDTH_EXPANDED : PANEL_WIDTH_COMPACT)
|
|
35
|
+
const shouldPushContent = computed(() => !isMobile.value && isOpen.value)
|
|
36
|
+
|
|
37
|
+
const faqQuestions = computed<FaqCategory[]>(() => {
|
|
38
|
+
const aiChatConfig = appConfig.aiChat
|
|
39
|
+
const faqConfig = aiChatConfig?.faqQuestions
|
|
40
|
+
if (!faqConfig) return []
|
|
41
|
+
|
|
42
|
+
return normalizeFaqQuestions(faqConfig)
|
|
43
|
+
})
|
|
44
|
+
|
|
8
45
|
function open(initialMessage?: string, clearPrevious = false) {
|
|
9
46
|
if (clearPrevious) {
|
|
10
47
|
messages.value = []
|
|
@@ -32,14 +69,25 @@ export function useAIChat() {
|
|
|
32
69
|
messages.value = []
|
|
33
70
|
}
|
|
34
71
|
|
|
72
|
+
function toggleExpanded() {
|
|
73
|
+
isExpanded.value = !isExpanded.value
|
|
74
|
+
}
|
|
75
|
+
|
|
35
76
|
return {
|
|
77
|
+
isEnabled,
|
|
36
78
|
isOpen,
|
|
79
|
+
isExpanded,
|
|
80
|
+
isMobile,
|
|
81
|
+
panelWidth,
|
|
82
|
+
shouldPushContent,
|
|
37
83
|
messages,
|
|
38
84
|
pendingMessage,
|
|
85
|
+
faqQuestions,
|
|
39
86
|
open,
|
|
40
87
|
clearPending,
|
|
41
88
|
close,
|
|
42
89
|
toggle,
|
|
90
|
+
toggleExpanded,
|
|
43
91
|
clearMessages
|
|
44
92
|
}
|
|
45
93
|
}
|