feat: 引入prettier

This commit is contained in:
2025-06-29 08:32:17 +08:00
parent dfc817e3e3
commit cae0fe371b
29 changed files with 447 additions and 316 deletions

3
web/.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# .prettierignore
auto-imports.d.ts
components.d.ts

8
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"singleQuote": false,
"printWidth": 80,
"trailingComma": "none",
"ignorePath": ".prettierignore"
}

View File

@@ -1,39 +1,36 @@
import antfu from "@antfu/eslint-config" import antfu from "@antfu/eslint-config";
export default antfu( export default antfu({
{ formatters: {
formatters: { /**
/** * Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue * By default uses Prettier
* By default uses Prettier */
*/ css: true,
css: true, /**
/** * Format HTML files
* Format HTML files * By default uses Prettier
* By default uses Prettier */
*/ html: true,
html: true, /**
/** * Format Markdown files
* Format Markdown files * Supports Prettier and dprint
* Supports Prettier and dprint * By default uses Prettier
* By default uses Prettier */
*/ markdown: "prettier"
markdown: "prettier",
},
stylistic: {
indent: 2,
quotes: "double",
semi: true,
},
vue: true,
ignores: ["node_modules", "dist"],
rules: {
"vue/html-self-closing": "off",
"antfu/top-level-function": "off",
"ts/no-unsafe-function-type": "off",
"no-console": "off",
"unused-imports/no-unused-vars": "warn",
},
}, },
stylistic: {
) indent: 2,
quotes: "double",
semi: true
},
vue: true,
ignores: ["node_modules", "dist"],
rules: {
"vue/html-self-closing": "off",
"antfu/top-level-function": "off",
"ts/no-unsafe-function-type": "off",
"no-console": "off",
"unused-imports/no-unused-vars": "warn"
}
});

View File

@@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head>
<meta charset="UTF-8" /> <head>
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>chat</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head> <title>chat</title>
<body> </head>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script> <body>
</body> <div id="app"></div>
</html> <script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -34,8 +34,11 @@
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.29.0", "eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-format": "^1.0.1", "eslint-plugin-format": "^1.0.1",
"eslint-plugin-prettier": "^5.5.1",
"naive-ui": "^2.42.0", "naive-ui": "^2.42.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"unplugin-vue-components": "^0.28.0", "unplugin-vue-components": "^0.28.0",
"vite": "^7.0.0", "vite": "^7.0.0",

50
web/pnpm-lock.yaml generated
View File

