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: {
|
export const generateLessonPlan = async (params: {
|
||||||
query: string
|
query: string
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FormContext } from 'vee-validate'
|
import type { FormContext } from 'vee-validate'
|
||||||
import type { AnyZodObject } from 'zod'
|
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<{
|
const props = defineProps<{
|
||||||
formSchema: AnyZodObject
|
formSchema: AnyZodObject
|
||||||
form: FormContext<any>
|
form: FormContext<any>
|
||||||
@ -15,36 +15,129 @@ const props = defineProps<{
|
|||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages?: LLMMessage[]
|
conversations?: LLMConversation[]
|
||||||
messagesHistory?: LLMMessages[]
|
activeConversationId?: string | null
|
||||||
disableUserInput?: boolean
|
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: '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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col gap-4">
|
<div class="h-full flex flex-col gap-4">
|
||||||
<div class="flex justify-between items-start">
|
<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>
|
<div>
|
||||||
<!-- Histories -->
|
<!-- Histories -->
|
||||||
<Button
|
<Popover>
|
||||||
variant="outline"
|
<PopoverTrigger>
|
||||||
size="sm"
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Icon name="tabler:history" />
|
size="sm"
|
||||||
历史记录
|
>
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<!-- 消息区域调整 -->
|
<!-- 消息区域调整 -->
|
||||||
<div
|
<div
|
||||||
v-if="messages && messages.length > 0"
|
v-if="activeConversationId"
|
||||||
class="flex flex-col flex-1 gap-4 max-h-[calc(100vh-280px)]"
|
class="flex flex-col flex-1 gap-4 max-h-[calc(100vh-280px)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -8,14 +8,15 @@ export interface AIGeneratedContentItem {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LLMConversation {
|
||||||
|
id: string
|
||||||
|
created_at?: number
|
||||||
|
finished_at?: number
|
||||||
|
title: string
|
||||||
|
messages: LLMMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface LLMMessage {
|
export interface LLMMessage {
|
||||||
role: 'user' | 'assistant' | 'system'
|
role: 'user' | 'assistant' | 'system'
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMMessages {
|
|
||||||
id?: string | number
|
|
||||||
timestamp: number
|
|
||||||
title: string
|
|
||||||
messages: LLMMessage[]
|
|
||||||
}
|
|
||||||
|
@ -1,11 +1,49 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
import { z } from 'zod'
|
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({
|
const schema = z.object({
|
||||||
query: z.string().describe('课程名称'),
|
query: z.string().describe('课程名称'),
|
||||||
@ -16,25 +54,37 @@ const form = useForm({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = (values: z.infer<typeof schema>) => {
|
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||||
// const valuesNoNil = useOmitBy(values, (v) => v == null || v === '')
|
http_stream(
|
||||||
messages.value.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `*生成一份 ${values.query} 的课程标准*`,
|
|
||||||
})
|
|
||||||
messages.value.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
})
|
|
||||||
AGCStream(
|
|
||||||
'/ai/course-standard/stream',
|
'/ai/course-standard/stream',
|
||||||
{ query: values.query },
|
|
||||||
{
|
{
|
||||||
onTextChunk: (chunk) => {
|
query: values.query,
|
||||||
console.log(chunk)
|
},
|
||||||
messages.value[messages.value.length - 1].content += chunk
|
{
|
||||||
|
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: () => {
|
onTextChunk: (chunk) => {
|
||||||
console.log('complete')
|
appendChunkToLast(activeConversationId.value!, chunk)
|
||||||
|
},
|
||||||
|
onComplete: (id, finished_at) => {
|
||||||
|
updateConversation(id!, {
|
||||||
|
finished_at,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -46,9 +96,12 @@ const onSubmit = (values: z.infer<typeof schema>) => {
|
|||||||
<AiConversation
|
<AiConversation
|
||||||
:form
|
:form
|
||||||
:form-schema="schema"
|
:form-schema="schema"
|
||||||
:messages="messages"
|
:conversations
|
||||||
|
:active-conversation-id="activeConversationId"
|
||||||
disable-user-input
|
disable-user-input
|
||||||
@submit="onSubmit"
|
@submit="onSubmit"
|
||||||
|
@update:conversation-id="activeConversationId = $event"
|
||||||
|
@delete-conversation="historyStore.removeConversation($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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