Initial commit: AI Video Assistant fullstack platform.
Add pipecat-based backend with WebRTC/WS voice routes, Next.js frontend, and Docker Compose orchestration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
64
backend/services/config_resolver.py
Normal file
64
backend/services/config_resolver.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""assistant_id → 运行时配置(把真 key 在服务端组装好)。
|
||||
|
||||
浏览器只传 assistant_id;真 key 在这里从 provider_credentials 取出注入。
|
||||
取不到凭证记录时,降级用 .env 默认值(开发期零配置仍能跑)。
|
||||
"""
|
||||
|
||||
import config
|
||||
from db.models import Assistant, ProviderCredential
|
||||
from models import AssistantConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _get_credential(
|
||||
session: AsyncSession, type_: str, name: str = ""
|
||||
) -> ProviderCredential | None:
|
||||
"""取某类(LLM/ASR/TTS)凭证:优先按资源名匹配,否则取该类默认。"""
|
||||
stmt = select(ProviderCredential).where(ProviderCredential.type == type_)
|
||||
if name:
|
||||
# 助手按资源名引用(如 model="DeepSeek-V3");命中则用它
|
||||
named = (
|
||||
await session.execute(stmt.where(ProviderCredential.name == name).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if named:
|
||||
return named
|
||||
stmt = stmt.order_by(
|
||||
ProviderCredential.is_default.desc(), ProviderCredential.id.asc()
|
||||
).limit(1)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def resolve_runtime_config(
|
||||
session: AsyncSession, assistant_id: str
|
||||
) -> AssistantConfig:
|
||||
"""加载助手 + 解析凭证,产出可直接交给管线的运行时配置(含真 key)。
|
||||
|
||||
type 映射:LLM→大模型, ASR→语音识别, TTS→语音合成。
|
||||
"""
|
||||
assistant = await session.get(Assistant, assistant_id)
|
||||
if assistant is None:
|
||||
raise ValueError(f"助手不存在: {assistant_id}")
|
||||
|
||||
llm = await _get_credential(session, "LLM", assistant.model)
|
||||
stt = await _get_credential(session, "ASR", assistant.asr)
|
||||
tts = await _get_credential(session, "TTS")
|
||||
|
||||
return AssistantConfig(
|
||||
name=assistant.name,
|
||||
greeting=assistant.greeting,
|
||||
prompt=assistant.prompt,
|
||||
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||
enableInterrupt=assistant.enable_interrupt,
|
||||
# 模型/音色:凭证的模型ID优先,否则助手里填的
|
||||
model=(llm.model_id if llm else assistant.model),
|
||||
asr=(stt.model_id if stt else assistant.asr),
|
||||
voice=assistant.voice,
|
||||
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
|
||||
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),
|
||||
llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL),
|
||||
stt_api_key=(stt.api_key if stt else config.STT_API_KEY),
|
||||
stt_base_url=(stt.api_url if stt else config.STT_BASE_URL),
|
||||
tts_api_key=(tts.api_key if tts else config.TTS_API_KEY),
|
||||
tts_base_url=(tts.api_url if tts else config.TTS_BASE_URL),
|
||||
)
|
||||
27
backend/services/masking.py
Normal file
27
backend/services/masking.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""API Key 打码 / 写时哨兵(抄 dograh masking.py + merge.py 思路)。
|
||||
|
||||
- mask:返回前端时把真 key 变成 sk-****1234,真 key 永不出后端
|
||||
- is_masked:判断前端回传的是不是打码占位符
|
||||
- resolve_incoming_key:前端回传若是占位符 → 保留旧值;否则用新值
|
||||
"""
|
||||
|
||||
MASK_VISIBLE_TAIL = 4
|
||||
|
||||
|
||||
def mask(api_key: str) -> str:
|
||||
if not api_key:
|
||||
return ""
|
||||
if len(api_key) <= MASK_VISIBLE_TAIL:
|
||||
return "****"
|
||||
return f"{api_key[:2]}****{api_key[-MASK_VISIBLE_TAIL:]}"
|
||||
|
||||
|
||||
def is_masked(value: str) -> bool:
|
||||
return "****" in (value or "")
|
||||
|
||||
|
||||
def resolve_incoming_key(incoming: str | None, stored: str) -> str:
|
||||
"""写入时决定最终 key:占位符/空 → 保留旧;否则用新。"""
|
||||
if incoming is None or incoming == "" or is_masked(incoming):
|
||||
return stored
|
||||
return incoming
|
||||
0
backend/services/pipecat/__init__.py
Normal file
0
backend/services/pipecat/__init__.py
Normal file
67
backend/services/pipecat/pipeline.py
Normal file
67
backend/services/pipecat/pipeline.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""管线核心:给定一个 transport + 配置,跑完整的语音闭环。
|
||||
|
||||
关键设计:**transport 由调用方传入**,管线本身不关心是 WebRTC 还是 WS。
|
||||
这就是"同时支持多种输出"的落点——加输出方式不用动这里。
|
||||
|
||||
对应 dograh 的 pipeline_builder.py + run_pipeline.py(已砍掉 workflow 引擎/DB/录音/指标)。
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.pipecat.service_factory import create_services
|
||||
|
||||
from pipecat.frames.frames import EndFrame, TTSSpeakFrame
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||
|
||||
|
||||
async def run_pipeline(transport, cfg: AssistantConfig) -> None:
|
||||
"""在给定 transport 上构建并运行管线,直到连接结束。
|
||||
|
||||
Args:
|
||||
transport: 任意 pipecat transport(WebRTC / WS / 电话…),
|
||||
只要有 .input() / .output() / event_handler 即可。
|
||||
cfg: 助手配置(随请求内联传入)。
|
||||
"""
|
||||
logger.info(f"启动管线: assistant={cfg.name} mode={cfg.runtimeMode}")
|
||||
|
||||
stt, llm, tts = create_services(cfg)
|
||||
|
||||
context = OpenAILLMContext(messages=[{"role": "system", "content": cfg.prompt}])
|
||||
context_aggregator = llm.create_context_aggregator(context)
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
transport.input(),
|
||||
stt,
|
||||
context_aggregator.user(),
|
||||
llm,
|
||||
tts,
|
||||
transport.output(),
|
||||
context_aggregator.assistant(),
|
||||
]
|
||||
)
|
||||
|
||||
task = PipelineTask(
|
||||
pipeline,
|
||||
params=PipelineParams(
|
||||
allow_interruptions=cfg.enableInterrupt,
|
||||
enable_metrics=False,
|
||||
),
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(_transport, _client):
|
||||
if cfg.greeting:
|
||||
await task.queue_frame(TTSSpeakFrame(cfg.greeting))
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(_transport, _client):
|
||||
logger.info("对端断开,结束管线")
|
||||
await task.queue_frame(EndFrame())
|
||||
|
||||
runner = PipelineRunner(handle_sigint=False)
|
||||
await runner.run(task)
|
||||
logger.info("管线已结束")
|
||||
53
backend/services/pipecat/service_factory.py
Normal file
53
backend/services/pipecat/service_factory.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""创建 STT / LLM / TTS 服务。
|
||||
|
||||
对应 dograh 的 service_factory.py,但只留一套国产栈(OpenAI 兼容),
|
||||
按 provider 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。
|
||||
"""
|
||||
|
||||
import config
|
||||
from loguru import logger
|
||||
from models import AssistantConfig
|
||||
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import OpenAISTTService
|
||||
from pipecat.services.openai.tts import OpenAITTSService
|
||||
|
||||
|
||||
def create_stt(cfg: AssistantConfig):
|
||||
"""SenseVoice / FunASR 等,走 OpenAI 兼容的 /v1/audio/transcriptions。
|
||||
|
||||
连接信息优先用 cfg(由 config_resolver 从 DB 注入),为空回退 .env 默认。
|
||||
"""
|
||||
return OpenAISTTService(
|
||||
api_key=cfg.stt_api_key or config.STT_API_KEY,
|
||||
base_url=cfg.stt_base_url or config.STT_BASE_URL,
|
||||
model=cfg.asr or config.STT_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def create_llm(cfg: AssistantConfig):
|
||||
"""DeepSeek 等,走 OpenAI 兼容的 /v1/chat/completions。"""
|
||||
return OpenAILLMService(
|
||||
api_key=cfg.llm_api_key or config.LLM_API_KEY,
|
||||
base_url=cfg.llm_base_url or config.LLM_BASE_URL,
|
||||
model=cfg.model or config.LLM_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def create_tts(cfg: AssistantConfig):
|
||||
"""CosyVoice 等,走 OpenAI 兼容的 /v1/audio/speech。"""
|
||||
return OpenAITTSService(
|
||||
api_key=cfg.tts_api_key or config.TTS_API_KEY,
|
||||
base_url=cfg.tts_base_url or config.TTS_BASE_URL,
|
||||
model=config.TTS_MODEL,
|
||||
voice=cfg.voice or config.TTS_VOICE,
|
||||
)
|
||||
|
||||
|
||||
def create_services(cfg: AssistantConfig):
|
||||
logger.info(
|
||||
f"创建服务: stt={cfg.asr or config.STT_MODEL} "
|
||||
f"llm={cfg.model or config.LLM_MODEL} "
|
||||
f"tts={cfg.voice or config.TTS_VOICE}"
|
||||
)
|
||||
return create_stt(cfg), create_llm(cfg), create_tts(cfg)
|
||||
54
backend/services/pipecat/transports.py
Normal file
54
backend/services/pipecat/transports.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Transport 工厂——管线与"输出方式"解耦的关键。
|
||||
|
||||
同一条 STT→LLM→TTS 管线,可以挂在不同 transport 上:
|
||||
- WebRTC:浏览器,低延迟,带 NAT 穿透 -> build_webrtc_transport
|
||||
- WS: 裸音频流,服务端/话务/自定义客户端,简单 -> build_ws_transport
|
||||
|
||||
未来加电话(Twilio/Vonage)只是再加一个 build_xxx_transport + 对应 serializer。
|
||||
对应 dograh 的 transport_setup.py(WebRTC)+ 各 telephony provider 的 transport.py(WS)。
|
||||
"""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
|
||||
# WebRTC
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
|
||||
|
||||
# 裸 WS 音频流
|
||||
from pipecat.transports.network.fastapi_websocket import (
|
||||
FastAPIWebsocketTransport,
|
||||
FastAPIWebsocketParams,
|
||||
)
|
||||
from pipecat.serializers.protobuf import ProtobufFrameSerializer
|
||||
|
||||
|
||||
def _base_params() -> dict:
|
||||
"""两种 transport 共享的音频参数。"""
|
||||
return dict(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
vad_analyzer=SileroVADAnalyzer(), # 本地 VAD,打断功能依赖它
|
||||
)
|
||||
|
||||
|
||||
def build_webrtc_transport(connection: SmallWebRTCConnection) -> SmallWebRTCTransport:
|
||||
return SmallWebRTCTransport(
|
||||
webrtc_connection=connection,
|
||||
params=TransportParams(**_base_params()),
|
||||
)
|
||||
|
||||
|
||||
def build_ws_transport(websocket: WebSocket) -> FastAPIWebsocketTransport:
|
||||
"""裸 WS 输出。序列化用 protobuf(自定义客户端用同款解码);
|
||||
若对接电话商,把 serializer 换成对应的 TwilioFrameSerializer 等即可。
|
||||
"""
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
serializer=ProtobufFrameSerializer(),
|
||||
**_base_params(),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user