feat: 完成 AIGC Conversation 组件
This commit is contained in:
parent
92fc748a57
commit
20471bfbe3
71
api/aifn.ts
71
api/aifn.ts
@ -7,77 +7,6 @@ export type AIGeneratedContentResponse = IResponse<{
|
||||
}
|
||||
}>
|
||||
|
||||
export const AGCStream = async <ReqT>(
|
||||
endpoint: string,
|
||||
params: ReqT,
|
||||
events: {
|
||||
onTextChunk?: (message: string) => void
|
||||
onComplete?: () => void
|
||||
}) => {
|
||||
const { onTextChunk: onMessage, onComplete } = events
|
||||
|
||||
const loginState = useLoginState()
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const baseURL = runtimeConfig.public.baseURL as string
|
||||
|
||||
const response = await fetch(new URL(endpoint, baseURL), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'Authorization': `Bearer ${loginState.token}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader?.read() || {}
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop()!
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim().startsWith('data:')) continue
|
||||
const payload = part.replace(/^data:\s*/, '').trim()
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(payload)
|
||||
if (obj?.event && obj.event === 'workflow_finished') {
|
||||
onComplete?.()
|
||||
return
|
||||
}
|
||||
if (obj?.event && obj.event === 'text_chunk') {
|
||||
let text_chunk = obj.data?.text as string
|
||||
if (text_chunk) {
|
||||
if (text_chunk.startsWith('<') && text_chunk.endsWith('>')) {
|
||||
const endTag = text_chunk.match(/<\/[^>]*>/)
|
||||
if (endTag) {
|
||||
text_chunk = text_chunk.replace(endTag[0], '\n' + endTag[0])
|
||||
}
|
||||
}
|
||||
onMessage?.(text_chunk)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete?.()
|
||||
}
|
||||
|
||||
export const generateLessonPlan = async (params: {
|
||||
query: string
|
||||
}) => {
|
||||
|
@ -2,9 +2,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FormContext } from 'vee-validate'
|
||||
import type { AnyZodObject } from 'zod'
|
||||
import type { LLMMessage, LLMMessages } from '.'
|
||||
import dayjs from 'dayjs'
|
||||
import type { LLMConversation } from '.'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const props = defineProps<{
|
||||
formSchema: AnyZodObject
|
||||
form: FormContext<any>
|
||||
@ -15,36 +15,129 @@ const props = defineProps<{
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
messages?: LLMMessage[]
|
||||
messagesHistory?: LLMMessages[]
|
||||
conversations?: LLMConversation[]
|
||||
activeConversationId?: string | null
|
||||
disableUserInput?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const messages = computed(() => {
|
||||
const { conversations, activeConversationId } = props
|
||||
if (conversations && activeConversationId) {
|
||||
const conversation = conversations.find(
|
||||
(m) => m.id === activeConversationId,
|
||||
)
|
||||
return conversation ? conversation.messages : []
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', values: FormContext<any>['values']): void
|
||||
(e: 'update:conversationId', conversationId: string | null): void
|
||||
(e: 'deleteConversation', conversationId: string): void
|
||||
}>()
|
||||
|
||||
const onDeleteConversation = (conversationId: string) => {
|
||||
emit('deleteConversation', conversationId)
|
||||
if (conversationId === props.activeConversationId) {
|
||||
emit('update:conversationId', null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col gap-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div></div>
|
||||
<div>
|
||||
<Button
|
||||
v-if="activeConversationId"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="$emit('update:conversationId', null)"
|
||||
>
|
||||
<Icon name="tabler:arrow-back" />
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Histories -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="tabler:history" />
|
||||
历史记录
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Icon name="tabler:history" />
|
||||
历史记录
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
class="p-0"
|
||||
>
|
||||
<ScrollArea
|
||||
v-if="conversations?.length"
|
||||
class="flex flex-col gap-2 p-3 h-[320px]"
|
||||
>
|
||||
<div
|
||||
v-for="(conversation, i) in props.conversations"
|
||||
:key="i"
|
||||
class="flex items-start gap-2 p-2 rounded-md cursor-pointer relative"
|
||||
:class="`${conversation.id === activeConversationId ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`"
|
||||
@click="$emit('update:conversationId', conversation.id)"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute top-1.5 right-2"
|
||||
@click.stop="onDeleteConversation(conversation.id)"
|
||||
>
|
||||
<Icon name="tabler:trash" />
|
||||
</Button>
|
||||
|
||||
<Icon
|
||||
name="tabler:history"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<div class="flex-1 text-sm font-medium flex flex-col">
|
||||
<span class="w-2/3 text-ellipsis line-clamp-1">
|
||||
{{ conversation.title || '历史对话' }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="`${conversation.id === activeConversationId ? 'text-primary-foreground/60' : 'text-muted-foreground'}`"
|
||||
>
|
||||
{{
|
||||
dayjs((conversation.created_at || 0) * 1000).format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground/60 font-medium text-center pt-2">
|
||||
到底了
|
||||
</p>
|
||||
</ScrollArea>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-2 h-[320px]"
|
||||
>
|
||||
<Icon
|
||||
name="tabler:history"
|
||||
class="text-3xl text-muted-foreground"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">暂无历史记录</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<!-- 消息区域调整 -->
|
||||
<div
|
||||
v-if="messages && messages.length > 0"
|
||||
v-if="activeConversationId"
|
||||
class="flex flex-col flex-1 gap-4 max-h-[calc(100vh-280px)]"
|
||||
>
|
||||
<div
|
||||
|
@ -8,14 +8,15 @@ export interface AIGeneratedContentItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface LLMConversation {
|
||||
id: string
|
||||
created_at?: number
|
||||
finished_at?: number
|
||||
title: string
|
||||
messages: LLMMessage[]
|
||||
}
|
||||
|
||||
export interface LLMMessage {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface LLMMessages {
|
||||
id?: string | number
|
||||
timestamp: number
|
||||
title: string
|
||||
messages: LLMMessage[]
|
||||
}
|
||||
|
@ -1,11 +1,49 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { z } from 'zod'
|
||||
import { AGCStream } from '~/api/aifn'
|
||||
import type { LLMMessage } from '~/components/ai'
|
||||
|
||||
const messages = ref<LLMMessage[]>([])
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const historyStore = useLlmHistories('course-std-design')
|
||||
const { conversations } = storeToRefs(historyStore)
|
||||
const {
|
||||
updateConversation,
|
||||
appendChunkToLast,
|
||||
isConversationExist
|
||||
} = historyStore
|
||||
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
|
||||
watch(activeConversationId, (val) => {
|
||||
if (val) {
|
||||
router.replace({
|
||||
query: {
|
||||
fn: route.query.fn,
|
||||
conversationId: val,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.replace({
|
||||
query: {
|
||||
fn: route.query.fn,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.conversationId) {
|
||||
if (isConversationExist(route.query.conversationId as string)) {
|
||||
activeConversationId.value = route.query.conversationId as string
|
||||
} else {
|
||||
activeConversationId.value = null
|
||||
toast.error('会话不存在')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
query: z.string().describe('课程名称'),
|
||||
@ -16,25 +54,37 @@ const form = useForm({
|
||||
})
|
||||
|
||||
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||
// const valuesNoNil = useOmitBy(values, (v) => v == null || v === '')
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: `*生成一份 ${values.query} 的课程标准*`,
|
||||
})
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
})
|
||||
AGCStream(
|
||||
http_stream(
|
||||
'/ai/course-standard/stream',
|
||||
{ query: values.query },
|
||||
{
|
||||
onTextChunk: (chunk) => {
|
||||
console.log(chunk)
|
||||
messages.value[messages.value.length - 1].content += chunk
|
||||
query: values.query,
|
||||
},
|
||||
{
|
||||
onStart(id, created_at) {
|
||||
activeConversationId.value = id
|
||||
updateConversation(id, {
|
||||
id,
|
||||
created_at,
|
||||
title: values.query,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `*生成一份 ${values.query} 的课程标准*`,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log('complete')
|
||||
onTextChunk: (chunk) => {
|
||||
appendChunkToLast(activeConversationId.value!, chunk)
|
||||
},
|
||||
onComplete: (id, finished_at) => {
|
||||
updateConversation(id!, {
|
||||
finished_at,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -46,9 +96,12 @@ const onSubmit = (values: z.infer<typeof schema>) => {
|
||||
<AiConversation
|
||||
:form
|
||||
:form-schema="schema"
|
||||
:messages="messages"
|
||||
:conversations
|
||||
:active-conversation-id="activeConversationId"
|
||||
disable-user-input
|
||||
@submit="onSubmit"
|
||||
@update:conversation-id="activeConversationId = $event"
|
||||
@delete-conversation="historyStore.removeConversation($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
29
components/ui/scroll-area/ScrollArea.vue
Normal file
29
components/ui/scroll-area/ScrollArea.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
type ScrollAreaRootProps,
|
||||
ScrollAreaViewport,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import ScrollBar from './ScrollBar.vue'
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
|
||||
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
30
components/ui/scroll-area/ScrollBar.vue
Normal file
30
components/ui/scroll-area/ScrollBar.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ScrollAreaScrollbar, type ScrollAreaScrollbarProps, ScrollAreaThumb } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
orientation: 'vertical',
|
||||
})
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn('flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical'
|
||||
&& 'h-full w-2.5 border-l border-l-transparent p-px',
|
||||
orientation === 'horizontal'
|
||||
&& 'h-2.5 flex-col border-t border-t-transparent p-px',
|
||||
props.class)"
|
||||
>
|
||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
2
components/ui/scroll-area/index.ts
Normal file
2
components/ui/scroll-area/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
export { default as ScrollBar } from './ScrollBar.vue'
|
98
stores/llmHistories.ts
Normal file
98
stores/llmHistories.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import type { LLMConversation, LLMMessage } from '~/components/ai'
|
||||
|
||||
export function useLlmHistories(key: string) {
|
||||
const useStore = defineStore(
|
||||
`llmConversations/${key}`,
|
||||
() => {
|
||||
const conversations = ref<LLMConversation[]>([])
|
||||
|
||||
const createConversation = (conversation: LLMConversation) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conversation.id)
|
||||
if (index >= 0) {
|
||||
conversations.value[index] = conversation
|
||||
} else {
|
||||
conversations.value.unshift(conversation)
|
||||
}
|
||||
}
|
||||
|
||||
const getConversation = (conversationId: string) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||
return index >= 0 ? conversations.value[index] : null
|
||||
}
|
||||
|
||||
const updateConversation = (conversationId: string, conversation: Partial<LLMConversation>) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||
if (index >= 0) {
|
||||
conversations.value[index] = {
|
||||
...conversations.value[index],
|
||||
...conversation,
|
||||
}
|
||||
} else {
|
||||
createConversation({
|
||||
id: conversationId,
|
||||
...conversation,
|
||||
} as LLMConversation)
|
||||
}
|
||||
}
|
||||
|
||||
const removeConversation = (conversationId: string) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||
if (index >= 0) {
|
||||
conversations.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isConversationExist = (conversationId: string) => {
|
||||
return conversations.value.some(c => c.id === conversationId)
|
||||
}
|
||||
|
||||
const putMessageToConv = (conversationId: string, message: LLMMessage) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||
if (index >= 0) {
|
||||
const conversation = conversations.value[index]
|
||||
conversation.messages.push(message)
|
||||
conversations.value[index] = conversation
|
||||
} else {
|
||||
const newConversation: LLMConversation = {
|
||||
id: conversationId,
|
||||
title: '',
|
||||
messages: [message],
|
||||
}
|
||||
conversations.value.unshift(newConversation)
|
||||
}
|
||||
}
|
||||
|
||||
const appendChunkToLast = (conversationId: string, chunk: string) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conversationId)
|
||||
if (index >= 0) {
|
||||
const conversation = conversations.value[index]
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1]
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
lastMessage.content += chunk
|
||||
conversations.value[index] = conversation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
createConversation,
|
||||
getConversation,
|
||||
updateConversation,
|
||||
removeConversation,
|
||||
putMessageToConv,
|
||||
appendChunkToLast,
|
||||
isConversationExist
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: `xshic_llm_histories_${key}`,
|
||||
storage: piniaPluginPersistedstate.localStorage(),
|
||||
pick: ['conversations'],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return useStore()
|
||||
}
|
@ -39,3 +39,78 @@ export const http = async <T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const http_stream = async <ReqT>(
|
||||
endpoint: string,
|
||||
params: ReqT,
|
||||
events: {
|
||||
onStart?: (id: string, created_at?: number) => void
|
||||
onTextChunk?: (message: string) => void
|
||||
onComplete?: (id: string | null, finished_at?: number) => void
|
||||
}) => {
|
||||
const { onStart, onTextChunk, onComplete } = events
|
||||
|
||||
const loginState = useLoginState()
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const baseURL = runtimeConfig.public.baseURL as string
|
||||
|
||||
const response = await fetch(new URL(endpoint, baseURL), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'Authorization': `Bearer ${loginState.token}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader?.read() || {}
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop()!
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim().startsWith('data:')) continue
|
||||
const payload = part.replace(/^data:\s*/, '').trim()
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(payload)
|
||||
if (obj?.event && obj.event === 'workflow_started') {
|
||||
onStart?.(obj?.data?.id || null, obj?.data?.created_at || 0)
|
||||
}
|
||||
if (obj?.event && obj.event === 'workflow_finished') {
|
||||
onComplete?.(obj?.data?.id || null, obj?.data?.finished_at || 0)
|
||||
return
|
||||
}
|
||||
if (obj?.event && obj.event === 'text_chunk') {
|
||||
let text_chunk = obj.data?.text as string
|
||||
if (text_chunk) {
|
||||
if (text_chunk.startsWith('<') && text_chunk.endsWith('>')) {
|
||||
const endTag = text_chunk.match(/<\/[^>]*>/)
|
||||
if (endTag) {
|
||||
text_chunk = text_chunk.replace(endTag[0], '\n' + endTag[0])
|
||||
}
|
||||
}
|
||||
onTextChunk?.(text_chunk)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
onComplete?.(null)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user