@@ -72,12 +72,21 @@ importers:
eslint: eslint:
specifier: ^9.29.0 specifier: ^9.29.0
version: 9.29.0(jiti@2.4.2) version: 9.29.0(jiti@2.4.2)
eslint-config-prettier:
specifier: ^10.1.5
version: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-format: eslint-plugin-format:
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1(eslint@9.29.0(jiti@2.4.2)) version: 1.0.1(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-prettier:
specifier: ^5.5.1
version: 5.5.1(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.6.2)
naive-ui: naive-ui:
specifier: ^2.42.0 specifier: ^2.42.0
version: 2.42.0(vue@3.5.17(typescript@5.8.3)) version: 2.42.0(vue@3.5.17(typescript@5.8.3))
prettier:
specifier: ^3.6.2
version: 3.6.2
typescript: typescript:
specifier: ~5.8.3 specifier: ~5.8.3
version: 5.8.3 version: 5.8.3
@@ -1453,6 +1462,12 @@ packages:
peerDependencies: peerDependencies:
eslint: ^9.5.0 eslint: ^9.5.0
eslint-config-prettier@10.1.5:
resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-flat-config-utils@2.1.0: eslint-flat-config-utils@2.1.0:
resolution: {integrity: sha512-6fjOJ9tS0k28ketkUcQ+kKptB4dBZY2VijMZ9rGn8Cwnn1SH0cZBoPXT8AHBFHxmHcLFQK9zbELDinZ2Mr1rng==} resolution: {integrity: sha512-6fjOJ9tS0k28ketkUcQ+kKptB4dBZY2VijMZ9rGn8Cwnn1SH0cZBoPXT8AHBFHxmHcLFQK9zbELDinZ2Mr1rng==}
@@ -1544,6 +1559,20 @@ packages:
peerDependencies: peerDependencies:
eslint: ^9.0.0 eslint: ^9.0.0
eslint-plugin-prettier@5.5.1:
resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-regexp@2.9.0: eslint-plugin-regexp@2.9.0:
resolution: {integrity: sha512-9WqJMnOq8VlE/cK+YAo9C9YHhkOtcEtEk9d12a+H7OSZFwlpI6stiHmYPGa2VE0QhTzodJyhlyprUaXDZLgHBw==} resolution: {integrity: sha512-9WqJMnOq8VlE/cK+YAo9C9YHhkOtcEtEk9d12a+H7OSZFwlpI6stiHmYPGa2VE0QhTzodJyhlyprUaXDZLgHBw==}
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
@@ -2433,8 +2462,8 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
prettier@3.6.1: prettier@3.6.2:
resolution: {integrity: sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==} resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
@@ -4282,6 +4311,10 @@ snapshots:
'@eslint/compat': 1.3.1(eslint@9.29.0(jiti@2.4.2)) '@eslint/compat': 1.3.1(eslint@9.29.0(jiti@2.4.2))
eslint: 9.29.0(jiti@2.4.2) eslint: 9.29.0(jiti@2.4.2)
eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)):
dependencies:
eslint: 9.29.0(jiti@2.4.2)
eslint-flat-config-utils@2.1.0: eslint-flat-config-utils@2.1.0:
dependencies: dependencies:
pathe: 2.0.3 pathe: 2.0.3
@@ -4327,7 +4360,7 @@ snapshots:
eslint: 9.29.0(jiti@2.4.2) eslint: 9.29.0(jiti@2.4.2)
eslint-formatting-reporter: 0.0.0(eslint@9.29.0(jiti@2.4.2)) eslint-formatting-reporter: 0.0.0(eslint@9.29.0(jiti@2.4.2))
eslint-parser-plain: 0.1.1 eslint-parser-plain: 0.1.1
prettier: 3.6.1 prettier: 3.6.2
synckit: 0.9.3 synckit: 0.9.3
eslint-plugin-import-lite@0.3.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3): eslint-plugin-import-lite@0.3.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3):
@@ -4407,6 +4440,15 @@ snapshots:
tinyglobby: 0.2.14 tinyglobby: 0.2.14
yaml-eslint-parser: 1.3.0 yaml-eslint-parser: 1.3.0
eslint-plugin-prettier@5.5.1(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.6.2):
dependencies:
eslint: 9.29.0(jiti@2.4.2)
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.8
optionalDependencies:
eslint-config-prettier: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-regexp@2.9.0(eslint@9.29.0(jiti@2.4.2)): eslint-plugin-regexp@2.9.0(eslint@9.29.0(jiti@2.4.2)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2)) '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2))
@@ -5465,7 +5507,7 @@ snapshots:
dependencies: dependencies:
fast-diff: 1.3.0 fast-diff: 1.3.0
prettier@3.6.1: {} prettier@3.6.2: {}
pretty-ms@9.2.0: pretty-ms@9.2.0:
dependencies: dependencies:

View File

@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { GlobalThemeOverrides } from "naive-ui" import type { GlobalThemeOverrides } from "naive-ui";
import { zhCN } from "naive-ui" import { zhCN } from "naive-ui";
import { useWebSocketStore } from "@/services" import { useWebSocketStore } from "@/services";
const websocketStore = useWebSocketStore() const websocketStore = useWebSocketStore();
onMounted(() => { onMounted(() => {
websocketStore.connect() websocketStore.connect();
}) });
const themeOverrides: GlobalThemeOverrides = { const themeOverrides: GlobalThemeOverrides = {
common: { common: {
@@ -17,12 +17,12 @@ const themeOverrides: GlobalThemeOverrides = {
primaryColorSuppl: "#00bfff", primaryColorSuppl: "#00bfff",
fontWeightStrong: "600", fontWeightStrong: "600",
borderRadius: "8px", borderRadius: "8px",
borderRadiusSmall: "5px", borderRadiusSmall: "5px"
}, },
Button: { Button: {
textColor: "#0094c5", textColor: "#0094c5"
}, }
} };
</script> </script>
<template> <template>

View File

@@ -1,4 +1,4 @@
export { default as ExclamationTriangleIcon } from "./svg/heroicons/ExclamationTriangleIcon.svg?component" export { default as ExclamationTriangleIcon } from "./svg/heroicons/ExclamationTriangleIcon.svg?component";
export { default as microphone } from "./svg/heroicons/MicrophoneIcon.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 PaperAirplaneIcon } from "./svg/heroicons/PaperAirplaneIcon.svg?component";
export { default as TrashIcon } from "./svg/heroicons/TrashIcon.svg?component" export { default as TrashIcon } from "./svg/heroicons/TrashIcon.svg?component";

View File

@@ -6,8 +6,14 @@ const { avatar } = defineProps<{
<template> <template>
<NImage <NImage
:src="avatar" object-fit="cover" :preview-disabled="true" width="64" height="64" class="!block !w-16 !min-w-16 !h-16" :img-props="{ :src="avatar"
class: 'rounded-lg !block !w-16 !min-w-16 !h-16', width="64"
height="64"
:preview-disabled="true"
object-fit="cover"
class="!block !w-16 !min-w-16 !h-16"
:img-props="{
class: 'rounded-lg !block !w-16 !min-w-16 !h-16'
}" }"
/> />
</template> </template>

View File

@@ -17,13 +17,13 @@ const md = markdownit({
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
} catch (__) {
} }
// eslint-disable-next-line unused-imports/no-unused-vars
catch (__) { }
} }
return ""; // use external default escaping return ""; // use external default escaping
}, }
}); });
// // 计算代码块宽度 // // 计算代码块宽度
@@ -35,9 +35,9 @@ const codeWidth = computed(() => {
<template> <template>
<div <div
class="markdown-body w-full text-base break-words whitespace-normal" class="markdown-body w-full text-base break-words whitespace-normal"
:style="{ '--code-width': `${codeWidth}px` }" v-html="md.render(content)" :style="{ '--code-width': `${codeWidth}px` }"
> v-html="md.render(content)"
</div> ></div>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,9 +1,9 @@
export interface ICommonResponse<T> { export interface ICommonResponse<T> {
code: number code: number;
msg: string msg: string;
data: T data: T;
} }
export type IMsgOnlyResponse = ICommonResponse<{ msg: string }> export type IMsgOnlyResponse = ICommonResponse<{ msg: string }>;
export * from "./chat_service" export * from "./chat_service";

View File

@@ -11,15 +11,23 @@ const { onlineCount } = storeToRefs(chatStore);
<div class="h-screen flex overflow-hidden"> <div class="h-screen flex overflow-hidden">
<div class="flex-none w-[200px] h-full flex flex-col"> <div class="flex-none w-[200px] h-full flex flex-col">
<router-link class="w-full my-6 cursor-pointer" to="/"> <router-link class="w-full my-6 cursor-pointer" to="/">
<NImage class="w-full object-cover" :src="logo" alt="logo" :preview-disabled="true" /> <NImage
class="w-full object-cover"
:src="logo"
alt="logo"
:preview-disabled="true"
/>
</router-link> </router-link>
<router-link <router-link
class="w-full h-[52px] px-8 flex items-center cursor-pointer" :class="$route.path === '/' class="w-full h-[52px] px-8 flex items-center cursor-pointer"
? [ :class="
'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]', $route.path === '/'
] ? [
: [] 'bg-[rgba(37,99,235,0.04)] text-[#0094c5] border-r-2 border-[#0094c5]'
" to="/" ]
: []
"
to="/"
> >
聊天 聊天
</router-link> </router-link>

View File

@@ -1,14 +1,14 @@
import { createPinia } from 'pinia' import { createPinia } from "pinia";
import { createApp } from 'vue' import { createApp } from "vue";
import App from './App.vue' import App from "./App.vue";
import router from './router' import router from "./router";
import './style.css' import "./style.css";
const pinia = createPinia() const pinia = createPinia();
const app = createApp(App) const app = createApp(App);
app.use(pinia) app.use(pinia);
app.use(router) app.use(router);
app.mount('#app') app.mount("#app");

View File

@@ -1,28 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from "vue-router";
import BasicLayout from '@/layouts/BasicLayout.vue' import BasicLayout from "@/layouts/BasicLayout.vue";
import { resetDescription, setTitle } from '@/utils' import { resetDescription, setTitle } from "@/utils";
import community from '@/views/CommunityView.vue' import community from "@/views/CommunityView.vue";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: "/",
component: BasicLayout, component: BasicLayout,
children: [ children: [
{ {
path: '', path: "",
name: 'community', name: "community",
component: community, component: community,
meta: { meta: {
title: '社区', title: "社区"
}, }
}, }
], ]
}, }
], ]
}) });
// // 权限检查函数,检查并决定是否允许访问 // // 权限检查函数,检查并决定是否允许访问
// const checkPermission: NavigationGuard = (to, from, next) => { // const checkPermission: NavigationGuard = (to, from, next) => {
@@ -64,17 +64,17 @@ const router = createRouter({
// // 添加导航守卫 // // 添加导航守卫
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
setTitle(to.meta.title as string) setTitle(to.meta.title as string);
resetDescription() resetDescription();
// context.loadingBar?.start(); // context.loadingBar?.start();
// 在每个路由导航前执行权限检查 // 在每个路由导航前执行权限检查
// checkPermission(to, from, next); // checkPermission(to, from, next);
next() next();
}) });
// router.afterEach(() => { // router.afterEach(() => {
// context.loadingBar?.finish(); // context.loadingBar?.finish();
// }); // });
export default router export default router;

