feat: 项目初始化、完成基本流式传输和语音识别功能
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||
12
backend/.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
||||
6
backend/.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
- api/v1/endpoints/:所有接口路由
|
||||
- schemas/:Pydantic 数据模型
|
||||
- services/:业务逻辑/服务层
|
||||
- constants/:常量、配置
|
||||
- core/:全局配置、工具等
|
||||
- main.py:应用入口
|
||||
0
backend/app/__init__.py
Normal file
BIN
backend/app/__pycache__/__init__.cpython-310.pyc
Normal file
0
backend/app/api/__init__.py
Normal file
BIN
backend/app/api/__pycache__/__init__.cpython-310.pyc
Normal file
0
backend/app/api/v1/__init__.py
Normal file
BIN
backend/app/api/v1/__pycache__/__init__.cpython-310.pyc
Normal file
0
backend/app/api/v1/endpoints/__init__.py
Normal file
BIN
backend/app/api/v1/endpoints/__pycache__/chat.cpython-310.pyc
Normal file
BIN
backend/app/api/v1/endpoints/__pycache__/model.cpython-310.pyc
Normal file
60
backend/app/api/v1/endpoints/asr.py
Normal 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 '语音转换失败'
|
||||
26
backend/app/api/v1/endpoints/chat.py
Normal 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"
|
||||
)
|
||||
9
backend/app/api/v1/endpoints/model.py
Normal 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)
|
||||
59
backend/app/api/v1/endpoints/websocket_service.py
Normal 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()
|
||||
0
backend/app/constants/__init__.py
Normal file
BIN
backend/app/constants/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/constants/__pycache__/asr.cpython-310.pyc
Normal file
BIN
backend/app/constants/__pycache__/model_data.cpython-310.pyc
Normal file
4
backend/app/constants/asr.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# 百度语音识别相关
|
||||
APP_ID = '118875794'
|
||||
API_KEY = 'qQhVzENLwpBdOfyF2JQyu7F5'
|
||||
SECRET_KEY = '8DWKFUwmtmvIgOeZctgkED6rcX6r04gB'
|
||||
32
backend/app/constants/model_data.py
Normal 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"},
|
||||
]
|
||||
}
|
||||
]
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/config.py
Normal file
16
backend/app/main.py
Normal 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)
|
||||
8
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .chat import (
|
||||
Message,
|
||||
ChatRequest,
|
||||
ModelType,
|
||||
ModelInfo,
|
||||
VendorModelList,
|
||||
VendorModelResponse,
|
||||
)
|
||||
BIN
backend/app/schemas/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/schemas/__pycache__/chat.cpython-310.pyc
Normal file
35
backend/app/schemas/chat.py
Normal 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]
|
||||
0
backend/app/services/__init__.py
Normal file
BIN
backend/app/services/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/app/services/__pycache__/llm_request.cpython-310.pyc
Normal file
20
backend/app/services/llm_request.py
Normal 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
@@ -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
@@ -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">{
|
||||
"associatedIndex": 5
|
||||
}</component>
|
||||
<component name="ProjectId" id="2z4IIzXCiXTSl6mxmJRFBIaPNBp" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"Python.main (1).executor": "Run",
|
||||
"Python.main.executor": "Run",
|
||||
"Python.model_data.executor": "Run",
|
||||
"Python.websocket_service.executor": "Run",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</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
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
13
web/README.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
1
web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="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
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { GlobalThemeOverrides } from "naive-ui"
|
||||
import { zhCN } from "naive-ui"
|
||||
import { useWebSocketStore } from "@/services"
|
||||
|
||||
const websocketStore = useWebSocketStore()
|
||||
|
||||
onMounted(() => {
|
||||
websocketStore.connect()
|
||||
})
|
||||
|
||||
const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
primaryColor: "#0094c5",
|
||||
primaryColorHover: "#00bfff",
|
||||
primaryColorPressed: "#007399",
|
||||
primaryColorSuppl: "#00bfff",
|
||||
fontWeightStrong: "600",
|
||||
borderRadius: "8px",
|
||||
borderRadiusSmall: "5px",
|
||||
},
|
||||
Button: {
|
||||
textColor: "#0094c5",
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :theme-overrides="themeOverrides" :locale="zhCN">
|
||||
<NMessageProvider :max="3">
|
||||
<RouterView />
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
4
web/src/assets/Icons/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as ExclamationTriangleIcon } from "./svg/heroicons/ExclamationTriangleIcon.svg?component"
|
||||
export { default as microphone } from "./svg/heroicons/MicrophoneIcon.svg?component"
|
||||
export { default as PaperAirplaneIcon } from "./svg/heroicons/PaperAirplaneIcon.svg?component"
|
||||
export { default as TrashIcon } from "./svg/heroicons/TrashIcon.svg?component"
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
3
web/src/assets/Icons/svg/heroicons/MicrophoneIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
3
web/src/assets/Icons/svg/heroicons/PaperAirplaneIcon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
3
web/src/assets/Icons/svg/heroicons/TrashIcon.svg
Normal file
@@ -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
|
After Width: | Height: | Size: 343 KiB |
1
web/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
web/src/components/markdown.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import hljs from "highlight.js"
|
||||
import markdownit from "markdown-it"
|
||||
|
||||
const { content } = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const md = markdownit({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight(str, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (__) { }
|
||||
}
|
||||
|
||||
return "" // use external default escaping
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-body w-full text-base break-words whitespace-normal" v-html="md.render(content)">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-body :deep(pre) {
|
||||
background: #fafafacc;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
24
web/src/interfaces/chat_service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface IChatWithLLMRequest {
|
||||
messages: Message[]
|
||||
/**
|
||||
* 要使用的模型的 ID
|
||||
*/
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
content?: string
|
||||
role?: string
|
||||
[property: string]: any
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
model_id: string
|
||||
model_name: string
|
||||
model_type: string
|
||||
}
|
||||
|
||||
export interface ModelListInfo {
|
||||
vendor: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
9
web/src/interfaces/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface ICommonResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export type IMsgOnlyResponse = ICommonResponse<{ msg: string }>
|
||||
|
||||
export * from "./chat_service"
|
||||
39
web/src/layouts/BasicLayout.vue
Normal file
@@ -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
@@ -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
@@ -0,0 +1,80 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import BasicLayout from '@/layouts/BasicLayout.vue'
|
||||
import { resetDescription, setTitle } from '@/utils'
|
||||
import community from '@/views/CommunityView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: BasicLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'community',
|
||||
component: community,
|
||||
meta: {
|
||||
title: '社区',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// // 权限检查函数,检查并决定是否允许访问
|
||||
// const checkPermission: NavigationGuard = (to, from, next) => {
|
||||
// if (to.query.accessToken) return next(); // 如果有accessToken参数,则直接放行
|
||||
|
||||
// const userStore = useUserStore();
|
||||
// const token =
|
||||
// useUserStore().accessToken ||
|
||||
// localStorage.getItem("accessToken") ||
|
||||
// undefined;
|
||||
|
||||
// if (token) {
|
||||
// // 如果token无效,重定向到登录页,并提示信息
|
||||
// userStore.getUserInfo("router_get").then((hasPermission) => {
|
||||
// if (hasPermission) {
|
||||
// next(); // 如果有权限,继续导航
|
||||
// } else {
|
||||
// if (!to.meta.auth) {
|
||||
// next();
|
||||
// } else {
|
||||
// // 如果token无效,重定向到登录页,并提示信息
|
||||
// context.message?.info(
|
||||
// "登录信息已过期,请重新登录",
|
||||
// GotoLoginMessageOption
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// } else {
|
||||
// if (!to.meta.auth) {
|
||||
// next();
|
||||
// } else {
|
||||
// // 如果没有token,重定向到登录页,并提示信息
|
||||
// context.message?.info("请先登录", GotoLoginMessageOption);
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// // 添加导航守卫
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
setTitle(to.meta.title as string)
|
||||
resetDescription()
|
||||
|
||||
// context.loadingBar?.start();
|
||||
// 在每个路由导航前执行权限检查
|
||||
// checkPermission(to, from, next);
|
||||
next()
|
||||
})
|
||||
|
||||
// router.afterEach(() => {
|
||||
// context.loadingBar?.finish();
|
||||
// });
|
||||
|
||||
export default router
|
||||
33
web/src/services/base_service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { context } from "@/utils";
|
||||
|
||||
const BaseClientService = axios.create();
|
||||
|
||||
// 添加请求拦截器
|
||||
BaseClientService.interceptors.request.use((config) => {
|
||||
// 在发送请求之前做些什么
|
||||
return config;
|
||||
}, (e) => {
|
||||
// 对请求错误做些什么
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
// 添加响应拦截器
|
||||
BaseClientService.interceptors.response.use((response) => {
|
||||
// 2xx 范围内的状态码都会触发该函数。
|
||||
// 对响应数据做点什么
|
||||
return response;
|
||||
}, (e) => {
|
||||
// “50”开头统一处理
|
||||
if (e.response?.status >= 500 && e.response?.status < 600) {
|
||||
context.message?.error("服务器开小差了,请稍后再试");
|
||||
return Promise.reject(e);
|
||||
}
|
||||
return Promise.reject(e);
|
||||
});
|
||||
|
||||
/** 基础URL */
|
||||
export const BaseUrl = "/v1";
|
||||
|
||||
export default BaseClientService;
|
||||
91
web/src/services/chat_service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { AxiosRequestConfig } from "axios"
|
||||
|
||||
import type { IChatWithLLMRequest } from "@/interfaces"
|
||||
import BaseClientService, { BaseUrl } from "./base_service.ts"
|
||||
|
||||
export class ChatService {
|
||||
public static basePath = BaseUrl
|
||||
|
||||
/** Chat with LLM */
|
||||
public static async ChatWithLLM(
|
||||
accessToken: string,
|
||||
request: IChatWithLLMRequest,
|
||||
onProgress: (content: string) => void,
|
||||
) {
|
||||
let response
|
||||
let buffer = ""
|
||||
let accumulatedContent = ""
|
||||
try {
|
||||
response = await fetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// eslint-disable-next-line unicorn/error-message
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader!.read()
|
||||
|
||||
if (done)
|
||||
break
|
||||
|
||||
// 将二进制数据转为字符串并存入缓冲区
|
||||
buffer += decoder.decode(value)
|
||||
|
||||
// 查找换行符分割数据
|
||||
const lines = buffer.split("\n")
|
||||
|
||||
// 保留未处理完的部分
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
// 处理每一行
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine)
|
||||
continue
|
||||
|
||||
if (trimmedLine.startsWith("data: ")) {
|
||||
const jsonStr = trimmedLine.slice(6)
|
||||
|
||||
// 处理结束标记
|
||||
if (jsonStr === "[DONE]") {
|
||||
onProgress(accumulatedContent) // 最终更新
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
if (data.choices?.[0]?.delta?.content) {
|
||||
// 累积内容
|
||||
accumulatedContent += data.choices[0].delta.content
|
||||
// 触发回调
|
||||
onProgress(accumulatedContent)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("JSON解析失败:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
public static GetModelList(config?: AxiosRequestConfig<any>) {
|
||||
return BaseClientService.get(`${this.basePath}/model/list`, config)
|
||||
}
|
||||
}
|
||||
3
web/src/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./base_service"
|
||||
export * from "./chat_service"
|
||||
export * from "./websocket"
|
||||
66
web/src/services/websocket.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useChatStore } from "@/stores";
|
||||
|
||||
// WebSocket
|
||||
export const useWebSocketStore = defineStore("websocket", () => {
|
||||
const websocket = ref<WebSocket>();
|
||||
const connected = ref(false);
|
||||
const chatStore = useChatStore();
|
||||
|
||||
const { onlineCount } = storeToRefs(chatStore);
|
||||
|
||||
const onmessage = (e: MessageEvent) => {
|
||||
const data = JSON.parse(e.data);
|
||||
switch (data.type) {
|
||||
case "count":
|
||||
onlineCount.value = data.online_count;
|
||||
break;
|
||||
case "asr_result":
|
||||
chatStore.addMessageToHistory(data.result);
|
||||
}
|
||||
};
|
||||
|
||||
const send = (data: string) => {
|
||||
if (websocket.value && websocket.value.readyState === WebSocket.OPEN)
|
||||
websocket.value?.send(data);
|
||||
};
|
||||
const close = () => {
|
||||
websocket.value?.close();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const url = "ws://127.0.0.1:8000/websocket";
|
||||
websocket.value = new WebSocket(url);
|
||||
|
||||
websocket.value.onopen = () => {
|
||||
connected.value = true;
|
||||
|
||||
let pingIntervalId: NodeJS.Timeout | undefined;
|
||||
|
||||
if (pingIntervalId)
|
||||
clearInterval(pingIntervalId);
|
||||
pingIntervalId = setInterval(() => send("ping"), 30 * 1000);
|
||||
|
||||
if (websocket.value) {
|
||||
websocket.value.onmessage = onmessage;
|
||||
|
||||
websocket.value.onerror = (e: Event) => {
|
||||
console.error(`WebSocket错误:${(e as ErrorEvent).message}`);
|
||||
};
|
||||
websocket.value.onclose = () => {
|
||||
connected.value = false;
|
||||
setTimeout(() => {
|
||||
connect(); // 尝试重新连接
|
||||
}, 1000); // 1秒后重试连接
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
websocket,
|
||||
connected,
|
||||
send,
|
||||
close,
|
||||
connect,
|
||||
};
|
||||
});
|
||||
154
web/src/stores/asr_store.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useWebSocketStore } from "@/services";
|
||||
import { convertToPCM16 } from "@/utils";
|
||||
|
||||
export const useAsrStore = defineStore("asr", () => {
|
||||
// 是否正在录音
|
||||
const isRecording = ref(false);
|
||||
// 识别结果消息列表
|
||||
const messages = ref<string[]>([]);
|
||||
// 音频相关对象
|
||||
let audioContext: AudioContext | null = null;
|
||||
let mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||||
let workletNode: AudioWorkletNode | null = null;
|
||||
|
||||
// 获取 WebSocket store 实例
|
||||
const webSocketStore = useWebSocketStore();
|
||||
|
||||
/**
|
||||
* 发送消息到 WebSocket
|
||||
* @param data 字符串或二进制数据
|
||||
*/
|
||||
const sendMessage = (data: string | Uint8Array) => {
|
||||
// 仅在连接已建立时发送
|
||||
if (webSocketStore.connected) {
|
||||
if (typeof data === "string") {
|
||||
webSocketStore.send(data);
|
||||
}
|
||||
else {
|
||||
webSocketStore.websocket?.send(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// AudioWorklet 处理器代码,作为字符串
|
||||
const audioProcessorCode = `
|
||||
class AudioProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0]
|
||||
if (input.length > 0) {
|
||||
const inputChannel = input[0]
|
||||
// 发送音频数据到主线程
|
||||
this.port.postMessage({
|
||||
type: 'audiodata',
|
||||
data: inputChannel
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
registerProcessor('audio-processor', AudioProcessor)
|
||||
`;
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (isRecording.value)
|
||||
return;
|
||||
messages.value = [];
|
||||
// 确保 WebSocket 已连接
|
||||
if (!webSocketStore.connected) {
|
||||
webSocketStore.connect();
|
||||
// 等待连接建立
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (webSocketStore.connected)
|
||||
resolve();
|
||||
else setTimeout(check, 100);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 获取麦克风音频流
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// 创建音频上下文,采样率16kHz
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)({
|
||||
sampleRate: 16000,
|
||||
});
|
||||
// 用Blob方式创建AudioWorklet模块的URL
|
||||
const blob = new Blob([audioProcessorCode], { type: "application/javascript" });
|
||||
const processorUrl = URL.createObjectURL(blob);
|
||||
// 加载AudioWorklet模块
|
||||
await audioContext.audioWorklet.addModule(processorUrl);
|
||||
// 释放URL对象(防止内存泄漏)
|
||||
URL.revokeObjectURL(processorUrl);
|
||||
// 创建音频源节点
|
||||
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||||
// 创建AudioWorkletNode
|
||||
workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
channelCount: 1,
|
||||
});
|
||||
// 监听来自AudioWorklet的音频数据
|
||||
workletNode.port.onmessage = (event) => {
|
||||
if (event.data.type === "audiodata") {
|
||||
// 转换为16位PCM格式
|
||||
const pcmData = convertToPCM16(event.data.data);
|
||||
// 发送PCM数据到WebSocket
|
||||
sendMessage(pcmData);
|
||||
}
|
||||
};
|
||||
// 连接音频节点
|
||||
mediaStreamSource.connect(workletNode);
|
||||
workletNode.connect(audioContext.destination);
|
||||
isRecording.value = true;
|
||||
}
|
||||
catch (err) {
|
||||
// 麦克风权限失败或AudioWorklet加载失败
|
||||
console.error("需要麦克风权限才能录音", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止录音
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
if (!isRecording.value)
|
||||
return;
|
||||
|
||||
// 通知后端录音结束
|
||||
sendMessage(JSON.stringify({ type: "asr_end" }));
|
||||
|
||||
// 停止所有音轨
|
||||
if (mediaStreamSource?.mediaStream) {
|
||||
const tracks = mediaStreamSource.mediaStream.getTracks();
|
||||
tracks.forEach(track => track.stop());
|
||||
}
|
||||
|
||||
// 断开音频节点
|
||||
workletNode?.disconnect();
|
||||
mediaStreamSource?.disconnect();
|
||||
setTimeout(() => {
|
||||
// TODO: 临时写法,这里的更新状态需要调整
|
||||
// 确保在停止录音后延迟更新状态,因为要等待LLM请求
|
||||
isRecording.value = false;
|
||||
}, 300);
|
||||
|
||||
// 释放音频资源
|
||||
audioContext?.close().then(() => {
|
||||
audioContext = null;
|
||||
mediaStreamSource = null;
|
||||
workletNode = null;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
messages,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
sendMessage,
|
||||
};
|
||||
});
|
||||
94
web/src/stores/chat_store.ts
Normal file
@@ -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
@@ -0,0 +1,2 @@
|
||||
export * from "./asr_store"
|
||||
export * from "./chat_store"
|
||||
5
web/src/stores/user_store.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
return {
|
||||
|
||||
};
|
||||
});
|
||||
2
web/src/style.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'highlight.js/styles/github.css';
|
||||
9
web/src/utils/context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { LoadingBarApiInjection } from "naive-ui/es/loading-bar/src/LoadingBarProvider"
|
||||
import type { MessageApiInjection } from "naive-ui/es/message/src/MessageProvider"
|
||||
import type { NotificationApiInjection } from "naive-ui/es/notification/src/NotificationProvider"
|
||||
|
||||
export const context: {
|
||||
message?: MessageApiInjection
|
||||
notification?: NotificationApiInjection
|
||||
loadingBar?: LoadingBarApiInjection
|
||||
} = {}
|
||||
6
web/src/utils/index.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,15 @@
|
||||
/** 直接当前页面跳转到指定url */
|
||||
export const jump = (url: string) => {
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
/** 在新标签页中跳转到指定url */
|
||||
export const jumpBlank = (url: string) => {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
/** 将对象转换为url查询字符串 */
|
||||
export const queryFormat = (query: Record<string, any>) => {
|
||||
const params = new URLSearchParams(query)
|
||||
return params.toString() ? `?${params}` : ""
|
||||
}
|
||||
130
web/src/views/CommunityView.vue
Normal file
@@ -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
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
20
web/tsconfig.app.json
Normal 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
@@ -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
@@ -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
@@ -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)),
|
||||
},
|
||||
},
|
||||
});
|
||||