diff --git a/ai-chat-web/src/locales/en-US.ts b/ai-chat-web/src/locales/en-US.ts index 357b5ec..d8492fe 100644 --- a/ai-chat-web/src/locales/en-US.ts +++ b/ai-chat-web/src/locales/en-US.ts @@ -48,6 +48,11 @@ export default { clearHistoryConfirm: 'Are you sure to clear chat history?', preview: 'Preview', showRawText: 'Show as raw text', + sourceSemantic: 'Semantic Match', + sourceLlm: 'LLM Output', + promptTokens: 'Prompt {count} tokens', + completionTokens: 'Completion {count} tokens', + sessionTokens: 'Session {count} tokens', }, setting: { setting: 'Setting', diff --git a/ai-chat-web/src/locales/zh-CN.ts b/ai-chat-web/src/locales/zh-CN.ts index ba6db4e..5a9eb82 100644 --- a/ai-chat-web/src/locales/zh-CN.ts +++ b/ai-chat-web/src/locales/zh-CN.ts @@ -48,6 +48,11 @@ export default { clearHistoryConfirm: '确定清空聊天记录?', preview: '预览', showRawText: '显示原文', + sourceSemantic: '语义匹配', + sourceLlm: '大模型输出', + promptTokens: '问题 {count} tokens', + completionTokens: '回答 {count} tokens', + sessionTokens: '本轮消耗 {count} tokens', }, setting: { setting: '设置', diff --git a/ai-chat-web/src/locales/zh-TW.ts b/ai-chat-web/src/locales/zh-TW.ts index 68f4b27..9c2b670 100644 --- a/ai-chat-web/src/locales/zh-TW.ts +++ b/ai-chat-web/src/locales/zh-TW.ts @@ -48,6 +48,11 @@ export default { clearHistoryConfirm: '確定清除紀錄?', preview: '預覽', showRawText: '顯示原文', + sourceSemantic: '語義匹配', + sourceLlm: '大模型輸出', + promptTokens: '問題 {count} tokens', + completionTokens: '回答 {count} tokens', + sessionTokens: '本輪消耗 {count} tokens', }, setting: { setting: '設定', diff --git a/ai-chat-web/src/store/modules/chat/helper.ts b/ai-chat-web/src/store/modules/chat/helper.ts index c934a40..fed9036 100644 --- a/ai-chat-web/src/store/modules/chat/helper.ts +++ b/ai-chat-web/src/store/modules/chat/helper.ts @@ -2,19 +2,36 @@ import { ss } from '@/utils/storage' const LOCAL_NAME = 'chatStorage' +export function emptyUsage(): Chat.TokenUsage { + return { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + } +} + export function defaultState(): Chat.ChatState { const uuid = 1002 return { active: uuid, usingContext: true, history: [{ uuid, title: 'New Chat', isEdit: false }], - chat: [{ uuid, data: [] }], + chat: [{ uuid, data: [], sessionUsage: emptyUsage() }], } } export function getLocalState(): Chat.ChatState { const localState = ss.get(LOCAL_NAME) - return { ...defaultState(), ...localState } + const state = { ...defaultState(), ...localState } + + return { + ...state, + chat: (state.chat || []).map((item: Partial) => ({ + uuid: item.uuid || Date.now(), + data: item.data || [], + sessionUsage: { ...emptyUsage(), ...(item.sessionUsage || {}) }, + })), + } } export function setLocalState(state: Chat.ChatState) { diff --git a/ai-chat-web/src/store/modules/chat/index.ts b/ai-chat-web/src/store/modules/chat/index.ts index 38f3215..dcb498a 100644 --- a/ai-chat-web/src/store/modules/chat/index.ts +++ b/ai-chat-web/src/store/modules/chat/index.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { getLocalState, setLocalState } from './helper' +import { emptyUsage, getLocalState, setLocalState } from './helper' import { router } from '@/router' export const useChatStore = defineStore('chat-store', { @@ -20,6 +20,14 @@ export const useChatStore = defineStore('chat-store', { return state.chat.find(item => item.uuid === state.active)?.data ?? [] } }, + + getSessionUsageByUuid(state: Chat.ChatState) { + return (uuid?: number) => { + if (uuid) + return state.chat.find(item => item.uuid === uuid)?.sessionUsage ?? emptyUsage() + return state.chat.find(item => item.uuid === state.active)?.sessionUsage ?? emptyUsage() + } + }, }, actions: { @@ -30,7 +38,7 @@ export const useChatStore = defineStore('chat-store', { addHistory(history: Chat.History, chatData: Chat.Chat[] = []) { this.history.unshift(history) - this.chat.unshift({ uuid: history.uuid, data: chatData }) + this.chat.unshift({ uuid: history.uuid, data: chatData, sessionUsage: emptyUsage() }) this.active = history.uuid this.reloadRoute(history.uuid) }, @@ -97,7 +105,7 @@ export const useChatStore = defineStore('chat-store', { if (this.history.length === 0) { const uuid = Date.now() this.history.push({ uuid, title: chat.text, isEdit: false }) - this.chat.push({ uuid, data: [chat] }) + this.chat.push({ uuid, data: [chat], sessionUsage: emptyUsage() }) this.active = uuid this.recordState() } @@ -182,6 +190,32 @@ export const useChatStore = defineStore('chat-store', { } }, + addSessionUsageByUuid(uuid: number, usage: Partial) { + const sessionUsage = { ...emptyUsage(), ...usage } + + if (!uuid || uuid === 0) { + if (this.chat.length) { + this.chat[0].sessionUsage = { + prompt_tokens: this.chat[0].sessionUsage.prompt_tokens + sessionUsage.prompt_tokens, + completion_tokens: this.chat[0].sessionUsage.completion_tokens + sessionUsage.completion_tokens, + total_tokens: this.chat[0].sessionUsage.total_tokens + sessionUsage.total_tokens, + } + this.recordState() + } + return + } + + const index = this.chat.findIndex(item => item.uuid === uuid) + if (index !== -1) { + this.chat[index].sessionUsage = { + prompt_tokens: this.chat[index].sessionUsage.prompt_tokens + sessionUsage.prompt_tokens, + completion_tokens: this.chat[index].sessionUsage.completion_tokens + sessionUsage.completion_tokens, + total_tokens: this.chat[index].sessionUsage.total_tokens + sessionUsage.total_tokens, + } + this.recordState() + } + }, + async reloadRoute(uuid?: number) { this.recordState() await router.push({ name: 'Chat', params: { uuid } }) diff --git a/ai-chat-web/src/typings/chat.d.ts b/ai-chat-web/src/typings/chat.d.ts index 0c8012e..7a61b36 100644 --- a/ai-chat-web/src/typings/chat.d.ts +++ b/ai-chat-web/src/typings/chat.d.ts @@ -1,4 +1,17 @@ declare namespace Chat { + interface TokenUsage { + prompt_tokens: number + completion_tokens: number + total_tokens: number + } + + type ReplySource = 'semantic_match' | 'llm' + + interface MessageMeta { + source?: ReplySource | null + tokenUsed?: boolean + usage?: TokenUsage | null + } interface Chat { dateTime: string @@ -8,6 +21,7 @@ declare namespace Chat { loading?: boolean conversationOptions?: ConversationRequest | null requestOptions: { prompt: string; options?: ConversationRequest | null } + messageMeta?: MessageMeta | null } interface History { @@ -16,11 +30,17 @@ declare namespace Chat { uuid: number } + interface Session { + uuid: number + data: Chat[] + sessionUsage: TokenUsage + } + interface ChatState { active: number | null usingContext: boolean; history: History[] - chat: { uuid: number; data: Chat[] }[] + chat: Session[] } interface ConversationRequest { @@ -29,18 +49,22 @@ declare namespace Chat { } interface ConversationResponse { - conversationId: string - detail: { + conversationId?: string + detail?: { choices: { finish_reason: string; index: number; logprobs: any; text: string }[] created: number id: string model: string object: string - usage: { completion_tokens: number; prompt_tokens: number; total_tokens: number } + usage: TokenUsage } - id: string - parentMessageId: string - role: string + id?: string + parentMessageId?: string + role?: string text: string + usage?: TokenUsage + source?: ReplySource + tokenUsed?: boolean + meta?: MessageMeta | null } } diff --git a/ai-chat-web/src/views/chat/components/Message/index.vue b/ai-chat-web/src/views/chat/components/Message/index.vue index 541dde1..35575b5 100644 --- a/ai-chat-web/src/views/chat/components/Message/index.vue +++ b/ai-chat-web/src/views/chat/components/Message/index.vue @@ -15,6 +15,7 @@ interface Props { inversion?: boolean error?: boolean loading?: boolean + messageMeta?: Chat.MessageMeta | null } interface Emit { @@ -36,6 +37,42 @@ const asRawText = ref(props.inversion) const messageRef = ref() +const sourceLabel = computed(() => { + if (props.inversion) + return '' + + switch (props.messageMeta?.source) { + case 'semantic_match': + return t('chat.sourceSemantic') + case 'llm': + return t('chat.sourceLlm') + default: + return '' + } +}) + +const sourceClass = computed(() => { + switch (props.messageMeta?.source) { + case 'semantic_match': + return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300' + case 'llm': + return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300' + default: + return 'border-[#d0d7de] bg-white text-[#57606a] dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + } +}) + +const usageLabel = computed(() => { + const usage = props.messageMeta?.usage + if (!usage) + return '' + + if (props.inversion) + return t('chat.promptTokens', { count: usage.prompt_tokens }) + + return t('chat.completionTokens', { count: usage.completion_tokens }) +}) + const options = computed(() => { const common = [ { @@ -93,9 +130,22 @@ function handleRegenerate() {
-

- {{ dateTime }} -

+
+ {{ dateTime }} + + {{ sourceLabel }} + + + {{ usageLabel }} + +
chatStore.getChatByUuid(+uuid)) +const sessionUsage = computed(() => chatStore.getSessionUsageByUuid(+uuid)) +const sessionTokenText = computed(() => t('chat.sessionTokens', { count: sessionUsage.value.total_tokens })) const conversationList = computed(() => dataSources.value.filter(item => (!item.inversion && !item.error))) const prompt = ref('') @@ -49,6 +51,102 @@ const promptStore = usePromptStore() // 使用storeToRefs,保证store修改后,联想部分能够重新渲染 const { promptList: promptTemplate } = storeToRefs(promptStore) +function createEmptyUsage(): Chat.TokenUsage { + return { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + } +} + +function mergeUsage(current: Chat.TokenUsage, next?: Partial | null): Chat.TokenUsage { + return { + prompt_tokens: current.prompt_tokens + Number(next?.prompt_tokens || 0), + completion_tokens: current.completion_tokens + Number(next?.completion_tokens || 0), + total_tokens: current.total_tokens + Number(next?.total_tokens || 0), + } +} + +function normalizeUsage(usage?: Partial | null): Chat.TokenUsage | null { + if (!usage) + return null + + return { + prompt_tokens: Number(usage.prompt_tokens || 0), + completion_tokens: Number(usage.completion_tokens || 0), + total_tokens: Number(usage.total_tokens || 0), + } +} + +function normalizeResponseMeta(data: Chat.ConversationResponse): { + source: Chat.ReplySource | null + tokenUsed?: boolean + usage: Chat.TokenUsage | null +} { + const usage = normalizeUsage(data.meta?.usage || data.usage || data.detail?.usage || null) + const rawSource = data.meta?.source ?? data.source ?? null + const source = rawSource === 'llm' ? 'llm' : rawSource ? 'semantic_match' : null + const tokenUsed = data.meta?.tokenUsed ?? data.tokenUsed ?? (usage ? usage.total_tokens > 0 : undefined) + + return { + source, + tokenUsed, + usage, + } +} + +function buildPromptUsage(usage: Chat.TokenUsage): Chat.TokenUsage { + return { + prompt_tokens: usage.prompt_tokens, + completion_tokens: 0, + total_tokens: usage.prompt_tokens, + } +} + +function buildCompletionUsage(usage: Chat.TokenUsage): Chat.TokenUsage { + return { + prompt_tokens: 0, + completion_tokens: usage.completion_tokens, + total_tokens: usage.completion_tokens, + } +} + +function findQuestionIndex(answerIndex: number) { + for (let current = answerIndex - 1; current >= 0; current -= 1) { + if (dataSources.value[current]?.inversion) + return current + } + + return -1 +} + +function updateMessageMeta(questionIndex: number, answerIndex: number, usage: Chat.TokenUsage, source: Chat.ReplySource | null, tokenUsed?: boolean) { + if (questionIndex >= 0) { + updateChatSome( + +uuid, + questionIndex, + { + messageMeta: { + tokenUsed, + usage: buildPromptUsage(usage), + }, + }, + ) + } + + updateChatSome( + +uuid, + answerIndex, + { + messageMeta: { + source, + tokenUsed, + usage: buildCompletionUsage(usage), + }, + }, + ) +} + // 未知原因刷新页面,loading 状态不会重置,手动重置 dataSources.value.forEach((item, index) => { if (item.loading) @@ -79,10 +177,13 @@ async function onConversation() { error: false, conversationOptions: null, requestOptions: { prompt: message, options: null }, + messageMeta: null, }, ) scrollToBottom() + const questionIndex = dataSources.value.length - 1 + loading.value = true prompt.value = '' @@ -102,13 +203,18 @@ async function onConversation() { error: false, conversationOptions: null, requestOptions: { prompt: message, options: { ...options } }, + messageMeta: null, }, ) scrollToBottom() + const answerIndex = dataSources.value.length - 1 + try { let lastText = '' + let accumulatedUsage = createEmptyUsage() const fetchChatAPIOnce = async () => { + let usageApplied = false await fetchChatAPIProcess({ prompt: message, options, @@ -122,22 +228,37 @@ async function onConversation() { if (lastIndex !== -1) chunk = responseText.substring(lastIndex) try { - const data = JSON.parse(chunk) + const data = JSON.parse(chunk.trim()) as Chat.ConversationResponse + const responseMeta = normalizeResponseMeta(data) + const nextUsage = responseMeta.usage && !usageApplied + ? mergeUsage(accumulatedUsage, responseMeta.usage) + : accumulatedUsage + updateChat( +uuid, - dataSources.value.length - 1, + answerIndex, { dateTime: new Date().toLocaleString(), text: lastText + data.text ?? '', inversion: false, error: false, loading: false, - conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id }, + conversationOptions: data.conversationId && data.id ? { conversationId: data.conversationId, parentMessageId: data.id } : null, requestOptions: { prompt: message, options: { ...options } }, + messageMeta: getChatByUuidAndIndex(+uuid, answerIndex)?.messageMeta ?? null, }, ) - if (openLongReply && data.detail.choices[0].finish_reason === 'length') { + if (responseMeta.source || responseMeta.usage || responseMeta.tokenUsed !== undefined) + updateMessageMeta(questionIndex, answerIndex, nextUsage, responseMeta.source, responseMeta.tokenUsed) + + if (responseMeta.usage && !usageApplied) { + accumulatedUsage = nextUsage + usageApplied = true + chatStore.addSessionUsageByUuid(+uuid, responseMeta.usage) + } + + if (openLongReply && data.detail?.choices?.[0]?.finish_reason === 'length' && data.id) { options.parentMessageId = data.id lastText = data.text message = '' @@ -161,7 +282,7 @@ async function onConversation() { if (error.message === 'canceled') { updateChatSome( +uuid, - dataSources.value.length - 1, + answerIndex, { loading: false, }, @@ -170,12 +291,12 @@ async function onConversation() { return } - const currentChat = getChatByUuidAndIndex(+uuid, dataSources.value.length - 1) + const currentChat = getChatByUuidAndIndex(+uuid, answerIndex) if (currentChat?.text && currentChat.text !== '') { updateChatSome( +uuid, - dataSources.value.length - 1, + answerIndex, { text: `${currentChat.text}\n[${errorMessage}]`, error: false, @@ -187,7 +308,7 @@ async function onConversation() { updateChat( +uuid, - dataSources.value.length - 1, + answerIndex, { dateTime: new Date().toLocaleString(), text: errorMessage, @@ -196,6 +317,7 @@ async function onConversation() { loading: false, conversationOptions: null, requestOptions: { prompt: message, options: { ...options } }, + messageMeta: getChatByUuidAndIndex(+uuid, answerIndex)?.messageMeta ?? null, }, ) scrollToBottomIfAtBottom() @@ -222,6 +344,9 @@ async function onRegenerate(index: number) { loading.value = true + let accumulatedUsage = createEmptyUsage() + const questionIndex = findQuestionIndex(index) + updateChat( +uuid, index, @@ -232,13 +357,15 @@ async function onRegenerate(index: number) { error: false, loading: true, conversationOptions: null, - requestOptions: { prompt: message, ...options }, + requestOptions: { prompt: message, options: { ...options } }, + messageMeta: null, }, ) try { let lastText = '' const fetchChatAPIOnce = async () => { + let usageApplied = false await fetchChatAPIProcess({ prompt: message, options, @@ -252,7 +379,12 @@ async function onRegenerate(index: number) { if (lastIndex !== -1) chunk = responseText.substring(lastIndex) try { - const data = JSON.parse(chunk) + const data = JSON.parse(chunk.trim()) as Chat.ConversationResponse + const responseMeta = normalizeResponseMeta(data) + const nextUsage = responseMeta.usage && !usageApplied + ? mergeUsage(accumulatedUsage, responseMeta.usage) + : accumulatedUsage + updateChat( +uuid, index, @@ -262,12 +394,22 @@ async function onRegenerate(index: number) { inversion: false, error: false, loading: false, - conversationOptions: { conversationId: data.conversationId, parentMessageId: data.id }, - requestOptions: { prompt: message, ...options }, + conversationOptions: data.conversationId && data.id ? { conversationId: data.conversationId, parentMessageId: data.id } : null, + requestOptions: { prompt: message, options: { ...options } }, + messageMeta: getChatByUuidAndIndex(+uuid, index)?.messageMeta ?? null, }, ) - if (openLongReply && data.detail.choices[0].finish_reason === 'length') { + if (responseMeta.source || responseMeta.usage || responseMeta.tokenUsed !== undefined) + updateMessageMeta(questionIndex, index, nextUsage, responseMeta.source, responseMeta.tokenUsed) + + if (responseMeta.usage && !usageApplied) { + accumulatedUsage = nextUsage + usageApplied = true + chatStore.addSessionUsageByUuid(+uuid, responseMeta.usage) + } + + if (openLongReply && data.detail?.choices?.[0]?.finish_reason === 'length' && data.id) { options.parentMessageId = data.id lastText = data.text message = '' @@ -306,7 +448,8 @@ async function onRegenerate(index: number) { error: true, loading: false, conversationOptions: null, - requestOptions: { prompt: message, ...options }, + requestOptions: { prompt: message, options: { ...options } }, + messageMeta: getChatByUuidAndIndex(+uuid, index)?.messageMeta ?? null, }, ) } @@ -483,6 +626,12 @@ onUnmounted(() => { class="w-full max-w-screen-xl m-auto dark:bg-[#101014]" :class="[isMobile ? 'p-2' : 'p-4']" > +
+
+ + {{ sessionTokenText }} +
+