diff --git a/api/aifn.ts b/api/aifn.ts index 578fd5f..a8bd944 100644 --- a/api/aifn.ts +++ b/api/aifn.ts @@ -7,77 +7,6 @@ export type AIGeneratedContentResponse = IResponse<{ } }> -export const AGCStream = async ( - 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 }) => { diff --git a/components/ai/Conversation.vue b/components/ai/Conversation.vue index af25735..989daf1 100644 --- a/components/ai/Conversation.vue +++ b/components/ai/Conversation.vue @@ -2,9 +2,9 @@ diff --git a/components/ui/scroll-area/ScrollArea.vue b/components/ui/scroll-area/ScrollArea.vue new file mode 100644 index 0000000..8876d53 --- /dev/null +++ b/components/ui/scroll-area/ScrollArea.vue @@ -0,0 +1,29 @@ + + + diff --git a/components/ui/scroll-area/ScrollBar.vue b/components/ui/scroll-area/ScrollBar.vue new file mode 100644 index 0000000..59f723a --- /dev/null +++ b/components/ui/scroll-area/ScrollBar.vue @@ -0,0 +1,30 @@ + + + diff --git a/components/ui/scroll-area/index.ts b/components/ui/scroll-area/index.ts new file mode 100644 index 0000000..2bd4fae --- /dev/null +++ b/components/ui/scroll-area/index.ts @@ -0,0 +1,2 @@ +export { default as ScrollArea } from './ScrollArea.vue' +export { default as ScrollBar } from './ScrollBar.vue' diff --git a/stores/llmHistories.ts b/stores/llmHistories.ts new file mode 100644 index 0000000..5d980e0 --- /dev/null +++ b/stores/llmHistories.ts @@ -0,0 +1,98 @@ +import type { LLMConversation, LLMMessage } from '~/components/ai' + +export function useLlmHistories(key: string) { + const useStore = defineStore( + `llmConversations/${key}`, + () => { + const conversations = ref([]) + + 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) => { + 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() +} diff --git a/utils/http.ts b/utils/http.ts index b0b198a..8d8c58e 100644 --- a/utils/http.ts +++ b/utils/http.ts @@ -39,3 +39,78 @@ export const http = async ( } } } + +export const http_stream = async ( + 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) +}