feat: 项目初始化、完成基本流式传输和语音识别功能

This commit is contained in:
2025-06-28 19:21:46 +08:00
commit d6f9cd7aed
91 changed files with 7827 additions and 0 deletions

34
web/src/App.vue Normal file
View 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>

View 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"

View 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 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

1
web/src/assets/vue.svg Normal file
View 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

View 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>

View 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[]
}

View 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"

View 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
View 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
View 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

View 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;

View 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)
}
}

View File

@@ -0,0 +1,3 @@
export * from "./base_service"
export * from "./chat_service"
export * from "./websocket"

View 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
View 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,
};
});

View 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
View File

@@ -0,0 +1,2 @@
export * from "./asr_store"
export * from "./chat_store"

View File

@@ -0,0 +1,5 @@
export const useUserStore = defineStore("user", () => {
return {
};
});

2
web/src/style.css Normal file
View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import 'highlight.js/styles/github.css';

9
web/src/utils/context.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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}` : ""
}

View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />