feat: 完成 AIGC Conversation 组件
Some checks failed
CI / lint (push) Failing after 59s
CI / test (push) Failing after 47s

This commit is contained in:
Timothy Yin 2025-04-26 21:54:10 +08:00
parent 92fc748a57
commit 20471bfbe3
9 changed files with 422 additions and 112 deletions

View File

@ -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
}) => {

View File

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

View File

@ -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[]
}

View File

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

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

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

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

View File

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