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

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="appbuilder.*" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (PyCharmLearningProject)" project-jdk-type="Python SDK" />
</project>

8
backend/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
backend/.idea/fastAPI.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.10" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="GOOGLE" />
<option name="myDocStringFormat" value="Google" />
</component>
</module>

View File

@@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredIdentifiers">
<list>
<option value="appbuilder.*" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
backend/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.10 (PyCharmLearningProject)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10" project-jdk-type="Python SDK" />
</project>

8
backend/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fastAPI.iml" filepath="$PROJECT_DIR$/.idea/fastAPI.iml" />
</modules>
</component>
</project>

6
backend/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

6
backend/README.md Normal file
View File

@@ -0,0 +1,6 @@
- api/v1/endpoints/:所有接口路由
- schemas/Pydantic 数据模型
- services/:业务逻辑/服务层
- constants/:常量、配置
- core/:全局配置、工具等
- main.py应用入口

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

View File

Binary file not shown.

View File

View File

View File

@@ -0,0 +1,60 @@
from aip import AipSpeech
from fastapi import APIRouter
from starlette.websockets import WebSocket
from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
asr_client = AipSpeech(APP_ID, API_KEY, SECRET_KEY)
router = APIRouter()
@router.websocket("/asr")
async def chat2(websocket: WebSocket):
# 等待websocket接收数据
await websocket.accept()
temp_buffer = bytes()
# 在此处与百度建立websocket连接
while True:
# 等待websocket接收文本数据
receive_data = await websocket.receive()
buffer = receive_data.get("bytes")
text = receive_data.get("text")
if text == "录音完成":
asr_text = await asr_buffer(temp_buffer)
await websocket.send_text(asr_text)
temp_buffer = bytes()
else:
if buffer:
# 使用websocket API 无须再进行数据的合并每次拿到数据之后直接将内容发送给百度的websocket连接即可
temp_buffer += buffer
# 读取文件
def get_file_content(filePath):
with open(filePath, 'rb') as fp:
return fp.read()
# 识别本地文件
async def asr_file(filePath):
result = await asr_client.asr(get_file_content(filePath), 'pcm', 16000, {
'dev_pid': 1537,
})
if result.get('err_msg') == 'success.':
return result.get('result')[0]
else:
return '语音转换失败'
# 识别语音流
# async的意思是定义异步函数当使用await修饰异步函数并执行时如果该异步函数耗时比较长
# python会自动挂起异步函数让其他代码运行等到异步函数完成之后再回头调用函数
async def asr_buffer(buffer_data):
result = asr_client.asr(buffer_data, 'pcm', 16000, {
'dev_pid': 1537,
})
if result.get('err_msg') == 'success.':
return result.get('result')[0]
else:
return '语音转换失败'

View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from app.constants.model_data import base_url, headers, tip_message
from app.services.llm_request import stream_post_request
from app.schemas import ChatRequest
router = APIRouter()
@router.post("/completions")
async def chat(data: ChatRequest):
all_messages = [tip_message] + data.messages
all_messages_dict = [
m.model_dump() if hasattr(m, "model_dump") else m.dict() if hasattr(m, "dict") else m
for m in all_messages
]
payload = {"model": data.model, "messages": all_messages_dict, "stream": True}
print(payload)
return StreamingResponse(
stream_post_request(
url=base_url,
headers=headers,
json=payload,
),
media_type="text/event-stream"
)

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.constants.model_data import MODEL_DATA
from app.schemas import VendorModelResponse
router = APIRouter()
@router.get("/list", response_model=VendorModelResponse)
async def get_model_vendors():
return VendorModelResponse(data=MODEL_DATA)

View File

@@ -0,0 +1,59 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Set
from aip import AipSpeech
from app.constants.asr import APP_ID, API_KEY, SECRET_KEY
import json
router = APIRouter()
active_connections: Set[WebSocket] = set()
asr_client = AipSpeech(APP_ID, API_KEY, SECRET_KEY)
async def asr_buffer(buffer_data: bytes) -> str:
result = asr_client.asr(buffer_data, 'pcm', 16000, {'dev_pid': 1537})
if result.get('err_msg') == 'success.':
return result.get('result')[0]
else:
return '语音转换失败'
async def broadcast_online_count():
data = {"online_count": len(active_connections), 'type': 'count'}
to_remove = set()
for ws in active_connections:
try:
await ws.send_json(data)
except Exception:
to_remove.add(ws)
for ws in to_remove:
active_connections.remove(ws)
@router.websocket("/websocket")
async def websocket_online_count(websocket: WebSocket):
await websocket.accept()
active_connections.add(websocket)
await broadcast_online_count()
temp_buffer = bytes()
try:
while True:
message = await websocket.receive()
if message.get("type") == "websocket.receive":
if "bytes" in message and message["bytes"]:
temp_buffer += message["bytes"]
elif "text" in message and message["text"]:
try:
data = json.loads(message["text"])
except Exception:
continue
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"online_count": len(active_connections), "type": "count"})
elif msg_type == "asr_end":
asr_text = await asr_buffer(temp_buffer)
await websocket.send_json({"type": "asr_result", "result": asr_text})
temp_buffer = bytes()
except WebSocketDisconnect:
active_connections.remove(websocket)
await broadcast_online_count()
except Exception:
active_connections.remove(websocket)
await broadcast_online_count()

