feat: 引入 Eino 框架并实现 AI 编排层基础设施与节点
- 引入 cloudwego/eino v0.9.9 和 eino-ext/components/model/openai v0.1.13 - 新增 internal/eino/ 包: - types.go: PipelineInput/Output、STTOutput、TokenUsage 类型定义 - state.go: PipelineState 跨节点状态收集(线程安全) - callback.go: ChatModel OnEndWithStreamOutput 回调,逐 token 推送 llm_chunk - nodes_stt.go: STT Lambda,支持文本/语音输入模式 - nodes_history.go: 历史组装 Lambda,含多模态图片支持 - nodes_splitter.go: 句子分割 Transform Lambda - nodes_tts.go: TTS Lambda,逐句合成推送音频 - nodes_done.go: Done Lambda,发送 llm_done 并追加历史 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
116
CamTalk-演讲稿.md
Normal file
116
CamTalk-演讲稿.md
Normal file
@@ -0,0 +1,116 @@
|
||||
## CamTalk — 多模态实时 AI 视觉对话助手 · 演讲稿
|
||||
|
||||
> 面向面试官,预计 10-15 分钟。建议配合架构图或项目文档做演示。
|
||||
|
||||
---
|
||||
|
||||
### 开场(约 1 分钟)
|
||||
|
||||
各位好,今天我想和大家分享一个我主导设计和开发的项目——**CamTalk**,一个多模态实时 AI 视觉对话助手。
|
||||
|
||||
简单来说,用户打开浏览器,对着摄像头,用语音提问,AI 就能同时"看到"画面、"听到"语音,然后用文字和语音自然地回应。整个过程不需要打字,就像一个面对面的助手。
|
||||
|
||||
做这个项目的初衷其实很直接——现在的大模型已经具备多模态能力,但大多数产品还是"上传一张图、输入一段文字"的交互方式。我认为真正的多模态交互应该是**无感的**——用户只需要说话,AI 自己去理解视觉场景,就像两个人面对面聊天一样。
|
||||
|
||||
---
|
||||
|
||||
### 系统架构(约 3 分钟)
|
||||
|
||||
CamTalk 采用三层架构:**前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用**。
|
||||
|
||||
**前端**是 React 18 加 TypeScript,跑在浏览器里。它负责三件事:摄像头和麦克风的采集,边缘侧的预处理——比如语音活动检测和关键帧过滤,以及 UI 渲染。通过 WebSocket 与后端通信。
|
||||
|
||||
**后端**是 Go 写的网关服务,用 Gin 框架做 HTTP 路由,gorilla/websocket 处理长连接。它是整个系统的"大脑",负责会话管理、AI 编排,以及和各家 AI 服务的对接。每个 WebSocket 连接对应一个 goroutine,天然适合这种长连接场景。
|
||||
|
||||
**AI 服务层**是可插拔的。LLM 默认用 GPT-4o,通过 OpenAI 兼容接口可以随时切换成通义千问等国产模型。语音识别默认 Deepgram,语音合成默认 OpenAI TTS,同时也支持小米的 MiMo 系列作为备选。
|
||||
|
||||
有人可能会问:为什么不直接让前端调用 AI API?这里有三点考虑。第一是**安全性**,API Key 不应该暴露在客户端。第二是**统一管控**,速率限制、成本监控、模型路由这些逻辑集中在网关层更好维护。第三是**可扩展性**,未来加缓存、做负载均衡、多实例部署,都在网关层解决。
|
||||
|
||||
部署方面,我们设计了 Nginx 做同源反向代理,前端静态资源和后端 API 在同一个域名下,天然解决跨域问题。Go 网关可以水平扩展,通过 Redis 共享会话状态。目前也已经配置好了 Docker Compose 一键部署方案,包含前端、后端和 PostgreSQL 三个容器。
|
||||
|
||||
---
|
||||
|
||||
### 核心交互流程(约 3 分钟)
|
||||
|
||||
我想重点讲一下一次完整的交互流程,因为它串起了整个系统最核心的技术挑战。
|
||||
|
||||
用户对着摄像头说了一句话,比如"这是什么花"。首先是**前端**的 VAD——语音活动检测模块——在浏览器端实时检测用户何时开始说话、何时说完。这一步完全在端侧完成,用的是 @ricky0123/vad-web,基于 WebRTC VAD 算法。好处是:用户不说话时不需要上传任何音频,节省约 70% 的无效带宽。
|
||||
|
||||
VAD 检测到语音结束后,前端会同时做两件事:把音频编码成 PCM 格式,以及从摄像头捕获当前画面,一起通过 WebSocket 发给后端。
|
||||
|
||||
后端收到后,启动一个 **AI 编排管道**(我们叫它 Orchestrator)。第一步,把音频发给 STT 服务做语音识别,拿到文字结果。第二步,把识别出的文字、摄像头画面以及对话历史,一起打包发给多模态 LLM 做推理。LLM 以流式方式逐 token 输出。第三步,也是最关键的优化——我们不等待 LLM 输出完再调用 TTS,而是做**句子级切分**:LLM 每输出一个完整句子,就立即送入 TTS 合成并推送给客户端。
|
||||
|
||||
所以客户端的体验是这样的:文字一个 token 一个 token 地出现,几乎同时语音就开始播放了。用户**先看到文字、紧接着听到语音**,感知延迟可以控制在 0.5 秒以内。整个端到端的目标延迟是 1.5 到 2 秒。
|
||||
|
||||
这个"LLM 文本流和 TTS 音频流并行推送"的设计,是我们降低感知延迟最关键的手段。
|
||||
|
||||
---
|
||||
|
||||
### 成本控制(约 2 分钟)
|
||||
|
||||
做实时多模态应用,成本是最容易失控的地方。我在设计之初就把成本控制作为架构级别的考量。
|
||||
|
||||
最直观的例子是视觉链路:如果按 1fps 全量发送画面给 LLM,一个用户每天用 10 分钟,一天就是 60 万帧的 token 消耗,1000 个用户时成本完全不可控。
|
||||
|
||||
我们的核心策略叫**端云协同**——把适合的计算前置到客户端。
|
||||
|
||||
在视觉侧,我们做了三个优化:一是降低采样频率,空闲时 5 秒一帧,用户说话时 1 秒一帧;二是关键帧过滤,通过 Canvas 像素比较计算帧间相似度,画面没有显著变化就不发送;三是只在用户提问时捕获画面,而不是持续上传视频流。
|
||||
|
||||
在语音侧,VAD 在浏览器端检测,只上传有效语音片段,环境噪音和静默时段完全不消耗带宽。
|
||||
|
||||
在推理侧,我们规划了模型分级策略——简单识别类问题走 GPT-4o-mini,深度分析走 GPT-4o,复杂推理走 o1。同时对话历史做了裁剪,前端保留最近 10 轮,后端保留 20 轮,限制每轮的固定 token 开销。
|
||||
|
||||
这些策略综合下来,预估月成本可以从无优化的约 5000 美元降到 300 到 500 美元,降幅大约 90%。
|
||||
|
||||
---
|
||||
|
||||
### 工程设计与取舍(约 2 分钟)
|
||||
|
||||
除了技术实现,我想分享几个设计上的取舍。
|
||||
|
||||
**存储方案的分阶段设计**。MVP 阶段我们用进程内存存会话状态,快速验证核心功能。但代码层面我们已经通过 Repository 接口模式做了抽象——HistoryRepository、UsageRepository 这些接口定义好了,底层实现可以是 Memory、Redis 或 PostgreSQL,通过配置切换。目前 Redis 实现已经就绪,PostgreSQL 的 schema 也设计好了,包括 sessions、messages、usage_daily 三张表。这种渐进式设计让我们既能快速交付,又为后续扩展留好了空间。
|
||||
|
||||
**文档驱动开发**。项目里有一套完整的设计文档,涵盖架构、接口协议、技术选型、成本控制等。我们遵循"文档优先"原则——实现功能前先写设计文档,实现和文档不一致时优先更新文档。这在团队协作中特别重要,接口契约清晰,前后端可以并行开发。
|
||||
|
||||
**WebSocket 协议的可靠性设计**。客户端每 30 秒发心跳,服务端 60 秒没收到心跳就断开。断线后用指数退避加抖动重连——1 秒、2 秒、4 秒、8 秒,最大 30 秒。消息用统一信封格式,所有消息都带 type 字段做类型分发。
|
||||
|
||||
---
|
||||
|
||||
### 用户故事与产品规划(约 2 分钟)
|
||||
|
||||
最后讲一下产品层面的思考。用户故事我按 P0 到 P2 分了三个优先级。
|
||||
|
||||
P0 是 MVP 必做的四个场景:AI 识别画面中的物体、语音对话无需打字、AI 能看到摄像头画面、AI 用语音回答。这四个跑通了,核心价值就成立了。
|
||||
|
||||
P1 是体验增强:AI 主动观察画面变化并提示重要事件、识别画面中的文字做 OCR、以及多轮对话的上下文记忆。
|
||||
|
||||
P2 是进阶探索:比如视障用户的无障碍辅助——AI 实时描述周围环境并提示障碍物,画面中外语内容的实时翻译,以及"观察模式"和"对话模式"的切换。
|
||||
|
||||
优先级判断用两个维度交叉评估:用户价值和实现成本。P0 是高价值且成本合理的,P1 是高价值但成本较高的,P2 是探索性的,验证后再投入。
|
||||
|
||||
目前还有几个功能创意在规划中,包括视频录制、对话翻译、对话总结、手动对话输入,以及对话情景选择——比如面试官模式、英语老师模式、辩论赛模式等。
|
||||
|
||||
---
|
||||
|
||||
### 总结(约 1 分钟)
|
||||
|
||||
总结一下,CamTalk 这个项目有几个我比较满意的设计点。
|
||||
|
||||
第一是**架构清晰**:三层分离,每层职责明确,前端做轻量预处理,后端做智能编排,AI 服务可插拔。
|
||||
|
||||
第二是**体验导向**:从用户感知延迟倒推技术方案,流式并行推送、句子级切分、端侧 VAD 这些手段都是围绕"让对话像真人一样自然"这个目标设计的。
|
||||
|
||||
第三是**成本意识**:从架构层面就融入了成本控制,端云协同、智能采样、模型分级,不是等功能做完再去优化成本。
|
||||
|
||||
第四是**工程成熟度**:接口抽象、文档驱动、渐进式存储升级,为项目的长期演进留好了空间。
|
||||
|
||||
以上就是 CamTalk 项目的整体介绍。谢谢大家,有什么问题我们可以一起讨论。
|
||||
|
||||
---
|
||||
|
||||
> **附:讲解提示**
|
||||
>
|
||||
> - 如果面试官追问技术深度,可以展开讲 Orchestrator 的管道实现细节(goroutine 并发、context 取消、句子切分算法)或 VAD 参数调优。
|
||||
> - 如果追问产品思维,可以展开讲用户故事的优先级判断逻辑,以及观察模式和对话模式的差异设计。
|
||||
> - 如果追问可扩展性,可以讲 Redis 共享会话、多 Gateway 水平扩展、模型路由器的规划。
|
||||
> - 如果追问成本数据,可以给出具体的 token 消耗计算过程和各种优化手段的量化效果。
|
||||
@@ -13,16 +13,24 @@ require (
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.23.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino v0.9.9 // indirect
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eino-contrib/jsonschema v1.0.3 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
@@ -31,18 +39,25 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goph/emperror v0.17.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -51,10 +66,13 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
|
||||
104
backend/go.sum
104
backend/go.sum
@@ -1,26 +1,50 @@
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
|
||||
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.9.9 h1:x63hvRif6ANPh9YEPoTIrp1potEeoLQFAjOclKaX/Kg=
|
||||
github.com/cloudwego/eino v0.9.9/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
|
||||
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
@@ -37,15 +61,20 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -54,37 +83,60 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -96,15 +148,18 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
@@ -113,6 +168,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
@@ -125,18 +184,26 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
@@ -144,8 +211,9 @@ google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
133
backend/internal/eino/callback.go
Normal file
133
backend/internal/eino/callback.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/cloudwego/eino/callbacks"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
callbacksHelper "github.com/cloudwego/eino/utils/callbacks"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
)
|
||||
|
||||
// context key 类型,避免与其他包冲突。
|
||||
type ctxKeySender struct{}
|
||||
type ctxKeyRequestID struct{}
|
||||
type ctxKeyState struct{}
|
||||
|
||||
// WithSender 将 Sender 注入 context。
|
||||
func WithSender(ctx context.Context, sender orchestrator.Sender) context.Context {
|
||||
return context.WithValue(ctx, ctxKeySender{}, sender)
|
||||
}
|
||||
|
||||
// WithRequestID 将 requestID 注入 context。
|
||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyRequestID{}, requestID)
|
||||
}
|
||||
|
||||
// WithPipelineState 将 PipelineState 注入 context。
|
||||
func WithPipelineState(ctx context.Context, state *PipelineState) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyState{}, state)
|
||||
}
|
||||
|
||||
// senderFromCtx 从 context 获取 Sender。
|
||||
func senderFromCtx(ctx context.Context) orchestrator.Sender {
|
||||
s, _ := ctx.Value(ctxKeySender{}).(orchestrator.Sender)
|
||||
return s
|
||||
}
|
||||
|
||||
// requestIDFromCtx 从 context 获取 requestID。
|
||||
func requestIDFromCtx(ctx context.Context) string {
|
||||
s, _ := ctx.Value(ctxKeyRequestID{}).(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// stateFromCtx 从 context 获取 PipelineState。
|
||||
func stateFromCtx(ctx context.Context) *PipelineState {
|
||||
s, _ := ctx.Value(ctxKeyState{}).(*PipelineState)
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildCallbackHandler 构建 Eino Callback Handler。
|
||||
//
|
||||
// 核心职责:ChatModel 节点通过 OnEndWithStreamOutput 逐 token 推送 llm_chunk 到客户端,
|
||||
// 同时累积完整文本到 PipelineState。
|
||||
//
|
||||
// 其他节点的消息推送(stt_result、tts_audio、llm_done)由各 Lambda 内部直接调用 Sender。
|
||||
func BuildCallbackHandler() callbacks.Handler {
|
||||
return callbacksHelper.NewHandlerHelper().
|
||||
ChatModel(&callbacksHelper.ModelCallbackHandler{
|
||||
OnEndWithStreamOutput: func(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[*model.CallbackOutput]) context.Context {
|
||||
log := logger.Log
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
state := stateFromCtx(ctx)
|
||||
|
||||
if sender == nil || requestID == "" {
|
||||
log.Warnw("ModelCallback: missing sender or request_id in context",
|
||||
"node", info.Name)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// 异步消费流,避免阻塞框架的下游处理。
|
||||
// 框架对流做了内部拷贝,此 goroutine 读取独立副本。
|
||||
go func() {
|
||||
defer output.Close()
|
||||
|
||||
for {
|
||||
chunk, err := output.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
log.Errorw("ModelCallback: stream recv error",
|
||||
"node", info.Name, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if chunk == nil || chunk.Message == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
delta := chunk.Message.Content
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 推送 llm_chunk 到客户端
|
||||
if err := sender.SendLLMChunk(models.WsLLMChunk{
|
||||
Type: "llm_chunk",
|
||||
RequestID: requestID,
|
||||
Delta: delta,
|
||||
Role: "assistant",
|
||||
}); err != nil {
|
||||
log.Errorw("ModelCallback: send llm_chunk failed", "error", err)
|
||||
}
|
||||
|
||||
// 累积完整文本到 State
|
||||
if state != nil {
|
||||
state.AppendText(delta)
|
||||
}
|
||||
|
||||
// 记录 token 用量(流的最后一帧携带)
|
||||
if chunk.TokenUsage != nil && state != nil {
|
||||
state.mu.Lock()
|
||||
state.TokenUsage = &TokenUsage{
|
||||
Prompt: chunk.TokenUsage.PromptTokens,
|
||||
Completion: chunk.TokenUsage.CompletionTokens,
|
||||
Total: chunk.TokenUsage.TotalTokens,
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ctx
|
||||
},
|
||||
}).
|
||||
Handler()
|
||||
}
|
||||
114
backend/internal/eino/nodes_done.go
Normal file
114
backend/internal/eino/nodes_done.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
)
|
||||
|
||||
// NewDoneLambda 创建 Done Lambda 节点。
|
||||
// 输入: struct{}(TTS 完成信号)→ 输出: PipelineOutput
|
||||
//
|
||||
// 从 PipelineState 读取完整回复和 token 用量,发送 llm_done 到客户端,
|
||||
// 追加助手消息到会话历史,返回 PipelineOutput。
|
||||
func NewDoneLambda(sessionMgr session.Manager, model string) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, _ struct{}) (*PipelineOutput, error) {
|
||||
log := logger.Log
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
state := stateFromCtx(ctx)
|
||||
|
||||
if state == nil {
|
||||
return &PipelineOutput{}, nil
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
fullResponse := state.FullResponse.String()
|
||||
transcribedText := state.TranscribedText
|
||||
tokenUsage := state.TokenUsage
|
||||
modelName := model
|
||||
state.mu.Unlock()
|
||||
|
||||
// 追加助手消息到会话历史
|
||||
sessionID := ""
|
||||
if state != nil {
|
||||
// 从 context 获取 sessionID(由适配器注入)
|
||||
if sid, ok := ctx.Value(ctxKeySessionID{}).(string); ok {
|
||||
sessionID = sid
|
||||
}
|
||||
}
|
||||
if sessionID != "" && sessionMgr != nil && fullResponse != "" {
|
||||
if err := sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "assistant",
|
||||
Content: fullResponse,
|
||||
}); err != nil {
|
||||
log.Errorw("追加助手消息到历史失败", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送 llm_done
|
||||
if sender != nil && requestID != "" {
|
||||
done := models.WsLLMDone{
|
||||
Type: "llm_done",
|
||||
RequestID: requestID,
|
||||
FullText: fullResponse,
|
||||
Model: modelName,
|
||||
LatencyMs: 0, // 由适配器计算
|
||||
}
|
||||
if tokenUsage != nil {
|
||||
done.TokensUsed = struct {
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
}{
|
||||
Prompt: tokenUsage.Prompt,
|
||||
Completion: tokenUsage.Completion,
|
||||
Total: tokenUsage.Total,
|
||||
}
|
||||
}
|
||||
if err := sender.SendLLMDone(done); err != nil {
|
||||
log.Errorw("发送 llm_done 失败", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infow("查询处理完成",
|
||||
"request_id", requestID,
|
||||
"text_length", len(fullResponse))
|
||||
|
||||
return &PipelineOutput{
|
||||
TranscribedText: transcribedText,
|
||||
FullResponse: fullResponse,
|
||||
Model: modelName,
|
||||
TokenUsage: tokenUsage,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ctxKeySessionID sessionID 的 context key。
|
||||
type ctxKeySessionID struct{}
|
||||
|
||||
// WithSessionID 将 sessionID 注入 context。
|
||||
func WithSessionID(ctx context.Context, sessionID string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeySessionID{}, sessionID)
|
||||
}
|
||||
|
||||
// latencyFromCtx 从 context 获取开始时间并计算延迟。
|
||||
func latencyFromCtx(ctx context.Context) int64 {
|
||||
if startTime, ok := ctx.Value(ctxKeyStartTime{}).(time.Time); ok {
|
||||
return time.Since(startTime).Milliseconds()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ctxKeyStartTime 请求开始时间的 context key。
|
||||
type ctxKeyStartTime struct{}
|
||||
|
||||
// WithStartTime 将请求开始时间注入 context。
|
||||
func WithStartTime(ctx context.Context, t time.Time) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyStartTime{}, t)
|
||||
}
|
||||
117
backend/internal/eino/nodes_history.go
Normal file
117
backend/internal/eino/nodes_history.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/llm"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
// HistoryInput 历史组装节点的输入,包含 STT 输出和原始请求信息。
|
||||
type HistoryInput struct {
|
||||
STTOutput *STTOutput
|
||||
SessionID string
|
||||
RequestID string
|
||||
ImageData []byte
|
||||
Scenario string
|
||||
DetailLevel string
|
||||
}
|
||||
|
||||
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
||||
// 输入: HistoryInput → 输出: []*schema.Message
|
||||
//
|
||||
// 构建系统提示词,组装历史消息和当前用户输入(含多模态图片)。
|
||||
func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string, maxHistory int) ([]models.Message, error), maxHistory int) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, input *HistoryInput) ([]*schema.Message, error) {
|
||||
log := logger.Log
|
||||
requestID := input.RequestID
|
||||
|
||||
// 构建系统提示词
|
||||
scenarioPrompt := llm.GetScenarioPrompt(input.Scenario, input.STTOutput.Language)
|
||||
systemPrompt := llm.BuildSystemPrompt(input.STTOutput.Language, input.DetailLevel, scenarioPrompt)
|
||||
|
||||
// 构建 system message(含图片)
|
||||
systemMsg := &schema.Message{
|
||||
Role: schema.System,
|
||||
Content: systemPrompt,
|
||||
}
|
||||
|
||||
// 如果有图片,添加到 system message 的多模态内容中
|
||||
if len(input.ImageData) > 0 {
|
||||
base64Str := base64.StdEncoding.EncodeToString(input.ImageData)
|
||||
mimeType := detectImageMimeType(input.ImageData)
|
||||
systemMsg.UserInputMultiContent = []schema.MessageInputPart{
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeImageURL,
|
||||
Image: &schema.MessageInputImage{
|
||||
MessagePartCommon: schema.MessagePartCommon{
|
||||
Base64Data: &base64Str,
|
||||
MIMEType: mimeType,
|
||||
},
|
||||
Detail: schema.ImageURLDetailAuto,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
messages := []*schema.Message{systemMsg}
|
||||
|
||||
// 获取并追加历史消息
|
||||
if historyFetcher != nil && input.SessionID != "" {
|
||||
history, err := historyFetcher(ctx, input.SessionID, maxHistory)
|
||||
if err != nil {
|
||||
log.Warnw("获取历史消息失败,继续处理", "error", err, "request_id", requestID)
|
||||
} else {
|
||||
for _, msg := range history {
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.RoleType(msg.Role),
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 追加当前用户输入
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: input.STTOutput.Text,
|
||||
})
|
||||
|
||||
log.Infow("历史组装完成",
|
||||
"request_id", requestID,
|
||||
"message_count", len(messages),
|
||||
"has_image", len(input.ImageData) > 0,
|
||||
"scenario", input.Scenario)
|
||||
|
||||
return messages, nil
|
||||
})
|
||||
}
|
||||
|
||||
// detectImageMimeType 简单检测图片 MIME 类型。
|
||||
func detectImageMimeType(data []byte) string {
|
||||
if len(data) < 4 {
|
||||
return "image/jpeg"
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
// PNG: 89 50 4E 47
|
||||
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||
return "image/png"
|
||||
}
|
||||
// GIF: 47 49 46 38
|
||||
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
|
||||
return "image/gif"
|
||||
}
|
||||
// WebP: 52 49 46 46
|
||||
if data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 {
|
||||
return "image/webp"
|
||||
}
|
||||
return "image/jpeg" // 默认
|
||||
}
|
||||
|
||||
72
backend/internal/eino/nodes_splitter.go
Normal file
72
backend/internal/eino/nodes_splitter.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// sentenceDelimiters 句子分隔符集合。
|
||||
var sentenceDelimiters = map[rune]bool{
|
||||
'。': true,
|
||||
'!': true,
|
||||
'?': true,
|
||||
'\n': true,
|
||||
'.': true,
|
||||
'!': true,
|
||||
'?': true,
|
||||
}
|
||||
|
||||
// NewSplitterLambda 创建句子分割 Transform Lambda 节点。
|
||||
// 输入: StreamReader[string](LLM 完整文本的单帧流)→ 输出: StreamReader[[]string](句子数组流)
|
||||
//
|
||||
// 在 Stream 模式下,框架自动将 ChatModel 的 StreamReader[*schema.Message]
|
||||
// concat 为 string 后传入此节点。此节点将文本按句子边界切分,
|
||||
// 每切出一个句子就输出一次,供 TTS 节点实时合成。
|
||||
func NewSplitterLambda() *compose.Lambda {
|
||||
return compose.TransformableLambda(func(ctx context.Context, input *schema.StreamReader[string]) (*schema.StreamReader[[]string], error) {
|
||||
sr, sw := schema.Pipe[[]string](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
|
||||
var buffer strings.Builder
|
||||
|
||||
for {
|
||||
chunk, err := input.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
// 流结束,flush 剩余缓冲
|
||||
if buffer.Len() > 0 {
|
||||
text := strings.TrimSpace(buffer.String())
|
||||
if text != "" {
|
||||
sw.Send([]string{text}, nil)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
sw.Send(nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
// chunk 是 concat 后的完整文本(单帧流)
|
||||
// 逐字符累积,按句子分隔符切分
|
||||
for _, r := range chunk {
|
||||
buffer.WriteRune(r)
|
||||
if sentenceDelimiters[r] {
|
||||
text := strings.TrimSpace(buffer.String())
|
||||
if text != "" {
|
||||
sw.Send([]string{text}, nil)
|
||||
}
|
||||
buffer.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
})
|
||||
}
|
||||
119
backend/internal/eino/nodes_stt.go
Normal file
119
backend/internal/eino/nodes_stt.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/stt"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
// NewSTTLambda 创建 STT Lambda 节点。
|
||||
// 输入: PipelineInput → 输出: STTOutput
|
||||
//
|
||||
// 文本输入模式:跳过 STT,直接返回用户输入文本。
|
||||
// 语音模式:调用 sttService.Recognize() 进行语音识别。
|
||||
// 识别结果通过 Sender 发送 stt_result 到客户端。
|
||||
func NewSTTLambda(sttService stt.Service) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, input *PipelineInput) (*STTOutput, error) {
|
||||
log := logger.Log
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
|
||||
// 文本输入模式:跳过 STT
|
||||
if input.Text != "" {
|
||||
log.Infow("使用文本输入,跳过 STT",
|
||||
"request_id", requestID, "text", input.Text)
|
||||
|
||||
// 发送 stt_result 保持前端消息流一致性
|
||||
if sender != nil {
|
||||
if err := sender.SendSTTResult(models.WsSTTResult{
|
||||
Type: "stt_result",
|
||||
RequestID: requestID,
|
||||
Text: input.Text,
|
||||
IsFinal: true,
|
||||
}); err != nil {
|
||||
log.Errorw("发送 stt_result 失败", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 State
|
||||
if state := stateFromCtx(ctx); state != nil {
|
||||
state.mu.Lock()
|
||||
state.TranscribedText = input.Text
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
return &STTOutput{
|
||||
Text: input.Text,
|
||||
Language: input.Language,
|
||||
IsSkipped: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 语音模式:解码音频
|
||||
if len(input.AudioData) == 0 {
|
||||
return nil, fmt.Errorf("stt: no audio data provided")
|
||||
}
|
||||
|
||||
log.Infow("开始语音识别",
|
||||
"request_id", requestID, "audio_bytes", len(input.AudioData))
|
||||
|
||||
// 调用 STT 服务
|
||||
text, err := sttService.Recognize(ctx, input.AudioData, stt.Options{
|
||||
Encoding: "pcm_s16le",
|
||||
SampleRate: 16000,
|
||||
Language: input.Language,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorw("语音识别失败", "error", err, "request_id", requestID)
|
||||
if sender != nil {
|
||||
sender.SendError(models.WsError{
|
||||
Type: "error",
|
||||
RequestID: requestID,
|
||||
Code: "STT_ERROR",
|
||||
Message: "语音识别失败: " + err.Error(),
|
||||
})
|
||||
}
|
||||
return nil, fmt.Errorf("stt: recognize: %w", err)
|
||||
}
|
||||
|
||||
// STT 返回空文本
|
||||
if strings.TrimSpace(text) == "" {
|
||||
log.Infow("语音识别结果为空", "request_id", requestID)
|
||||
text = "(未识别到语音)"
|
||||
}
|
||||
|
||||
log.Infow("语音识别完成", "request_id", requestID, "text", text)
|
||||
|
||||
// 发送 stt_result
|
||||
if sender != nil {
|
||||
if err := sender.SendSTTResult(models.WsSTTResult{
|
||||
Type: "stt_result",
|
||||
RequestID: requestID,
|
||||
Text: text,
|
||||
IsFinal: true,
|
||||
}); err != nil {
|
||||
log.Errorw("发送 stt_result 失败", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 State
|
||||
if state := stateFromCtx(ctx); state != nil {
|
||||
state.mu.Lock()
|
||||
state.TranscribedText = text
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
return &STTOutput{
|
||||
Text: text,
|
||||
Language: input.Language,
|
||||
IsSkipped: false,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
92
backend/internal/eino/nodes_tts.go
Normal file
92
backend/internal/eino/nodes_tts.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
|
||||
"github.com/hhs/camtalk/internal/ai/tts"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
// NewTTSLambda 创建 TTS Lambda 节点。
|
||||
// 输入: []string(句子数组,框架自动从 StreamReader concat)→ 输出: struct{}
|
||||
//
|
||||
// 将句子数组转为 channel,调用 ttsService.SynthesizeStream() 流式合成,
|
||||
// 逐 chunk 推送 tts_audio 到客户端。TTS 失败静默跳过。
|
||||
func NewTTSLambda(ttsService tts.Service, ttsVoice string, ttsSpeed float64, ttsOutputFmt string, ttsSampleRate int) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, sentences []string) (struct{}, error) {
|
||||
log := logger.Log
|
||||
sender := senderFromCtx(ctx)
|
||||
requestID := requestIDFromCtx(ctx)
|
||||
state := stateFromCtx(ctx)
|
||||
|
||||
// 检查 TTS 是否启用(从 State 或 context 获取)
|
||||
// TTSEnabled 信息在 PipelineInput 中,通过 State 传递
|
||||
if state != nil {
|
||||
state.mu.Lock()
|
||||
ttsEnabled := true // 默认启用,由适配器通过 State 设置
|
||||
state.mu.Unlock()
|
||||
if !ttsEnabled {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(sentences) == 0 {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
|
||||
if sender == nil || requestID == "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
|
||||
log.Infow("开始 TTS 合成", "request_id", requestID, "sentence_count", len(sentences))
|
||||
|
||||
// 将句子数组转为 channel(ttsService.SynthesizeStream 需要 <-chan string)
|
||||
sentenceCh := make(chan string, len(sentences))
|
||||
for _, s := range sentences {
|
||||
sentenceCh <- s
|
||||
}
|
||||
close(sentenceCh)
|
||||
|
||||
// 调用 TTS 服务
|
||||
ttsStream, err := ttsService.SynthesizeStream(ctx, sentenceCh, tts.Options{
|
||||
Voice: ttsVoice,
|
||||
Speed: ttsSpeed,
|
||||
OutputFmt: ttsOutputFmt,
|
||||
SampleRate: ttsSampleRate,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorw("TTS 合成启动失败(已跳过)", "error", err, "request_id", requestID)
|
||||
return struct{}{}, nil // TTS 失败不中断流程
|
||||
}
|
||||
|
||||
// 消费 TTS 音频流,推送到客户端
|
||||
for chunk := range ttsStream {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Infow("TTS 流被中断", "request_id", requestID)
|
||||
return struct{}{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
audioBase64 := base64.StdEncoding.EncodeToString(chunk.Audio)
|
||||
|
||||
if err := sender.SendTTSAudio(models.WsTTSAudio{
|
||||
Type: "tts_audio",
|
||||
RequestID: requestID,
|
||||
Audio: audioBase64,
|
||||
MimeType: "audio/mp3",
|
||||
IsLast: chunk.IsLast,
|
||||
Final: chunk.Final,
|
||||
}); err != nil {
|
||||
log.Errorw("发送 tts_audio 失败", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infow("TTS 合成完成", "request_id", requestID)
|
||||
return struct{}{}, nil
|
||||
})
|
||||
}
|
||||
36
backend/internal/eino/state.go
Normal file
36
backend/internal/eino/state.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PipelineState Graph 全局状态,用于跨节点收集数据。
|
||||
// 通过 compose.WithGenLocalState 注册,各节点通过 StatePreHandler/StatePostHandler 读写。
|
||||
type PipelineState struct {
|
||||
mu sync.Mutex
|
||||
FullResponse strings.Builder // LLM 完整回复(由 Callback 累积)
|
||||
TranscribedText string // STT 识别文本
|
||||
Model string // 实际使用的模型名
|
||||
TokenUsage *TokenUsage // token 用量
|
||||
}
|
||||
|
||||
// genLocalState 创建每请求的 PipelineState 实例。
|
||||
func genLocalState(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}
|
||||
|
||||
// AppendText 追加文本到 FullResponse(线程安全)。
|
||||
func (s *PipelineState) AppendText(text string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.FullResponse.WriteString(text)
|
||||
}
|
||||
|
||||
// GetFullResponse 获取完整回复文本(线程安全)。
|
||||
func (s *PipelineState) GetFullResponse() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.FullResponse.String()
|
||||
}
|
||||
37
backend/internal/eino/types.go
Normal file
37
backend/internal/eino/types.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Package eino 基于 CloudWeGo Eino 框架的 AI 编排层。
|
||||
// 使用 Eino Graph 替代手写 goroutine 管道,实现声明式 STT → LLM → TTS 编排。
|
||||
package eino
|
||||
|
||||
// PipelineInput Graph 统一输入。
|
||||
type PipelineInput struct {
|
||||
AudioData []byte // base64 解码后的音频(可选)
|
||||
ImageData []byte // base64 解码后的图像(可选)
|
||||
Text string // 直接文本输入(可选,跳过 STT)
|
||||
SessionID string
|
||||
RequestID string
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
TTSEnabled bool
|
||||
}
|
||||
|
||||
// PipelineOutput Graph 统一输出。
|
||||
type PipelineOutput struct {
|
||||
TranscribedText string // STT 结果
|
||||
FullResponse string // LLM 完整回复
|
||||
Model string // 实际使用的模型名
|
||||
TokenUsage *TokenUsage // token 用量
|
||||
}
|
||||
|
||||
// STTOutput STT 节点输出。
|
||||
type STTOutput struct {
|
||||
Text string
|
||||
Language string
|
||||
IsSkipped bool // 文本输入模式跳过了 STT
|
||||
}
|
||||
|
||||
// TokenUsage token 用量统计。
|
||||
type TokenUsage struct {
|
||||
Prompt int
|
||||
Completion int
|
||||
Total int
|
||||
}
|
||||
Reference in New Issue
Block a user