View File

@@ -5,27 +5,33 @@ import { context } from "@/utils";
const BaseClientService = axios.create(); const BaseClientService = axios.create();
// 添加请求拦截器 // 添加请求拦截器
BaseClientService.interceptors.request.use((config) => { BaseClientService.interceptors.request.use(
// 在发送请求之前做些什么 (config) => {
return config; // 在发送请求之前做些什么
}, (e) => { return config;
// 对请求错误做些什么 },
return Promise.reject(e); (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);
} }
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 */ /** 基础URL */
export const BaseUrl = "/v1"; export const BaseUrl = "/v1";

View File

@@ -11,7 +11,7 @@ export class ChatService {
accessToken: string, accessToken: string,
request: IChatWithLLMRequest, request: IChatWithLLMRequest,
onProgress: (content: string) => void, onProgress: (content: string) => void,
getUsageInfo: (object: UsageInfo) => void = () => { }, getUsageInfo: (object: UsageInfo) => void = () => {}
) { ) {
let response; let response;
let buffer = ""; let buffer = "";
@@ -20,10 +20,10 @@ export class ChatService {
response = await fetch("/v1/chat/completions", { response = await fetch("/v1/chat/completions", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json"
}, },
body: JSON.stringify(request), body: JSON.stringify(request)
}); });
if (!response.ok) { if (!response.ok) {
@@ -37,8 +37,7 @@ export class ChatService {
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);
@@ -52,8 +51,7 @@ export class ChatService {
// 处理每一行 // 处理每一行
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);
@@ -77,18 +75,21 @@ export class ChatService {
// 处理使用信息 // 处理使用信息
if (data.usage) { if (data.usage) {
const { prompt_tokens, completion_tokens, total_tokens } = data.usage; const { prompt_tokens, completion_tokens, total_tokens } =
getUsageInfo({ 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);
} }
} }

View File

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

View File

@@ -36,8 +36,7 @@ export const useWebSocketStore = defineStore("websocket", () => {
let pingIntervalId: NodeJS.Timeout | undefined; let pingIntervalId: NodeJS.Timeout | undefined;
if (pingIntervalId) if (pingIntervalId) clearInterval(pingIntervalId);
clearInterval(pingIntervalId);
pingIntervalId = setInterval(() => send("ping"), 30 * 1000); pingIntervalId = setInterval(() => send("ping"), 30 * 1000);
if (websocket.value) { if (websocket.value) {
@@ -61,6 +60,6 @@ export const useWebSocketStore = defineStore("websocket", () => {
connected, connected,
send, send,
close, close,
connect, connect
}; };
}); });

View File

@@ -23,8 +23,7 @@ export const useAsrStore = defineStore("asr", () => {
if (webSocketStore.connected) { if (webSocketStore.connected) {
if (typeof data === "string") { if (typeof data === "string") {
webSocketStore.send(data); webSocketStore.send(data);
} } else {
else {
webSocketStore.websocket?.send(data); webSocketStore.websocket?.send(data);
} }
} }
@@ -53,8 +52,7 @@ export const useAsrStore = defineStore("asr", () => {
* 开始录音 * 开始录音
*/ */
const startRecording = async () => { const startRecording = async () => {
if (isRecording.value) if (isRecording.value) return;
return;
messages.value = []; messages.value = [];
// 确保 WebSocket 已连接 // 确保 WebSocket 已连接
if (!webSocketStore.connected) { if (!webSocketStore.connected) {
@@ -62,8 +60,7 @@ export const useAsrStore = defineStore("asr", () => {
// 等待连接建立 // 等待连接建立
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const check = () => { const check = () => {
if (webSocketStore.connected) if (webSocketStore.connected) resolve();
resolve();
else setTimeout(check, 100); else setTimeout(check, 100);
}; };
check(); check();
@@ -73,11 +70,14 @@ export const useAsrStore = defineStore("asr", () => {
// 获取麦克风音频流 // 获取麦克风音频流
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 创建音频上下文采样率16kHz // 创建音频上下文采样率16kHz
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({ audioContext = new (window.AudioContext ||
sampleRate: 16000, (window as any).webkitAudioContext)({
sampleRate: 16000
}); });
// 用Blob方式创建AudioWorklet模块的URL // 用Blob方式创建AudioWorklet模块的URL
const blob = new Blob([audioProcessorCode], { type: "application/javascript" }); const blob = new Blob([audioProcessorCode], {
type: "application/javascript"
});
const processorUrl = URL.createObjectURL(blob); const processorUrl = URL.createObjectURL(blob);
// 加载AudioWorklet模块 // 加载AudioWorklet模块
await audioContext.audioWorklet.addModule(processorUrl); await audioContext.audioWorklet.addModule(processorUrl);
@@ -89,7 +89,7 @@ export const useAsrStore = defineStore("asr", () => {
workletNode = new AudioWorkletNode(audioContext, "audio-processor", { workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
numberOfInputs: 1, numberOfInputs: 1,
numberOfOutputs: 1, numberOfOutputs: 1,
channelCount: 1, channelCount: 1
}); });
// 监听来自AudioWorklet的音频数据 // 监听来自AudioWorklet的音频数据
workletNode.port.onmessage = (event) => { workletNode.port.onmessage = (event) => {
@@ -104,8 +104,7 @@ export const useAsrStore = defineStore("asr", () => {
mediaStreamSource.connect(workletNode); mediaStreamSource.connect(workletNode);
workletNode.connect(audioContext.destination); workletNode.connect(audioContext.destination);
isRecording.value = true; isRecording.value = true;
} } catch (err) {
catch (err) {
// 麦克风权限失败或AudioWorklet加载失败 // 麦克风权限失败或AudioWorklet加载失败
console.error("需要麦克风权限才能录音", err); console.error("需要麦克风权限才能录音", err);
} }
@@ -115,8 +114,7 @@ export const useAsrStore = defineStore("asr", () => {
* 停止录音 * 停止录音
*/ */
const stopRecording = () => { const stopRecording = () => {
if (!isRecording.value) if (!isRecording.value) return;
return;
// 通知后端录音结束 // 通知后端录音结束
sendMessage(JSON.stringify({ type: "asr_end" })); sendMessage(JSON.stringify({ type: "asr_end" }));
@@ -124,7 +122,7 @@ export const useAsrStore = defineStore("asr", () => {
// 停止所有音轨 // 停止所有音轨
if (mediaStreamSource?.mediaStream) { if (mediaStreamSource?.mediaStream) {
const tracks = mediaStreamSource.mediaStream.getTracks(); const tracks = mediaStreamSource.mediaStream.getTracks();
tracks.forEach(track => track.stop()); tracks.forEach((track) => track.stop());
} }
// 断开音频节点 // 断开音频节点
@@ -149,6 +147,6 @@ export const useAsrStore = defineStore("asr", () => {
messages, messages,
startRecording, startRecording,
stopRecording, stopRecording,
sendMessage, sendMessage
}; };
}); });

View File

@@ -1,8 +1,13 @@
import type { IChatWithLLMRequest, ModelInfo, ModelListInfo, UsageInfo } 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", () => {
const token = ("sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee"); const token = "sk-fkGVZBrAqvIxLjlF3b5f19EfBb63486c90Fa5a1fBd7076Ee";
// 默认模型 // 默认模型
const modelInfo = ref<ModelInfo | null>(null); const modelInfo = ref<ModelInfo | null>(null);
// 历史消息 // 历史消息
@@ -16,23 +21,25 @@ 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 = () => { }, getUsageInfo: (object: UsageInfo) => void = () => {}
) => { ) => {
if (completing.value) if (completing.value) throw new Error("正在响应中");
throw new Error("正在响应中");
completing.value = true; // 开始请求 completing.value = true; // 开始请求
try { try {
await ChatService.ChatWithLLM(token, request, (content) => { await ChatService.ChatWithLLM(
onProgress(content); token,
}, (object: UsageInfo) => { request,
getUsageInfo(object); (content) => {
}); onProgress(content);
} },
catch (error) { (object: UsageInfo) => {
getUsageInfo(object);
}
);
} catch (error) {
console.error("请求失败:", error); console.error("请求失败:", error);
} } finally {
finally {
completing.value = false; completing.value = false;
} }
}; };
@@ -40,12 +47,11 @@ export const useChatStore = defineStore("chat", () => {
// 添加消息到历史记录 // 添加消息到历史记录
const addMessageToHistory = (message: string) => { const addMessageToHistory = (message: string) => {
const content = message.trim(); const content = message.trim();
if (!content) if (!content) return;
return;
historyMessages.value.push({ historyMessages.value.push({
role: "user", role: "user",
content, content
}); });
}; };
@@ -54,39 +60,51 @@ export const useChatStore = defineStore("chat", () => {
historyMessages.value = []; historyMessages.value = [];
}; };
watch(historyMessages, (newVal) => { watch(
// 当历史消息变化时,发送请求 historyMessages,
if (newVal.length > 0) { (newVal) => {
const lastMessage = newVal[newVal.length - 1]; // 当历史消息变化时,发送请求
if (lastMessage.role === "user" && modelInfo.value) { if (newVal.length > 0) {
chatWithLLM({ const lastMessage = newVal[newVal.length - 1];
messages: newVal, if (lastMessage.role === "user" && modelInfo.value) {
model: modelInfo.value?.model_id, chatWithLLM(
}, (content) => { {
// 处理进度回调 messages: newVal,
if ( model: modelInfo.value?.model_id
historyMessages.value.length === 0 },
|| historyMessages.value[historyMessages.value.length - 1].role !== "assistant" (content) => {
) { // 处理进度回调
historyMessages.value.push({ if (
role: "assistant", historyMessages.value.length === 0 ||
content: "", historyMessages.value[historyMessages.value.length - 1].role !==
}); "assistant"
} ) {
historyMessages.value[historyMessages.value.length - 1].content = content; historyMessages.value.push({
}, (usageInfo: UsageInfo) => { role: "assistant",
// 处理使用usage信息回调 content: ""
// 如果最后一条消息是助手的回复,则更新使用信息 });
if ( }
historyMessages.value.length > 0 historyMessages.value[historyMessages.value.length - 1].content =
&& historyMessages.value[historyMessages.value.length - 1].role === "assistant" content;
) { },
historyMessages.value[historyMessages.value.length - 1].usage = usageInfo; (usageInfo: UsageInfo) => {
} // 处理使用usage信息回调
}); // 如果最后一条消息是助手的回复,则更新使用信息
if (
historyMessages.value.length > 0 &&
historyMessages.value[historyMessages.value.length - 1].role ===
"assistant"
) {
historyMessages.value[historyMessages.value.length - 1].usage =
usageInfo;
}
}
);
}
} }
} },
}, { deep: true }); { deep: true }
);
// 模型列表 // 模型列表
const modelList = ref<ModelListInfo[]>([]); const modelList = ref<ModelListInfo[]>([]);
@@ -96,11 +114,21 @@ export const useChatStore = defineStore("chat", () => {
try { try {
const response = await ChatService.GetModelList(); const response = await ChatService.GetModelList();
modelList.value = response.data.data; modelList.value = response.data.data;
} } catch (error) {
catch (error) {
console.error("获取模型列表失败:", error); console.error("获取模型列表失败:", error);
} }
}; };
return { token, completing, chatWithLLM, historyMessages, addMessageToHistory, clearHistoryMessages, getModelList, modelList, modelInfo, onlineCount }; return {
token,
completing,
chatWithLLM,
historyMessages,
addMessageToHistory,
clearHistoryMessages,
getModelList,
modelList,
modelInfo,
onlineCount
};
}); });

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import type { LoadingBarApiInjection } from "naive-ui/es/loading-bar/src/LoadingBarProvider" import type { LoadingBarApiInjection } from "naive-ui/es/loading-bar/src/LoadingBarProvider";
import type { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider" import type { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider";
import type { NotificationApiInjection } from "naive-ui/es/notification/src/NotificationProvider" import type { NotificationApiInjection } from "naive-ui/es/notification/src/NotificationProvider";
export const context: { export const context: {
message?: MessageApiInjection message?: MessageApiInjection;
notification?: NotificationApiInjection notification?: NotificationApiInjection;
loadingBar?: LoadingBarApiInjection loadingBar?: LoadingBarApiInjection;
} = {} } = {};

View File

@@ -1,6 +1,6 @@
export * from "./context" export * from "./context";
export * from "./media" export * from "./media";
export * from "./pcm" export * from "./pcm";
export * from "./title" export * from "./title";
export * from "./title" export * from "./title";
export * from "./url" export * from "./url";

View File

@@ -2,41 +2,34 @@
export const matchMedia = ( export const matchMedia = (
type: "sm" | "md" | "lg" | string, type: "sm" | "md" | "lg" | string,
matchFunc?: Function, matchFunc?: Function,
mismatchFunc?: Function, mismatchFunc?: Function
) => { ) => {
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?.();
} }
} }

View File

@@ -1,12 +1,12 @@
export const convertToPCM16 = (float32Array: Float32Array): Uint8Array => { export const convertToPCM16 = (float32Array: Float32Array): Uint8Array => {
const int16Buffer = new Int16Array(float32Array.length) const int16Buffer = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) { for (let i = 0; i < float32Array.length; i++) {
int16Buffer[i] = Math.max(-1, Math.min(1, float32Array[i])) * 0x7FFF int16Buffer[i] = Math.max(-1, Math.min(1, float32Array[i])) * 0x7FFF;
} }
const buffer = new ArrayBuffer(int16Buffer.length * 2) const buffer = new ArrayBuffer(int16Buffer.length * 2);
const view = new DataView(buffer) const view = new DataView(buffer);
for (let i = 0; i < int16Buffer.length; i++) { for (let i = 0; i < int16Buffer.length; i++) {
view.setInt16(i * 2, int16Buffer[i], true) view.setInt16(i * 2, int16Buffer[i], true);
} }
return new Uint8Array(buffer) return new Uint8Array(buffer);
} };

View File

@@ -1,25 +1,24 @@
import { useTitle } from "@vueuse/core" import { useTitle } from "@vueuse/core";
const DEFAULT_TITLE = "Agent" const DEFAULT_TITLE = "Agent";
const DEFAULT_DESCRIPTION = document const DEFAULT_DESCRIPTION = document
.querySelector("meta[name='description']") .querySelector("meta[name='description']")
?.getAttribute("content") ?.getAttribute("content");
export function setTitle(title?: string) { export function setTitle(title?: string) {
useTitle().value = (title ? `${title} | ` : "") + DEFAULT_TITLE useTitle().value = (title ? `${title} | ` : "") + DEFAULT_TITLE;
} }
export function resetDescription() { export function resetDescription() {
document document
.querySelector("meta[name='description']") .querySelector("meta[name='description']")
?.setAttribute("content", DEFAULT_DESCRIPTION!) ?.setAttribute("content", DEFAULT_DESCRIPTION!);
} }
export function setDescription(description?: string) { export function setDescription(description?: string) {
if (!description) if (!description) return;
return
document document
.querySelector("meta[name='description']") .querySelector("meta[name='description']")
?.setAttribute("content", `${description} | ${DEFAULT_TITLE}`) ?.setAttribute("content", `${description} | ${DEFAULT_TITLE}`);
} }

View File

@@ -1,15 +1,15 @@
/** 直接当前页面跳转到指定url */ /** 直接当前页面跳转到指定url */
export const jump = (url: string) => { export const jump = (url: string) => {
window.location.href = url window.location.href = url;
} };
/** 在新标签页中跳转到指定url */ /** 在新标签页中跳转到指定url */
export const jumpBlank = (url: string) => { export const jumpBlank = (url: string) => {
window.open(url, "_blank") window.open(url, "_blank");
} };
/** 将对象转换为url查询字符串 */ /** 将对象转换为url查询字符串 */
export const queryFormat = (query: Record<string, any>) => { export const queryFormat = (query: Record<string, any>) => {
const params = new URLSearchParams(query) const params = new URLSearchParams(query);
return params.toString() ? `?${params}` : "" return params.toString() ? `?${params}` : "";
} };

View File

@@ -2,7 +2,12 @@
import type { SelectGroupOption, SelectOption } from "naive-ui"; import type { SelectGroupOption, SelectOption } from "naive-ui";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import AIAvatar from "@/assets/ai_avatar.png"; import AIAvatar from "@/assets/ai_avatar.png";
import { ExclamationTriangleIcon, microphone, PaperAirplaneIcon, TrashIcon } from "@/assets/Icons"; import {
ExclamationTriangleIcon,
microphone,
PaperAirplaneIcon,
TrashIcon
} from "@/assets/Icons";
import UserAvatar from "@/assets/user_avatar.jpg"; import UserAvatar from "@/assets/user_avatar.jpg";
import markdown from "@/components/markdown.vue"; import markdown from "@/components/markdown.vue";
import { useAsrStore, useChatStore } from "@/stores"; import { useAsrStore, useChatStore } from "@/stores";
@@ -10,7 +15,8 @@ import { useAsrStore, useChatStore } from "@/stores";
const chatStore = useChatStore(); const chatStore = useChatStore();
const asrStore = useAsrStore(); const asrStore = useAsrStore();
const { historyMessages, completing, modelList, modelInfo } = storeToRefs(chatStore); const { historyMessages, completing, modelList, modelInfo } =
storeToRefs(chatStore);
const { isRecording } = storeToRefs(asrStore); const { isRecording } = storeToRefs(asrStore);
const inputData = ref(""); const inputData = ref("");
@@ -22,40 +28,43 @@ const selectedModelId = computed({
get: () => modelInfo.value?.model_id ?? null, get: () => modelInfo.value?.model_id ?? null,
set: (id: string | null) => { set: (id: string | null) => {
for (const vendor of modelList.value) { for (const vendor of modelList.value) {
const found = vendor.models.find(model => model.model_id === id); const found = vendor.models.find((model) => model.model_id === id);
if (found) { if (found) {
modelInfo.value = found; modelInfo.value = found;
return; return;
} }
} }
modelInfo.value = null; modelInfo.value = null;
}, }
}); });
// 监听模型列表变化,更新选项 // 监听模型列表变化,更新选项
watch(() => modelList.value, (newVal) => { watch(
if (newVal) { () => modelList.value,
options.value = newVal.map(vendor => ({ (newVal) => {
type: "group", if (newVal) {
label: vendor.vendor, options.value = newVal.map((vendor) => ({
key: vendor.vendor, type: "group",
children: vendor.models.map(model => ({ label: vendor.vendor,
label: model.model_name, key: vendor.vendor,
value: model.model_id, children: vendor.models.map((model) => ({
type: model.model_type, label: model.model_name,
})), value: model.model_id,
})); type: model.model_type
}))
}));
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];
}
} }
} },
}, { immediate: true, deep: true }); { immediate: true, deep: true }
);
// 发送消息 // 发送消息
const handleSendMessage = () => { const handleSendMessage = () => {
if (inputData.value.trim() === "") if (inputData.value.trim() === "") return;
return;
chatStore.addMessageToHistory(inputData.value); chatStore.addMessageToHistory(inputData.value);
inputData.value = ""; inputData.value = "";
}; };
@@ -64,8 +73,7 @@ const handleSendMessage = () => {
const toggleRecording = throttle(() => { const toggleRecording = throttle(() => {
if (isRecording.value) { if (isRecording.value) {
asrStore.stopRecording(); asrStore.stopRecording();
} } else {
else {
asrStore.startRecording(); asrStore.startRecording();
} }
}, 500); }, 500);
@@ -84,7 +92,9 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-8 !pr-4 h-full w-full flex flex-col gap-4 border-l-[24px] border-l-[#FAFAFA] text-base"> <div
class="p-8 !pr-4 h-full w-full flex flex-col gap-4 border-l-[24px] border-l-[#FAFAFA] text-base"
>
<!-- 历史消息区 --> <!-- 历史消息区 -->
<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">
@@ -93,20 +103,34 @@ 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 mb-4">助手</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
<span v-if="msg.role === 'user'" class="rounded-lg overflow-hidden !w-16 !min-w-16 !h-16"> 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 !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">
<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">{{ msg.role === 'user' ? '你:' : '助手:' }}</span> <span class="text-base font-bold">{{
<div v-if="msg.role !== 'user'" class="text-[12px] text-[#7A7A7A] mb-[2px]"> 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> Tokens: <span class="mr-1">{{ msg.usage?.total_tokens }}</span>
</div> </div>
<div class="w-full max-w-full"> <div class="w-full max-w-full">
@@ -124,23 +148,34 @@ onMounted(() => {
</NScrollbar> </NScrollbar>
<!-- 输入框 --> <!-- 输入框 -->
<NInput <NInput
v-model:value="inputData" type="textarea" placeholder="输入内容Enter发送Shift+Enter换行" :autosize="{ v-model:value="inputData"
type="textarea"
placeholder="输入内容Enter发送Shift+Enter换行"
:autosize="{
minRows: 3, minRows: 3,
maxRows: 15, maxRows: 15
}" @keyup.enter="handleSendMessage" }"
@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">
<NSelect <NSelect
v-model:value="selectedModelId" label-field="label" value-field="value" children-field="children" v-model:value="selectedModelId"
filterable :options="options" label-field="label"
value-field="value"
children-field="children"
filterable
:options="options"
/> />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NPopconfirm <NPopconfirm
:positive-button-props="{ type: 'error' }" positive-text="清除" negative-text="取消" :positive-button-props="{ type: 'error' }"
@positive-click="chatStore.clearHistoryMessages" @negative-click="() => { }" positive-text="清除"
negative-text="取消"
@positive-click="chatStore.clearHistoryMessages"
@negative-click="() => {}"
> >
<template #icon> <template #icon>
<ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" /> <ExclamationTriangleIcon class="!w-6 !h-6 text-[#d03050]" />
@@ -158,7 +193,11 @@ onMounted(() => {
{{ isRecording ? "停止输入" : "语音输入" }} {{ isRecording ? "停止输入" : "语音输入" }}
<microphone class="!w-4 !h-4 ml-1" /> <microphone class="!w-4 !h-4 ml-1" />
</NButton> </NButton>
<NButton :disabled="isRecording" :loading="completing" @click="handleSendMessage"> <NButton
:disabled="isRecording"
:loading="completing"
@click="handleSendMessage"
>
发送 发送
<PaperAirplaneIcon class="!w-4 !h-4 ml-1" /> <PaperAirplaneIcon class="!w-4 !h-4 ml-1" />
</NButton> </NButton>