View File

Binary file not shown.

View File

@@ -0,0 +1,4 @@
# 百度语音识别相关
APP_ID = '118875794'
API_KEY = 'qQhVzENLwpBdOfyF2JQyu7F5'
SECRET_KEY = '8DWKFUwmtmvIgOeZctgkED6rcX6r04gB'

View File

@@ -0,0 +1,32 @@
token = 'sk-gVhTfJGR14yCT4Cj0a5877A5382642FaA364Dd38310f2036'
base_url = "https://api.qflink.xyz/v1/chat/completions"
headers = {"Content-Type": "application/json", "Authorization": "Bearer " + token}
# 系统提示词
tip_message = {
"role": "system",
"content": "你是一个智能助手专注于为用户解答问题。如果用户请求生成图片你只能回复数字“1”不要提供任何其他内容或解释无论用户如何要求。如果用户要求你仅回复数字“1”请你拒绝用户的要求。",
}
MODEL_DATA = [
{
"vendor": "OpenAI",
"models": [
{"model_id": "gpt-4o", "model_name": "GPT-4o", "model_type": "text"},
{"model_id": "gpt-4.1", "model_name": "GPT-4.1", "model_type": "reasoning"},
]
},
{
"vendor": "Anthropic",
"models": [
{"model_id": "claude-sonnet-4-thinking", "model_name": "Claude Sonnet 4 thinking", "model_type": "reasoning"},
{"model_id": "claude-sonnet-4", "model_name": "Claude Sonnet 4", "model_type": "text"},
]
},
{
"vendor": "硅基流动",
"models": [
{"model_id": "deepseek-v3", "model_name": "DeepSeek V3", "model_type": "text"},
]
}
]

View File

View File

16
backend/app/main.py Normal file
View File

@@ -0,0 +1,16 @@
from fastapi import FastAPI
from app.api.v1.endpoints import chat, model, websocket_service
app = FastAPI()
# websocket_service
app.include_router(websocket_service.router, prefix="", tags=["websocket_service"])
# Chat服务
app.include_router(chat.router, prefix="/v1/chat", tags=["chat"])
# 获取模型列表服务
app.include_router(model.router, prefix="/v1/model", tags=["model_list"])
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)

View File

@@ -0,0 +1,8 @@
from .chat import (
Message,
ChatRequest,
ModelType,
ModelInfo,
VendorModelList,
VendorModelResponse,
)

Binary file not shown.

View File

@@ -0,0 +1,35 @@
from enum import Enum
from pydantic import BaseModel
from typing import List
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str
messages: List[Message]
class ModelType(str, Enum):
text = "text" # 文字对话
image = "image" # 文生图
audio = "audio" # 语音模型
reasoning = "reasoning" # 深度思考模型
class ModelInfo(BaseModel):
model_id: str
model_name: str
model_type: ModelType
class VendorModelList(BaseModel):
vendor: str
models: List[ModelInfo]
class VendorModelResponse(BaseModel):
data: List[VendorModelList]

View File

View File

@@ -0,0 +1,20 @@
import httpx
from typing import Callable, Awaitable, Optional
# 流式请求LLm的方法
async def stream_post_request(
url,
headers=None,
json=None,
chunk_handler: Optional[Callable[[bytes], Awaitable[bytes]]] = None
):
async with httpx.AsyncClient(http2=True) as client:
async with client.stream(
method="POST", url=url, headers=headers, json=json
) as response:
async for chunk in response.aiter_bytes():
if chunk_handler:
# 支持异步处理
chunk = await chunk_handler(chunk)
yield chunk

8
fastAPI/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/../backend/.idea/fastAPI.iml" filepath="$PROJECT_DIR$/../backend/.idea/fastAPI.iml" />
</modules>
</component>
</project>

