feat: 项目初始化、完成基本流式传输和语音识别功能
This commit is contained in:
34
web/src/App.vue
Normal file
34
web/src/App.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { GlobalThemeOverrides } from "naive-ui"
|
||||
import { zhCN } from "naive-ui"
|
||||
import { useWebSocketStore } from "@/services"
|
||||
|
||||
const websocketStore = useWebSocketStore()
|
||||
|
||||
onMounted(() => {
|
||||
websocketStore.connect()
|
||||
})
|
||||
|
||||
const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
primaryColor: "#0094c5",
|
||||
primaryColorHover: "#00bfff",
|
||||
primaryColorPressed: "#007399",
|
||||
primaryColorSuppl: "#00bfff",
|
||||
fontWeightStrong: "600",
|
||||
borderRadius: "8px",
|
||||
borderRadiusSmall: "5px",
|
||||
},
|
||||
Button: {
|
||||
textColor: "#0094c5",
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :theme-overrides="themeOverrides" :locale="zhCN">
|
||||
<NMessageProvider :max="3">
|
||||
<RouterView />
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
4
web/src/assets/Icons/index.ts
Normal file
4
web/src/assets/Icons/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as ExclamationTriangleIcon } from "./svg/heroicons/ExclamationTriangleIcon.svg?component"
|
||||
export { default as microphone } from "./svg/heroicons/MicrophoneIcon.svg?component"
|
||||
export { default as PaperAirplaneIcon } from "./svg/heroicons/PaperAirplaneIcon.svg?component"
|
||||
export { default as TrashIcon } from "./svg/heroicons/TrashIcon.svg?component"
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
3
web/src/assets/Icons/svg/heroicons/MicrophoneIcon.svg
Normal file
3
web/src/assets/Icons/svg/heroicons/MicrophoneIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
3
web/src/assets/Icons/svg/heroicons/PaperAirplaneIcon.svg
Normal file
3
web/src/assets/Icons/svg/heroicons/PaperAirplaneIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
3
web/src/assets/Icons/svg/heroicons/TrashIcon.svg
Normal file
3
web/src/assets/Icons/svg/heroicons/TrashIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 612 B |
BIN
web/src/assets/logo.png
Normal file
BIN
web/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
1
web/src/assets/vue.svg
Normal file
1
web/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
web/src/components/markdown.vue
Normal file
41
web/src/components/markdown.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import hljs from "highlight.js"
|
||||
import markdownit from "markdown-it"
|
||||
|
||||
const { content } = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const md = markdownit({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight(str, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (__) { }
|
||||
}
|
||||
|
||||
return "" // use external default escaping
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-body w-full text-base break-words whitespace-normal" v-html="md.render(content)">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-body :deep(pre) {
|
||||
background: #fafafacc;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
24
web/src/interfaces/chat_service.ts
Normal file
24
web/src/interfaces/chat_service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface IChatWithLLMRequest {
|
||||
messages: Message[]
|
||||
/**
|
||||
* 要使用的模型的 ID
|
||||
*/
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
content?: string
|
||||
role?: string
|
||||
[property: string]: any
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
model_id: string
|
||||
model_name: string
|
||||
model_type: string
|
||||
}
|
||||
|
||||
export interface ModelListInfo {
|
||||
vendor: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
9
web/src/interfaces/index.ts
Normal file
9
web/src/interfaces/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface ICommonResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export type IMsgOnlyResponse = ICommonResponse<{ msg: string }>
|
||||
|
||||
export * from "./chat_service"
|
||||
39
web/src/layouts/BasicLayout.vue
Normal file
39
web/src/layouts/BasicLayout.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { NImage } from "naive-ui";
|
||||
import logo from "@/assets/logo.png";
|
||||
import { useChatStore } from "@/stores";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const { onlineCount } = storeToRefs(chatStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden">
|
||||
<div class="flex-none w-[200px] h-full flex flex-col">
|
||||
<router-link class="w-full my-6 cursor-pointer" to="/">
|
||||
<NImage class="w-full object-cover" :src="logo" alt="logo" :preview-disabled="true" />
|
||||
</router-link>
|
||||
<router-link
|
||||
class="w-full h-[52px] px-8 flex items-center cursor-pointer" :class="$route.path === '/'
|
||||
? [
|
||||
'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]',
|
||||
]
|
||||
: []
|
||||
" to="/"
|
||||
>
|
||||
聊天
|
||||
</router-link>
|
||||
|
||||
<div class="w-full h-full flex flex-col items-center text-[#0094c5]">
|
||||
<div class="flex-1 flex flex-col justify-end w-full">
|
||||
<div class="w-full p-8 font-bold">
|
||||
当前在线人数:{{ onlineCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 relative">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
14
web/src/main.ts
Normal file
14
web/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
80
web/src/router/index.ts
Normal file
80
web/src/router/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import BasicLayout from '@/layouts/BasicLayout.vue'
|
||||
import { resetDescription, setTitle } from '@/utils'
|
||||
import community from '@/views/CommunityView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: BasicLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'community',
|
||||
component: community,
|
||||
meta: {
|
||||
title: '社区',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// // 权限检查函数,检查并决定是否允许访问
|
||||
// const checkPermission: NavigationGuard = (to, from, next) => {
|
||||
// if (to.query.accessToken) return next(); // 如果有accessToken参数,则直接放行
|
||||
|
||||
// const userStore = useUserStore();
|
||||
// const token =
|
||||
// useUserStore().accessToken ||
|
||||
// localStorage.getItem("accessToken") ||
|
||||
// undefined;
|
||||
|
||||
// if (token) {
|
||||
// // 如果token无效,重定向到登录页,并提示信息
|
||||
// userStore.getUserInfo("router_get").then((hasPermission) => {
|
||||
// if (hasPermission) {
|
||||
// next(); // 如果有权限,继续导航
|
||||
// } else {
|
||||
// if (!to.meta.auth) {
|
||||
// next();
|
||||
// } else {
|
||||
// // 如果token无效,重定向到登录页,并提示信息
|
||||
// context.message?.info(
|
||||
// "登录信息已过期,请重新登录",
|
||||
// GotoLoginMessageOption
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// } else {
|
||||
// if (!to.meta.auth) {
|
||||
// next();
|
||||
// } else {
|
||||
// // 如果没有token,重定向到登录页,并提示信息
|
||||
// context.message?.info("请先登录", GotoLoginMessageOption);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// // 添加导航守卫
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
setTitle(to.meta.title as string)
|
||||
resetDescription()
|
||||
|
||||
// context.loadingBar?.start();
|
||||
// 在每个路由导航前执行权限检查
|
||||
// checkPermission(to, from, next);
|
||||
next()
|
||||
})
|
||||
|
||||
// router.afterEach(() => {
|
||||
// context.loadingBar?.finish();
|
||||
// });
|
||||
|
||||
export default router
|
||||
33
web/src/services/base_service.ts
Normal file
33
web/src/services/base_service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { context } from "@/utils";
|
||||
|
||||
const BaseClientService = axios.create();
|
||||
|
||||
// 添加请求拦截器
|
||||
BaseClientService.interceptors.request.use((config) => {
|
||||
// 在发送请求之前做些什么
|
||||
return config;
|
||||
}, (e) => {
|
||||
// 对请求错误做些什么
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
// 添加响应拦截器
|
||||
BaseClientService.interceptors.response.use((response) => {
|
||||
// 2xx 范围内的状态码都会触发该函数。
|
||||
// 对响应数据做点什么
|
||||
return response;
|
||||
}, (e) => {
|
||||
// “50”开头统一处理
|
||||
if (e.response?.status >= 500 && e.response?.status < 600) {
|
||||
context.message?.error("服务器开小差了,请稍后再试");
|
||||
return Promise.reject(e);
|
||||
}
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
/** 基础URL */
|
||||
export const BaseUrl = "/v1";
|
||||
|
||||
export default BaseClientService;
|
||||
91
web/src/services/chat_service.ts
Normal file
91
web/src/services/chat_service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { AxiosRequestConfig } from "axios"
|
||||
|
||||
import type { IChatWithLLMRequest } from "@/interfaces"
|
||||
import BaseClientService, { BaseUrl } from "./base_service.ts"
|
||||
|
||||
export class ChatService {
|
||||
public static basePath = BaseUrl
|
||||
|
||||
/** Chat with LLM */
|
||||
public static async ChatWithLLM(
|
||||
accessToken: string,
|
||||
request: IChatWithLLMRequest,
|
||||
onProgress: (content: string) => void,
|
||||
) {
|
||||
let response
|
||||
let buffer = ""
|
||||
let accumulatedContent = ""
|
||||
try {
|
||||
response = await fetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line unicorn/error-message
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader!.read()
|
||||
|
||||
if (done)
|
||||
break
|
||||
|
||||
// 将二进制数据转为字符串并存入缓冲区
|
||||
buffer += decoder.decode(value)
|
||||
|
||||
// 查找换行符分割数据
|
||||
const lines = buffer.split("\n")
|
||||
|
||||
// 保留未处理完的部分
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
// 处理每一行
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine)
|
||||
continue
|
||||
|
||||
if (trimmedLine.startsWith("data: ")) {
|
||||
const jsonStr = trimmedLine.slice(6)
|
||||
|
||||
// 处理结束标记
|
||||
if (jsonStr === "[DONE]") {
|
||||
onProgress(accumulatedContent) // 最终更新
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
if (data.choices?.[0]?.delta?.content) {
|
||||
// 累积内容
|
||||
accumulatedContent += data.choices[0].delta.content
|
||||
// 触发回调
|
||||
onProgress(accumulatedContent)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("JSON解析失败:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
public static GetModelList(config?: AxiosRequestConfig<any>) {
|
||||
return BaseClientService.get(`${this.basePath}/model/list`, config)
|
||||
}
|
||||
}
|
||||
3
web/src/services/index.ts
Normal file
3
web/src/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./base_service"
|
||||
export * from "./chat_service"
|
||||
export * from "./websocket"
|
||||
66
web/src/services/websocket.ts
Normal file
66
web/src/services/websocket.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useChatStore } from "@/stores";
|
||||
|
||||
// WebSocket
|
||||
export const useWebSocketStore = defineStore("websocket", () => {
|
||||
const websocket = ref<WebSocket>();
|
||||
const connected = ref(false);
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const { onlineCount } = storeToRefs(chatStore);
|
||||
|
||||
const onmessage = (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data);
|
||||
switch (data.type) {
|
||||
case "count":
|
||||
onlineCount.value = data.online_count;
|
||||
break;
|
||||
case "asr_result":
|
||||
chatStore.addMessageToHistory(data.result);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (data: string) => {
|
||||
if (websocket.value && websocket.value.readyState === WebSocket.OPEN)
|
||||
websocket.value?.send(data);
|
||||
};
|
||||
const close = () => {
|
||||
websocket.value?.close();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const url = "ws://127.0.0.1:8000/websocket";
|
||||
websocket.value = new WebSocket(url);
|
||||
|
||||
websocket.value.onopen = () => {
|
||||
connected.value = true;
|
||||
|
||||
let pingIntervalId: NodeJS.Timeout | undefined;
|
||||
|
||||
if (pingIntervalId)
|
||||
clearInterval(pingIntervalId);
|
||||
pingIntervalId = setInterval(() => send("ping"), 30 * 1000);
|
||||
|
||||
if (websocket.value) {
|
||||
websocket.value.onmessage = onmessage;
|
||||
|
||||
websocket.value.onerror = (e: Event) => {
|
||||
console.error(`WebSocket错误:${(e as ErrorEvent).message}`);
|
||||
};
|
||||
websocket.value.onclose = () => {
|
||||
connected.value = false;
|
||||
setTimeout(() => {
|
||||
connect(); // 尝试重新连接
|
||||
}, 1000); // 1秒后重试连接
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
websocket,
|
||||
connected,
|
||||
send,
|
||||
close,
|
||||
connect,
|
||||
};
|
||||
});
|
||||
154
web/src/stores/asr_store.ts
Normal file
154
web/src/stores/asr_store.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useWebSocketStore } from "@/services";
|
||||
import { convertToPCM16 } from "@/utils";
|
||||
|
||||
export const useAsrStore = defineStore("asr", () => {
|
||||
// 是否正在录音
|
||||
const isRecording = ref(false);
|
||||
// 识别结果消息列表
|
||||
const messages = ref<string[]>([]);
|
||||
// 音频相关对象
|
||||
let audioContext: AudioContext | null = null;
|
||||
let mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||||
let workletNode: AudioWorkletNode | null = null;
|
||||
|
||||
// 获取 WebSocket store 实例
|
||||
const webSocketStore = useWebSocketStore();
|
||||
|
||||
/**
|
||||
* 发送消息到 WebSocket
|
||||
* @param data 字符串或二进制数据
|
||||
*/
|
||||
const sendMessage = (data: string | Uint8Array) => {
|
||||
// 仅在连接已建立时发送
|
||||
if (webSocketStore.connected) {
|
||||
if (typeof data === "string") {
|
||||
webSocketStore.send(data);
|
||||
}
|
||||
else {
|
||||
webSocketStore.websocket?.send(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// AudioWorklet 处理器代码,作为字符串
|
||||
const audioProcessorCode = `
|
||||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0]
|
||||
if (input.length > 0) {
|
||||
const inputChannel = input[0]
|
||||
// 发送音频数据到主线程
|
||||
this.port.postMessage({
|
||||
type: 'audiodata',
|
||||
data: inputChannel
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
registerProcessor('audio-processor', AudioProcessor)
|
||||
`;
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value)
|
||||
return;
|
||||
messages.value = [];
|
||||
// 确保 WebSocket 已连接
|
||||
if (!webSocketStore.connected) {
|
||||
webSocketStore.connect();
|
||||
// 等待连接建立
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (webSocketStore.connected)
|
||||
resolve();
|
||||
else setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 获取麦克风音频流
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// 创建音频上下文,采样率16kHz
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({
|
||||
sampleRate: 16000,
|
||||
});
|
||||
// 用Blob方式创建AudioWorklet模块的URL
|
||||
const blob = new Blob([audioProcessorCode], { type: "application/javascript" });
|
||||
const processorUrl = URL.createObjectURL(blob);
|
||||
// 加载AudioWorklet模块
|
||||
await audioContext.audioWorklet.addModule(processorUrl);
|
||||
// 释放URL对象(防止内存泄漏)
|
||||
URL.revokeObjectURL(processorUrl);
|
||||
// 创建音频源节点
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||||
// 创建AudioWorkletNode
|
||||
workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
channelCount: 1,
|
||||
});
|
||||
// 监听来自AudioWorklet的音频数据
|
||||
workletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === "audiodata") {
|
||||
// 转换为16位PCM格式
|
||||
const pcmData = convertToPCM16(event.data.data);
|
||||
// 发送PCM数据到WebSocket
|
||||
sendMessage(pcmData);
|
||||
}
|
||||
};
|
||||
// 连接音频节点
|
||||
mediaStreamSource.connect(workletNode);
|
||||
workletNode.connect(audioContext.destination);
|
||||
isRecording.value = true;
|
||||
}
|
||||
catch (err) {
|
||||
// 麦克风权限失败或AudioWorklet加载失败
|
||||
console.error("需要麦克风权限才能录音", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止录音
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
if (!isRecording.value)
|
||||
return;
|
||||
|
||||
// 通知后端录音结束
|
||||
sendMessage(JSON.stringify({ type: "asr_end" }));
|
||||
|
||||
// 停止所有音轨
|
||||
if (mediaStreamSource?.mediaStream) {
|
||||
const tracks = mediaStreamSource.mediaStream.getTracks();
|
||||
tracks.forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// 断开音频节点
|
||||
workletNode?.disconnect();
|
||||
mediaStreamSource?.disconnect();
|
||||
setTimeout(() => {
|
||||
// TODO: 临时写法,这里的更新状态需要调整
|
||||
// 确保在停止录音后延迟更新状态,因为要等待LLM请求
|
||||
isRecording.value = false;
|
||||
}, 300);
|
||||
|
||||
// 释放音频资源
|
||||
audioContext?.close().then(() => {
|
||||
audioContext = null;
|
||||
mediaStreamSource = null;
|
||||
workletNode = null;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
messages,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
sendMessage,
|
||||
};
|
||||
});
|
||||
94
web/src/stores/chat_store.ts
Normal file
94
web/src/stores/chat_store.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo } from "@/interfaces";
|
||||
import { ChatService } from "@/services";
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
const token = ("sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee");
|
||||
// 默认模型
|
||||
const modelInfo = ref<ModelInfo | null>(null);
|
||||
// 历史消息
|
||||
const historyMessages = ref<IChatWithLLMRequest["messages"]>([]);
|
||||
// 是否正在响应
|
||||
const completing = ref<boolean>(false);
|
||||
// 在线人数
|
||||
const onlineCount = ref<number>(0);
|
||||
|
||||
// 与 LLM 聊天
|
||||
const chatWithLLM = async (
|
||||
request: IChatWithLLMRequest,
|
||||
onProgress: (content: string) => void, // 接收进度回调
|
||||
) => {
|
||||
if (completing.value)
|
||||
throw new Error("正在响应中");
|
||||
|
||||
completing.value = true; // 开始请求
|
||||
try {
|
||||
await ChatService.ChatWithLLM(token, request, (content) => {
|
||||
onProgress(content);
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error("请求失败:", error);
|
||||
}
|
||||
finally {
|
||||
completing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 添加消息到历史记录
|
||||
const addMessageToHistory = (message: string) => {
|
||||
const content = message.trim();
|
||||
if (!content)
|
||||
return;
|
||||
|
||||
historyMessages.value.push({
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
// 清除历史消息
|
||||
const clearHistoryMessages = () => {
|
||||
historyMessages.value = [];
|
||||
};
|
||||
|
||||
watch(historyMessages, (newVal) => {
|
||||
// 当历史消息变化时,发送请求
|
||||
if (newVal.length > 0) {
|
||||
const lastMessage = newVal[newVal.length - 1];
|
||||
if (lastMessage.role === "user" && modelInfo.value) {
|
||||
chatWithLLM({
|
||||
messages: newVal,
|
||||
model: modelInfo.value?.model_id,
|
||||
}, (content) => {
|
||||
// 处理进度回调
|
||||
if (
|
||||
historyMessages.value.length === 0
|
||||
|| historyMessages.value[historyMessages.value.length - 1].role !== "assistant"
|
||||
) {
|
||||
historyMessages.value.push({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
});
|
||||
}
|
||||
historyMessages.value[historyMessages.value.length - 1].content = content;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 模型列表
|
||||
const modelList = ref<ModelListInfo[]>([]);
|
||||
|
||||
// 获取模型列表
|
||||
const getModelList = async () => {
|
||||
try {
|
||||
const response = await ChatService.GetModelList();
|
||||
modelList.value = response.data.data;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("获取模型列表失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return { token, completing, chatWithLLM, historyMessages, addMessageToHistory, clearHistoryMessages, getModelList, modelList, modelInfo, onlineCount };
|
||||
});
|
||||
2
web/src/stores/index.ts
Normal file
2
web/src/stores/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./asr_store"
|
||||
export * from "./chat_store"
|
||||
5
web/src/stores/user_store.ts
Normal file
5
web/src/stores/user_store.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
return {
|
||||
|
||||
};
|
||||
});
|
||||
2
web/src/style.css
Normal file
2
web/src/style.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'highlight.js/styles/github.css';
|
||||
9
web/src/utils/context.ts
Normal file
9
web/src/utils/context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { LoadingBarApiInjection } from "naive-ui/es/loading-bar/src/LoadingBarProvider"
|
||||
import type { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider"
|
||||
import type { NotificationApiInjection } from "naive-ui/es/notification/src/NotificationProvider"
|
||||
|
||||
export const context: {
|
||||
message?: MessageApiInjection
|
||||
notification?: NotificationApiInjection
|
||||
loadingBar?: LoadingBarApiInjection
|
||||
} = {}
|
||||
6
web/src/utils/index.ts
Normal file
6
web/src/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./context"
|
||||
export * from "./media"
|
||||
export * from "./pcm"
|
||||
export * from "./title"
|
||||
export * from "./title"
|
||||
export * from "./url"
|
||||
43
web/src/utils/media.ts
Normal file
43
web/src/utils/media.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** 视窗匹配回调函数 */
|
||||
export const matchMedia = (
|
||||
type: "sm" | "md" | "lg" | string,
|
||||
matchFunc?: Function,
|
||||
mismatchFunc?: Function,
|
||||
) => {
|
||||
if (type === "sm") {
|
||||
if (window.matchMedia("(max-width: 767.98px)").matches) {
|
||||
/* 窗口小于或等于 */
|
||||
matchFunc?.()
|
||||
}
|
||||
else {
|
||||
mismatchFunc?.()
|
||||
}
|
||||
}
|
||||
else if (type === "md") {
|
||||
if (window.matchMedia("(max-width: 992px)").matches) {
|
||||
/* 窗口小于或等于 */
|
||||
matchFunc?.()
|
||||
}
|
||||
else {
|
||||
mismatchFunc?.()
|
||||
}
|
||||
}
|
||||
else if (type === "lg") {
|
||||
if (window.matchMedia("(max-width: 1200px)").matches) {
|
||||
/* 窗口小于或等于 */
|
||||
matchFunc?.()
|
||||
}
|
||||
else {
|
||||
mismatchFunc?.()
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (window.matchMedia(`(max-width: ${type}px)`).matches) {
|
||||
/* 窗口小于或等于 */
|
||||
matchFunc?.()
|
||||
}
|
||||
else {
|
||||
mismatchFunc?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
12
web/src/utils/pcm.ts
Normal file
12
web/src/utils/pcm.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const convertToPCM16 = (float32Array: Float32Array): Uint8Array => {
|
||||
const int16Buffer = new Int16Array(float32Array.length)
|
||||
for (let i = 0; i < float32Array.length; i++) {
|
||||
int16Buffer[i] = Math.max(-1, Math.min(1, float32Array[i])) * 0x7FFF
|
||||
}
|
||||
const buffer = new ArrayBuffer(int16Buffer.length * 2)
|
||||
const view = new DataView(buffer)
|
||||
for (let i = 0; i < int16Buffer.length; i++) {
|
||||
view.setInt16(i * 2, int16Buffer[i], true)
|
||||
}
|
||||
return new Uint8Array(buffer)
|
||||
}
|
||||
25
web/src/utils/title.ts
Normal file
25
web/src/utils/title.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useTitle } from "@vueuse/core"
|
||||
|
||||
const DEFAULT_TITLE = "Agent"
|
||||
|
||||
const DEFAULT_DESCRIPTION = document
|
||||
.querySelector("meta[name='description']")
|
||||
?.getAttribute("content")
|
||||
|
||||
export function setTitle(title?: string) {
|
||||
useTitle().value = (title ? `${title} | ` : "") + DEFAULT_TITLE
|
||||
}
|
||||
|
||||
export function resetDescription() {
|
||||
document
|
||||
.querySelector("meta[name='description']")
|
||||
?.setAttribute("content", DEFAULT_DESCRIPTION!)
|
||||
}
|
||||
|
||||
export function setDescription(description?: string) {
|
||||
if (!description)
|
||||
return
|
||||
document
|
||||
.querySelector("meta[name='description']")
|
||||
?.setAttribute("content", `${description} | ${DEFAULT_TITLE}`)
|
||||
}
|
||||
15
web/src/utils/url.ts
Normal file
15
web/src/utils/url.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** 直接当前页面跳转到指定url */
|
||||
export const jump = (url: string) => {
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
/** 在新标签页中跳转到指定url */
|
||||
export const jumpBlank = (url: string) => {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
/** 将对象转换为url查询字符串 */
|
||||
export const queryFormat = (query: Record<string, any>) => {
|
||||
const params = new URLSearchParams(query)
|
||||
return params.toString() ? `?${params}` : ""
|
||||
}
|
||||
130
web/src/views/CommunityView.vue
Normal file
130
web/src/views/CommunityView.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupOption, SelectOption } from "naive-ui";
|
||||
import { ExclamationTriangleIcon, microphone, PaperAirplaneIcon, TrashIcon } from "@/assets/Icons";
|
||||
import markdown from "@/components/markdown.vue";
|
||||
import { useAsrStore, useChatStore } from "@/stores";
|
||||
|
||||
const chatStore = useChatStore();
|
||||
const asrStore = useAsrStore();
|
||||
|
||||
const { historyMessages, completing, modelList, modelInfo } = storeToRefs(chatStore);
|
||||
const { isRecording } = storeToRefs(asrStore);
|
||||
|
||||
const inputData = ref("");
|
||||
|
||||
const options = ref<Array<SelectGroupOption | SelectOption>>([]);
|
||||
|
||||
// 处理选中模型的 ID
|
||||
const selectedModelId = computed({
|
||||
get: () => modelInfo.value?.model_id ?? null,
|
||||
set: (id: string | null) => {
|
||||
for (const vendor of modelList.value) {
|
||||
const found = vendor.models.find(model => model.model_id === id);
|
||||
if (found) {
|
||||
modelInfo.value = found;
|
||||
return;
|
||||
}
|
||||
}
|
||||
modelInfo.value = null;
|
||||
},
|
||||
});
|
||||
|
||||
// 监听模型列表变化,更新选项
|
||||
watch(() => modelList.value, (newVal) => {
|
||||
if (newVal) {
|
||||
options.value = newVal.map(vendor => ({
|
||||
type: "group",
|
||||
label: vendor.vendor,
|
||||
key: vendor.vendor,
|
||||
children: vendor.models.map(model => ({
|
||||
label: model.model_name,
|
||||
value: model.model_id,
|
||||
type: model.model_type,
|
||||
})),
|
||||
}));
|
||||
|
||||
if (newVal.length > 0 && newVal[0].models.length > 0) {
|
||||
modelInfo.value = newVal[0].models[0];
|
||||
}
|
||||
console.log("Options updated:", options.value);
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = () => {
|
||||
if (inputData.value.trim() === "")
|
||||
return;
|
||||
chatStore.addMessageToHistory(inputData.value);
|
||||
inputData.value = "";
|
||||
};
|
||||
|
||||
// 开关语音输入
|
||||
const toggleRecording = () => {
|
||||
if (isRecording.value) {
|
||||
asrStore.stopRecording();
|
||||
}
|
||||
else {
|
||||
asrStore.startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
chatStore.getModelList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 !pr-4 h-full w-full flex flex-col gap-4 border-l-[24px] border-l-[#FAFAFA] text-base">
|
||||
<NScrollbar class="flex-1 pr-4">
|
||||
<div class="flex items-start mb-4">
|
||||
<span class="text-base w-14 min-w-14">助手:</span>
|
||||
<NTag type="success" class="text-base max-w-full !h-auto">
|
||||
<span class="text-base">你好,我是你的智能助手,请问有什么可以帮助你的吗?</span>
|
||||
</NTag>
|
||||
</div>
|
||||
<div v-for="(msg, idx) in historyMessages" :key="idx" class="flex items-start mb-4">
|
||||
<span v-if="msg.role === 'user'" class="text-base w-14 min-w-14">你:</span>
|
||||
<span v-else class="text-base w-14 min-w-14">助手:</span>
|
||||
<NTag :type="msg.role === 'user' ? 'info' : 'success'" class="max-w-full !h-auto">
|
||||
<markdown :content="msg.content || ''" />
|
||||
</NTag>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
<NInput v-model:value="inputData" type="textarea" placeholder="在这里输入消息" @keyup.enter="handleSendMessage" />
|
||||
<!-- 操作区 -->
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<NSelect
|
||||
v-model:value="selectedModelId" label-field="label" value-field="value" children-field="children"
|
||||
filterable :options="options"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NPopconfirm
|
||||
:positive-button-props="{ type: 'error' }" positive-text="清除" negative-text="取消"
|
||||
@positive-click="chatStore.clearHistoryMessages" @negative-click="() => { }"
|
||||
>
|
||||
<template #icon>
|
||||
<ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" />
|
||||
</template>
|
||||
<template #trigger>
|
||||
<NButton :disabled="isRecording || completing" type="warning">
|
||||
清除历史
|
||||
<TrashIcon class="!w-4 !h-4 ml-1" />
|
||||
</NButton>
|
||||
</template>
|
||||
<span>确定要清除历史消息吗?</span>
|
||||
</NPopconfirm>
|
||||
|
||||
<NButton :disabled="completing" @click="toggleRecording">
|
||||
{{ isRecording ? "停止输入" : "语音输入" }}
|
||||
<microphone class="!w-4 !h-4 ml-1" />
|
||||
</NButton>
|
||||
<NButton :disabled="isRecording" :loading="completing" @click="handleSendMessage">
|
||||
发送
|
||||
<PaperAirplaneIcon class="!w-4 !h-4 ml-1" />
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
2
web/src/vite-env.d.ts
vendored
Normal file
2
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
Reference in New Issue
Block a user