AI_Psyc_Project 是一个私有 Fork 项目。上游提供了初始基础,我参与了当前仓库整套应用的构建设计,并在后续 7 次提交中完成本地模式、模型服务衔接、认证、进度与前后端合并。这里不展开业务数据、提示词和私有模型配置。

这个仓库要解决什么问题

项目要把 AI 对话、语音输入、语音输出、过程评估和报告生成放进同一个应用,同时允许使用在线模型与本地模型。除了普通用户的访谈流程,还需要登录、数据保存和管理界面。

难点在于这些能力来自不同运行环境。浏览器负责录音和交互,Next.js 负责页面与服务调用,PocketBase 负责认证和数据,本地 Python 后端还要运行 STT、LLM、TTS 与视觉服务。只把每个接口分别接通,整个访谈仍然可能在模式切换、连接中断或页面刷新时失去状态。

我采用的整体方案

我参与把仓库组织为本地 AI 后端、Next.js 前端和 PocketBase 三部分。

FastAPI 后端在启动时初始化 VisionServiceSTTServiceToneServiceBrainServiceMouthService,分别处理视觉、语音识别、语气、模型推理与语音合成。HTTP 接口承担文件上传、STT 和 OpenAI 兼容调用,WebSocket 路由承担需要连续返回文本、音频和轮次状态的实时访谈。

前端不直接把所有模型逻辑写进组件,而是在 services 下分别放置 Gemini、OpenAI、Qwen 和本地服务适配。MainApp 负责欢迎页、访谈、报告等应用状态,具体页面负责展示和输入。认证及存储通过 PocketBase 接入,服务端操作和 Cookie 再负责 Next.js 两侧的会话衔接。

这套结构不是我只在末尾补几个接口。我参与了从用户工作流到前端状态、服务适配、本地后端和数据层的整体整合,目标是让同一套界面可以在不同模型方案下完成一次完整评估。

多模型调用怎么收口

项目同时存在在线服务和本地服务。直接在 React 组件里到处判断提供方,会让聊天、STT、TTS 与报告各自保存一份配置。同一轮对话中,文本已经切到本地,报告却还在请求在线服务,这类问题很难从界面表面判断。

我的处理是把差异放进 service 层。组件提交统一输入,服务层负责 URL、鉴权、请求体和响应格式转换。对于本地后端,我还提供 OpenAI 兼容的 /v1/chat/completions,让已有调用路径可以复用,不需要每个页面重新认识一套私有协议。

本地后端内部也按能力拆开。语音先由 STT 转成文本,Brain 处理上下文,Mouth 生成音频;视觉与语气分析保持独立。服务之间通过明确数据传递,而不是相互读取页面状态。

实时访谈需要完整的状态协议

WebSocket 中会连续出现 LLM 文本、TTS 音频、进度和报告就绪等消息。我为消息增加类型与完成标记,前端按类型处理,不把所有字符串直接拼进回答。

一轮访谈要区分正常完成、用户中止、服务异常和连接断开。文本最后一个分片到达不代表音频已经播放完,报告生成也不应该在轮次状态尚未落定时启动。把这些条件写进协议后,进度条才能由真实轮次驱动,而不是用定时动画假装前进。

项目后期也尝试把部分本地调用切到纯 HTTP 的 OpenAI 兼容模式,以降低部署复杂度。仓库中保留 WebSocket 路由和轻量 HTTP 路径,是两种运行方式合并过程中留下的状态。它们各自能解决不同场景,接下来需要继续收敛前端究竟使用哪一种作为默认路径。

认证与前后端合并

认证链路横跨 PocketBase、服务端操作、浏览器 Cookie 和客户端状态。登录接口返回成功,不代表所有页面已经拿到同一会话。我逐层检查 Cookie 写入、服务端读取、客户端初始化与退出清理,让认证只有明确来源。

两次较大的前后端合并也不只是处理 Git 冲突。接口字段、可选类型和状态含义变化,可能完全通过 TypeScript 检查,却在真实流程中返回空值。因此每次合并后都要走一遍登录、开始访谈、录音、STT、流式回答、进度、报告和结果保存。

截至这次提交的复盘

这一版已经形成完整应用骨架:Next.js 和 React 负责交互,TypeScript service 层隔离模型差异,FastAPI 组织本地 AI 能力,REST 与 WebSocket 传输不同类型任务,PocketBase 提供认证和存储。

当前最明显的问题是本地模式仍有 HTTP 与 WebSocket 两条演进路线,部分进度和报告接口为了兼容无数据库模式保留了模拟返回。下一阶段需要明确默认协议,并让演示数据与真实结果有清楚标识。

这次构建让我看到,多模型应用真正难的不是多放几个模型名称,而是让认证、消息生命周期、语音、报告和持久化对同一轮会话形成一致理解。把这些边界定下来,模型才可以被替换而不拖动整个界面。