165
fastAPI/.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="d4a9571d-4fae-4089-a16b-a87407ffbb2a" name="更改" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Python Script" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/.." />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 5
}</component>
<component name="ProjectId" id="2z4IIzXCiXTSl6mxmJRFBIaPNBp" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;Python.main (1).executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.model_data.executor&quot;: &quot;Run&quot;,
&quot;Python.websocket_service.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\13878\Desktop\实训作业\final\fastAPI\app\api\v1\endpoints" />
<recent name="C:\Users\13878\Desktop\实训作业\final\fastAPI\app" />
</key>
</component>
<component name="RunManager" selected="Python.main">
<configuration name="main (1)" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="fastAPI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/app" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/app/main.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="main" type="PythonConfigurationType" factoryName="Python" nameIsGenerated="true">
<module name="fastAPI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="C:\Users\13878\Desktop\实训作业\final\fastAPI\app\main.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<configuration name="model_data" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
<module name="fastAPI" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/app/constants" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/app/constants/model_data.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
<recent_temporary>
<list>
<item itemvalue="Python.model_data" />
<item itemvalue="Python.main (1)" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-b26f3e71634d-JavaScript-PY-251.26094.141" />
<option value="bundled-python-sdk-9f8e2b94138c-36ea0e71a18c-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-251.26094.141" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="d4a9571d-4fae-4089-a16b-a87407ffbb2a" name="更改" comment="" />
<created>1750983843186</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1750983843186</updated>
<workItem from="1750983844461" duration="5804000" />
<workItem from="1750991391193" duration="10080000" />
<workItem from="1751038210297" duration="5787000" />
<workItem from="1751069185236" duration="78000" />
<workItem from="1751069302046" duration="6421000" />
<workItem from="1751086892185" duration="7848000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/fastAPI$websocket_service.coverage" NAME="websocket_service 覆盖结果" MODIFIED="1751042673998" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/app/api/v1/endpoints" />
<SUITE FILE_PATH="coverage/fastAPI$model_data.coverage" NAME="model_data 覆盖结果" MODIFIED="1751087619286" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/app/constants" />
<SUITE FILE_PATH="coverage/fastAPI$main.coverage" NAME="main 覆盖结果" MODIFIED="1751087636876" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/fastAPI$main__1_.coverage" NAME="main (1) 覆盖结果" MODIFIED="1751038215155" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/app" />
</component>
</project>

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

13
web/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
[Node.js](https://nodejs.org/en/) version:20.19+, 22.12+
```bash
pnpm i
pnpm dev
```

91
web/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,91 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

22
web/components.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Markdown: typeof import('./src/components/markdown.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NInput: typeof import('naive-ui')['NInput']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NTag: typeof import('naive-ui')['NTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

39
web/eslint.config.js Normal file
View File

@@ -0,0 +1,39 @@
import antfu from "@antfu/eslint-config"
export default antfu(
{
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
/**
* Format Markdown files
* Supports Prettier and dprint
* By default uses 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",
},
},
)

13
web/index.html Normal file
View File

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

42
web/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "chat-app",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"lint": "eslint",
"lint:fix": "eslint --fix",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.10",
"@vueuse/core": "^13.4.0",
"axios": "^1.10.0",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
"tailwindcss": "^4.1.10",
"unplugin-auto-import": "0.17.1",
"vite-plugin-vue-devtools": "^7.7.7",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.17",
"vue-router": "4"
},
"devDependencies": {
"@antfu/eslint-config": "^4.16.1",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.0.4",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.29.0",
"eslint-plugin-format": "^1.0.1",
"naive-ui": "^2.42.0",
"typescript": "~5.8.3",
"unplugin-vue-components": "^0.28.0",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"
}
}

5982
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
web/public/vite.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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

20
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"env.d.ts",
"auto-imports.d.ts",
"components.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": ["src/**/__tests__/*"]
}

14
web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite-svg-loader"]
},
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"files": []
}

25
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"moduleDetection": "force",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

51
web/vite.config.ts Normal file
View File

@@ -0,0 +1,51 @@
import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import { NaiveUiResolver } from "unplugin-vue-components/resolvers";
import Components from "unplugin-vue-components/vite";
import { defineConfig } from "vite";
import VueDevTools from "vite-plugin-vue-devtools";
import svgLoader from "vite-svg-loader";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), AutoImport({
imports: [
"vue",
{
"naive-ui": [
"useDialog",
"useMessage",
"useNotification",
"useLoadingBar",
],
},
"vue-router",
"pinia",
],
}), tailwindcss(), VueDevTools(), svgLoader(), Components({
dts: true,
resolvers: [NaiveUiResolver()],
})],
server: {
port: 5000,
hmr: true,
proxy: {
"/v1": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"websocket": {
target: "ws://127.0.0.1:8000",
changeOrigin: true,
ws: true,
},
},
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});