feat: 一些优化,以及token消耗展示
This commit is contained in:
@@ -6,8 +6,8 @@ const { avatar } = defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NImage
|
<NImage
|
||||||
:src="avatar" object-fit="cover" :preview-disabled="true" width="60" height="60" class="!block" :img-props="{
|
:src="avatar" object-fit="cover" :preview-disabled="true" width="64" height="64" class="!block !w-16 !min-w-16 !h-16" :img-props="{
|
||||||
class: 'rounded-lg !block',
|
class: 'rounded-lg !block !w-16 !min-w-16 !h-16',
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import hljs from "highlight.js"
|
import hljs from "highlight.js";
|
||||||
import markdownit from "markdown-it"
|
import markdownit from "markdown-it";
|
||||||
|
import { useWindowWidth } from "@/utils";
|
||||||
|
|
||||||
const { content } = defineProps<{
|
const { content } = defineProps<{
|
||||||
content: string
|
content: string;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
|
const windowWidth = useWindowWidth();
|
||||||
|
|
||||||
const md = markdownit({
|
const md = markdownit({
|
||||||
html: true,
|
html: true,
|
||||||
@@ -13,24 +16,34 @@ const md = markdownit({
|
|||||||
highlight(str, lang) {
|
highlight(str, lang) {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
try {
|
try {
|
||||||
return hljs.highlight(str, { language: lang }).value
|
return hljs.highlight(str, { language: lang }).value;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
catch (__) { }
|
catch (__) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
return "" // use external default escaping
|
return ""; // use external default escaping
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// // 计算代码块宽度
|
||||||
|
const codeWidth = computed(() => {
|
||||||
|
return windowWidth.value - 160 - 200; // 减去左右边距和其他元素的宽度
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="markdown-body w-full text-base break-words whitespace-normal" v-html="md.render(content)">
|
<div
|
||||||
|
class="markdown-body w-full text-base break-words whitespace-normal"
|
||||||
|
:style="{ '--code-width': `${codeWidth}px` }" v-html="md.render(content)"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.markdown-body :deep(pre) {
|
.markdown-body :deep(pre) {
|
||||||
|
width: var(--code-width, 100%);
|
||||||
|
max-width: 100%;
|
||||||
background: #fafafacc;
|
background: #fafafacc;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -38,4 +51,20 @@ const md = markdownit({
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre)::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre)::-webkit-scrollbar-thumb {
|
||||||
|
background: #bfbfbf;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre)::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
export interface IChatWithLLMRequest {
|
export interface IChatWithLLMRequest {
|
||||||
messages: Message[]
|
messages: Message[];
|
||||||
/**
|
/**
|
||||||
* 要使用的模型的 ID
|
* 要使用的模型的 ID
|
||||||
*/
|
*/
|
||||||
model: string
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
content?: string
|
content?: string;
|
||||||
role?: string
|
role?: string;
|
||||||
[property: string]: any
|
usage?: UsageInfo;
|
||||||
|
[property: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
model_id: string
|
model_id: string;
|
||||||
model_name: string
|
model_name: string;
|
||||||
model_type: string
|
model_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelListInfo {
|
export interface ModelListInfo {
|
||||||
vendor: string
|
vendor: string;
|
||||||
models: ModelInfo[]
|
models: ModelInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageInfo {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import type { AxiosRequestConfig } from "axios"
|
import type { AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
import type { IChatWithLLMRequest } from "@/interfaces"
|
import type { IChatWithLLMRequest, UsageInfo } from "@/interfaces";
|
||||||
import BaseClientService, { BaseUrl } from "./base_service.ts"
|
import BaseClientService, { BaseUrl } from "./base_service.ts";
|
||||||
|
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
public static basePath = BaseUrl
|
public static basePath = BaseUrl;
|
||||||
|
|
||||||
/** Chat with LLM */
|
/** Chat with LLM */
|
||||||
public static async ChatWithLLM(
|
public static async ChatWithLLM(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
request: IChatWithLLMRequest,
|
request: IChatWithLLMRequest,
|
||||||
onProgress: (content: string) => void,
|
onProgress: (content: string) => void,
|
||||||
|
getUsageInfo: (object: UsageInfo) => void = () => { },
|
||||||
) {
|
) {
|
||||||
let response
|
let response;
|
||||||
let buffer = ""
|
let buffer = "";
|
||||||
let accumulatedContent = ""
|
let accumulatedContent = "";
|
||||||
try {
|
try {
|
||||||
response = await fetch("/v1/chat/completions", {
|
response = await fetch("/v1/chat/completions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -23,69 +24,77 @@ export class ChatService {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// eslint-disable-next-line unicorn/error-message
|
// eslint-disable-next-line unicorn/error-message
|
||||||
throw new Error()
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body?.getReader()
|
const reader = response.body?.getReader();
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader!.read()
|
const { done, value } = await reader!.read();
|
||||||
|
|
||||||
if (done)
|
if (done)
|
||||||
break
|
break;
|
||||||
|
|
||||||
// 将二进制数据转为字符串并存入缓冲区
|
// 将二进制数据转为字符串并存入缓冲区
|
||||||
buffer += decoder.decode(value)
|
buffer += decoder.decode(value);
|
||||||
|
|
||||||
// 查找换行符分割数据
|
// 查找换行符分割数据
|
||||||
const lines = buffer.split("\n")
|
const lines = buffer.split("\n");
|
||||||
|
|
||||||
// 保留未处理完的部分
|
// 保留未处理完的部分
|
||||||
buffer = lines.pop() || ""
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
// 处理每一行
|
// 处理每一行
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmedLine = line.trim()
|
const trimmedLine = line.trim();
|
||||||
if (!trimmedLine)
|
if (!trimmedLine)
|
||||||
continue
|
continue;
|
||||||
|
|
||||||
if (trimmedLine.startsWith("data: ")) {
|
if (trimmedLine.startsWith("data: ")) {
|
||||||
const jsonStr = trimmedLine.slice(6)
|
const jsonStr = trimmedLine.slice(6);
|
||||||
|
|
||||||
// 处理结束标记
|
// 处理结束标记
|
||||||
if (jsonStr === "[DONE]") {
|
if (jsonStr === "[DONE]") {
|
||||||
onProgress(accumulatedContent) // 最终更新
|
onProgress(accumulatedContent); // 最终更新
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonStr)
|
const data = JSON.parse(jsonStr);
|
||||||
|
|
||||||
|
// 处理内容
|
||||||
if (data.choices?.[0]?.delta?.content) {
|
if (data.choices?.[0]?.delta?.content) {
|
||||||
// 累积内容
|
// 累积内容
|
||||||
accumulatedContent += data.choices[0].delta.content
|
accumulatedContent += data.choices[0].delta.content;
|
||||||
// 触发回调
|
// 触发回调
|
||||||
onProgress(accumulatedContent)
|
onProgress(accumulatedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理使用信息
|
||||||
|
if (data.usage) {
|
||||||
|
const { prompt_tokens, completion_tokens, total_tokens } = data.usage;
|
||||||
|
getUsageInfo({ prompt_tokens, completion_tokens, total_tokens });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("JSON解析失败:", err)
|
console.error("JSON解析失败:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Error:", err)
|
console.error("Error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取模型列表
|
// 获取模型列表
|
||||||
public static GetModelList(config?: AxiosRequestConfig<any>) {
|
public static GetModelList(config?: AxiosRequestConfig<any>) {
|
||||||
return BaseClientService.get(`${this.basePath}/model/list`, config)
|
return BaseClientService.get(`${this.basePath}/model/list`, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo } from "@/interfaces";
|
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo, UsageInfo } from "@/interfaces";
|
||||||
import { ChatService } from "@/services";
|
import { ChatService } from "@/services";
|
||||||
|
|
||||||
export const useChatStore = defineStore("chat", () => {
|
export const useChatStore = defineStore("chat", () => {
|
||||||
@@ -16,6 +16,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
const chatWithLLM = async (
|
const chatWithLLM = async (
|
||||||
request: IChatWithLLMRequest,
|
request: IChatWithLLMRequest,
|
||||||
onProgress: (content: string) => void, // 接收进度回调
|
onProgress: (content: string) => void, // 接收进度回调
|
||||||
|
getUsageInfo: (object: UsageInfo) => void = () => { },
|
||||||
) => {
|
) => {
|
||||||
if (completing.value)
|
if (completing.value)
|
||||||
throw new Error("正在响应中");
|
throw new Error("正在响应中");
|
||||||
@@ -24,6 +25,8 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
try {
|
try {
|
||||||
await ChatService.ChatWithLLM(token, request, (content) => {
|
await ChatService.ChatWithLLM(token, request, (content) => {
|
||||||
onProgress(content);
|
onProgress(content);
|
||||||
|
}, (object: UsageInfo) => {
|
||||||
|
getUsageInfo(object);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -71,6 +74,15 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
historyMessages.value[historyMessages.value.length - 1].content = content;
|
historyMessages.value[historyMessages.value.length - 1].content = content;
|
||||||
|
}, (usageInfo: UsageInfo) => {
|
||||||
|
// 处理使用usage信息回调
|
||||||
|
// 如果最后一条消息是助手的回复,则更新使用信息
|
||||||
|
if (
|
||||||
|
historyMessages.value.length > 0
|
||||||
|
&& historyMessages.value[historyMessages.value.length - 1].role === "assistant"
|
||||||
|
) {
|
||||||
|
historyMessages.value[historyMessages.value.length - 1].usage = usageInfo;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./asr_store"
|
export * from "./asr_store";
|
||||||
export * from "./chat_store"
|
export * from "./chat_store";
|
||||||
|
|||||||
@@ -7,37 +7,55 @@ export const matchMedia = (
|
|||||||
if (type === "sm") {
|
if (type === "sm") {
|
||||||
if (window.matchMedia("(max-width: 767.98px)").matches) {
|
if (window.matchMedia("(max-width: 767.98px)").matches) {
|
||||||
/* 窗口小于或等于 */
|
/* 窗口小于或等于 */
|
||||||
matchFunc?.()
|
matchFunc?.();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
mismatchFunc?.()
|
mismatchFunc?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (type === "md") {
|
else if (type === "md") {
|
||||||
if (window.matchMedia("(max-width: 992px)").matches) {
|
if (window.matchMedia("(max-width: 992px)").matches) {
|
||||||
/* 窗口小于或等于 */
|
/* 窗口小于或等于 */
|
||||||
matchFunc?.()
|
matchFunc?.();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
mismatchFunc?.()
|
mismatchFunc?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (type === "lg") {
|
else if (type === "lg") {
|
||||||
if (window.matchMedia("(max-width: 1200px)").matches) {
|
if (window.matchMedia("(max-width: 1200px)").matches) {
|
||||||
/* 窗口小于或等于 */
|
/* 窗口小于或等于 */
|
||||||
matchFunc?.()
|
matchFunc?.();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
mismatchFunc?.()
|
mismatchFunc?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (window.matchMedia(`(max-width: ${type}px)`).matches) {
|
if (window.matchMedia(`(max-width: ${type}px)`).matches) {
|
||||||
/* 窗口小于或等于 */
|
/* 窗口小于或等于 */
|
||||||
matchFunc?.()
|
matchFunc?.();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
mismatchFunc?.()
|
mismatchFunc?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export const useWindowWidth = () => {
|
||||||
|
const width = ref(window.innerWidth);
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
width.value = window.innerWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("resize", updateWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", updateWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ watch(() => modelList.value, (newVal) => {
|
|||||||
if (newVal.length > 0 && newVal[0].models.length > 0) {
|
if (newVal.length > 0 && newVal[0].models.length > 0) {
|
||||||
modelInfo.value = newVal[0].models[0];
|
modelInfo.value = newVal[0].models[0];
|
||||||
}
|
}
|
||||||
console.log("Options updated:", options.value);
|
|
||||||
}
|
}
|
||||||
}, { immediate: true, deep: true });
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
@@ -89,17 +88,17 @@ onMounted(() => {
|
|||||||
<!-- 历史消息区 -->
|
<!-- 历史消息区 -->
|
||||||
<NScrollbar ref="scrollbarRef" class="flex-1 pr-4 relative">
|
<NScrollbar ref="scrollbarRef" class="flex-1 pr-4 relative">
|
||||||
<div class="flex items-start mb-4">
|
<div class="flex items-start mb-4">
|
||||||
<span class="rounded-lg overflow-hidden">
|
<span class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16">
|
||||||
<avatar :avatar="AIAvatar" />
|
<avatar :avatar="AIAvatar" />
|
||||||
</span>
|
</span>
|
||||||
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
||||||
<span class="text-base font-bold">助手:</span>
|
<span class="text-base font-bold mb-4">助手:</span>
|
||||||
<span class="text-base">你好,我是你的智能助手,请问有什么可以帮助你的吗?</span>
|
<span class="text-base">你好,我是你的智能助手,请问有什么可以帮助你的吗?</span>
|
||||||
<NDivider />
|
<NDivider />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="(msg, idx) in historyMessages" :key="idx" class="flex items-start mb-4">
|
<div v-for="(msg, idx) in historyMessages" :key="idx" class="flex items-start mb-4">
|
||||||
<span v-if="msg.role === 'user'" class="rounded-lg overflow-hidden">
|
<span v-if="msg.role === 'user'" class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16">
|
||||||
<avatar :avatar="UserAvatar" />
|
<avatar :avatar="UserAvatar" />
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="rounded-lg overflow-hidden">
|
<span v-else class="rounded-lg overflow-hidden">
|
||||||
@@ -107,6 +106,9 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
<div class="text-base w-full max-w-full ml-2 flex flex-col items-start">
|
||||||
<span class="text-base font-bold">{{ msg.role === 'user' ? '你:' : '助手:' }}</span>
|
<span class="text-base font-bold">{{ msg.role === 'user' ? '你:' : '助手:' }}</span>
|
||||||
|
<div v-if="msg.role !== 'user'" class="text-[12px] text-[#7A7A7A] mb-[2px]">
|
||||||
|
Tokens: <span class="mr-1">{{ msg.usage?.total_tokens }}</span>
|
||||||
|
</div>
|
||||||
<div class="w-full max-w-full">
|
<div class="w-full max-w-full">
|
||||||
<markdown :content="msg.content || ''" />
|
<markdown :content="msg.content || ''" />
|
||||||
<NDivider />
|
<NDivider />
|
||||||
@@ -121,7 +123,12 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
<!-- 输入框 -->
|
<!-- 输入框 -->
|
||||||
<NInput v-model:value="inputData" type="textarea" placeholder="在这里输入消息" @keyup.enter="handleSendMessage" />
|
<NInput
|
||||||
|
v-model:value="inputData" type="textarea" placeholder="输入内容,Enter发送,Shift+Enter换行" :autosize="{
|
||||||
|
minRows: 3,
|
||||||
|
maxRows: 15,
|
||||||
|
}" @keyup.enter="handleSendMessage"
|
||||||
|
/>
|
||||||
<!-- 操作区 -->
|
<!-- 操作区 -->
|
||||||
<div class="flex justify-between items-center gap-2">
|
<div class="flex justify-between items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user