Browse Source

上传代码

main
liwei1dao 4 months ago
parent
commit
66d561dc1b
  1. 103
      CLAUDE.md
  2. 1
      apps/proto/echomeet/echomeet_db.proto
  3. 1
      apps/proto/echomeet/echomeet_msg.proto
  4. 64
      apps/services/modules/echomeet/CLAUDE.md
  5. 6
      apps/services/modules/echomeet/api_addrecord.go
  6. 2
      apps/services/modules/echomeet/provider_ali.go
  7. 2
      apps/services/modules/echomeet/provider_bytedance.go
  8. 2
      apps/services/modules/echomeet/provider_google.go
  9. 2
      apps/services/modules/echomeet/provider_microsoft.go
  10. 5
      apps/services/modules/echomeet/providers.go
  11. 13
      apps/services/modules/echomeet/tasks.go
  12. 13
      apps/services/pb/echomeet_db.pb.go
  13. 27
      apps/services/pb/echomeet_msg.pb.go
  14. 3
      apps/services/sys/doubao/core.go
  15. 37
      apps/services/sys/doubao/doubao.go
  16. 3
      apps/services/sys/google/gemini/core.go
  17. 91
      apps/services/sys/google/gemini/gemini.go
  18. 3
      apps/services/sys/openai/core.go
  19. 33
      apps/services/sys/openai/openai.go

103
CLAUDE.md

@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概览
`yunyan-sas` 是一个从单产品服务 `deep_server`(Go 模块名 `earphone`)迁移而来的 **monorepo SaaS 平台**,目标是把单产品后端改造成可托管多个应用/产品的 SaaS。迁移时 Go 模块名 `earphone` → `yunyan`,所有 import 前缀随之变更。回复一律用中文。
## 仓库结构
```
yunyan-sas/
├── go.work # Go workspace:use ./apps/services
├── build.sh # 构建/推送镜像(被 deploy.sh 调用,也可单用)
├── apps/
│ ├── proto/ # .proto 源文件(按业务域分目录)
│ ├── services/ # Go 后端(module: yunyan, go 1.25.0)
│ │ ├── lego/ # 内部服务框架(IService/IModule/Comp + sys 子系统)
│ │ ├── sys/ # 第三方集成(aliyun/tencentyun/openai/coze/nats/...)
│ │ ├── comm/ # 跨模块公共:常量、ErrorCode、IUserSession、NATS key
│ │ ├── modules/ # 业务模块(每个 = 一组 Comp)
│ │ ├── services/ # 各服务的 main.go 入口 + conf/*.yaml
│ │ ├── pb/ # protoc 生成的 *.pb.go(DO NOT EDIT)
│ │ ├── swagger/、utils/
│ ├── admin/ # 管理后台前端(Nuxt 4 SSR + Tailwind + DaisyUI 4 + Pinia)
│ └── web/ # 用户侧前端骨架(Nuxt 4)
└── deploy/<service>/ # 每服务一份 deploy.sh + docker-compose.yml + env/
```
## 常用命令
### Go 后端(在 `apps/services/` 下执行)
```bash
go build ./... # 编译全部
go vet ./... # 静态检查
go test ./... # 跑全部测试(测试集中在 sys/* 与 utils/)
go test ./sys/sms/ -run TestXxx -v # 跑单个测试
go build -o /tmp/console ./services/console/ # 编译单个服务
go run ./services/console -conf ./services/console/conf/console.yaml # 本地起服务
```
服务列表:`api`、`gateway`、`home`、`mcp`、`migu`、`timer`(集群服务,走 ETCD/rpcx)、`console`(单例服务,不接 ETCD)。每个服务 `main.go` 用 `-conf` 指向自己的 `conf/*.yaml`,默认相对路径 `./conf/<svc>.yaml`。
### Admin 前端(在 `apps/admin/` 下执行)
```bash
pnpm install
pnpm dev # 开发,默认 :3000
pnpm build # 产出 .output/(SSR)
```
本地开发需同时起 console 后端(默认代理到 `127.0.0.1:8080`,见下文 proxy)。超管账号见各 `console.yaml` 的 `AdminAccount/AdminPassword`。
### 构建镜像与部署
```bash
./build.sh console # 构建 docker-registry.../yunyan-console:latest(PUSH=0 只构建不推)
./build.sh admin
cd deploy/console && ./deploy.sh prod # 一键发布:构建推送 + 同步配置 + 远程 pull + up
cd deploy/console && ./deploy.sh local up # 本机起容器
cd deploy/console && ./deploy.sh prod logs # 看日志
```
`deploy.sh <env> [action]`:`env` = `local|dev|prod`;`action` dev/prod 默认 `deploy`,local 默认 `up`,另有 `up/build/pull/down/restart/logs/ps`。服务名取自所在目录名。
## 后端架构(lego 框架)
整套后端围绕 `lego` 自研框架,核心是 **Service → Module → Comp** 三层 + 反射注册路由。
- **启动**:`lego.Run(service, modules...)`([lego/lego.go](apps/services/lego/lego.go))依次 `Init → Start → Run → Destroy`。
- **两种 Service 基类**:
- `lego/base/rpcx`(集群服务):接 ETCD 做服务发现,服务间用 rpcx RPC 互调。`api/gateway/home/mcp/migu/timer` 用它,且 `OnInstallComp` 装 `services.NewHttpRouteComp()`。
- `lego/base/single`(单例服务):**不接 ETCD/集群**,只驱动单个模块。`console` 用它,主库 Supabase Postgres + 独立 Redis + NATS,维护 `app_registry` 注册表,按"选中应用"动态建到目标应用库的连接。
- **Module → Comp**:模块(如 `modules/api`)在 `OnInstallComp` 里 `RegisterComp` 一组组件:`apiComp`(HTTP 入口)、`modelComp`(数据访问)、各种 consumer/interceptor。见 [modules/api/module.go](apps/services/modules/api/module.go)。
- **HTTP 路由靠反射自动注册**([modules/comp_httpgate.go](apps/services/modules/comp_httpgate.go)):继承 `modules.MCompHttpGate` 的组件,其**每个导出方法**只要签名是
```go
func (c *apiComp) MethodName(session comm.IUserSession, req *pb.XxxReq) (*pb.XxxResp, *pb.ErrorData)
```
就会被注册成路由 `<module>_<methodname小写>`(如 `api_getfactorys`)。新增接口 = 新建 `modules/<mod>/api_xxx.go` 加一个符合签名的方法即可,**无需手动注册**。后缀 `Check` 的方法不注册。
- **错误处理约定**:成功填 `resp`;失败构造 `&pb.ErrorData{Code: pb.ErrorCode_XXX, Message: ...}` 并 `return`。
- **pb 包**:`pb/*.pb.go` 由 `apps/proto/` 下 `.proto` 经 `protoc` + `protoc-gen-go` v1.36 生成,**手动改 .pb.go 无效**;改协议要改 `.proto` 再重新生成。注意 console 模块有本地 gorm struct(非 pb)的 model 文件,见 `modules/console/model_*.go`。
## Admin 前端约定(关键,易踩坑)
- **API 代理**:[server/middleware/proxy.ts](apps/admin/server/middleware/proxy.ts) 把 `/web/api/*` 和 `/console/api/*` 代理到 `CONSOLE_BACKEND`(容器内由 env 注入,本地默认 `http://127.0.0.1:8080`)。
- **`useApi().webApi(method, data)`**([app/composables/useApi.ts](apps/admin/app/composables/useApi.ts)):
- 已自动拼成 `/web/api/api_${method}` 并**已解包 `data`**(成功返回 `data` 本体;失败 `throw`;`code:18` 自动登出)。
- ⚠️ 调用时 `method` **不要带 `api_` 前缀**,否则变成 `api_api_xxx` → `code:11`。
- 方法名以 `get/list/load/query/fetch/search/count/stat/export/check` 开头视为只读,不弹全屏遮罩;其余(add/update/del/save/create…)视为写操作,请求期间套遮罩防重复提交。
- 选中应用通过请求头 `X-App-Id` 传给后端。
- **后端返回常多包一层**:如 `getfactorys → {factorys}`、`getproducts → {products}`、`getproduct → {product}`;保存 payload 也常包一层(厂家 `{Factory:{...}}`、产品 `{product:{...}}`);统计 trend 日期字段是 `stat_day`。列表 ID 多以十六进制展示(`id.toString(16).toUpperCase()`)。
- 样式:`app/assets/css/console.css` 由 nuxt.config 引用;DaisyUI 固定 4.x。
## 配置与密钥约定(重要)
- 各 `conf/*.yaml` 与 `deploy/<svc>/deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。
- ⚠️ **本仓库若要公开,必须先清理** `deploy.sh` 的 `deploy_profile`(`DEPLOY_HOST`/`REGISTRY_PASS`)与各 `*.yaml` 里的真实凭据。
- 改部署逻辑要同步两份 `deploy/console/deploy.sh` 和 `deploy/admin/deploy.sh`(内容相同,建议改一份再 cp 覆盖)。
## 迁移上下文
源项目在 `/Users/liwei/work/go/yunyan/deep_server_up/`(及 `go_earphone/server/deep_server/`)。从旧代码移植接口时,注意 import 路径由 `earphone/...` 改为 `yunyan/...`,并核对接口签名是否符合上文反射注册约定。

1
apps/proto/echomeet/echomeet_db.proto

@ -71,6 +71,7 @@ message DBEchoMeetRecord {
int64 processduration = 26; //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 int64 processduration = 26; //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒
int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率
string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结)
string imageurls = 29; //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结
} }
message ContextStruct{ message ContextStruct{

1
apps/proto/echomeet/echomeet_msg.proto

@ -86,6 +86,7 @@ message EchomeetAddRecordReq{
int32 seconds = 5; //@go_tags(`json:"seconds"`) //时长 int32 seconds = 5; //@go_tags(`json:"seconds"`) //时长
int64 size = 6; //@go_tags(`json:"size"`) //文件大小 int64 size = 6; //@go_tags(`json:"size"`) //文件大小
string remark = 7; //@go_tags(`json:"remark"`) //用户备注(参与AI总结) string remark = 7; //@go_tags(`json:"remark"`) //用户备注(参与AI总结)
repeated string imageurls = 8; //@go_tags(`json:"imageurls"`) //注释图片url列表(作为多模态内容参与AI总结)
} }
message EchomeetAddRecordResp{ message EchomeetAddRecordResp{

64
apps/services/modules/echomeet/CLAUDE.md

@ -0,0 +1,64 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> 根 [CLAUDE.md](../../../../CLAUDE.md) 描述整体架构(lego 框架、反射注册路由、pb 生成);本文件只补充 `echomeet` 模块自身。
## 模块职责
`echomeet` 是「会议记录」模块:上传音频 → **转写(Transcribe)** → **翻译(Translate)** → **AI 总结(Summary)**,产出 `overview`(短总结)+ `summary`(按模板的完整纪要)。由 `home` 服务装载运行([services/home/main.go](../../services/home/main.go)),不是独立服务。HTTP 接口经反射注册为 `echomeet_<方法名小写>`,前缀路由示例 `/api/home/echomeet_addrecord`。
## 核心设计:一会一服务商,三段同源
每条会议记录在 [providers.go](providers.go) 的 `ResolveServiceType` 解析出**唯一服务商**,转写/翻译/总结三步都走它,结果记在 `record.ServiceType`。解析优先级:
1. 请求显式 `service_type` →
2. 模块配置 `DefaultService` →
3. 兜底按源语言:字节支持该语言走字节,否则阿里。
四套服务商,每套三件套(见对应 `provider_*.go`):
| ServiceType | 转写 | 翻译 | 总结(Summarizer) |
|---|---|---|---|
| ByteDance | `sys/bytedance/audomodel` | 火山翻译 | 豆包 `sys/doubao` |
| Ali | DashScope `sys/aliyun/filetrans` | 阿里 MT | 通义千问(`sys/openai` 兼容端点)|
| Google | Cloud Speech | Cloud Translation | Gemini `sys/google/gemini` |
| Microsoft | Azure Speech | Azure Translator | Azure OpenAI(`sys/openai` 兼容端点)|
三类能力抽象为 `Transcriber` / `Translator` / `Summarizer` 接口(providers.go),运行期 `GetTranscriber/GetTranslator/GetSummarizer(svcType, language)` 按 ServiceType 路由。新增服务商 = 写一个 `provider_xxx.go` 实现三接口 + 在 `providersComp.Init` 注册进三张 map。
## 组件与异步流水线
`module.go` 装配组件:`api`(HTTP) / `model`(gorm) / `cache`(模板缓存) / `providers` / `tasks`。
[tasks.go](tasks.go) 是异步引擎:**Redis 队列 + ants 协程池 + cron 每秒调度**。
- 两条流水线,各有「等待队列 / 在执行集合」两个 list:短音频转写(`keyShortAwait/Proc`)、AI 总结(`keyAIAwait/Proc`)。队列 key 用 `service.GetTag()` + `redissys.RKey`(应用分组前缀)命名空间隔离,多应用共用一个 Redis 不串扰。
- `TaskScheduling`(cron `*/1 * * * * ?`)每秒把等待队列搬进执行集合(受 `MaxShortAudioProcess`/`MaxAIAwaitProcess` 限流),再 `pool.Invoke` 真正处理。
- **转写完成两条路径**:异步回调(字节 [api_backcall.go](api_backcall.go) / 阿里 [api_alibackcall.go](api_alibackcall.go))+ 客户端拉取时主动轮询(`PollTranscribe`,由 [api_getrecord.go](api_getrecord.go)/[api_getrecords.go](api_getrecords.go) 在状态为 Transcribing 时驱动,`TranscribeQueryMinInterval` 限频)。**没有 cron 兜底轮询**。
- 短音频走字节 flash 同步通道(`ShortAudioProcess`);长音频/其他服务商走异步 submit+query。
状态机见 `DBEchoMeetRecordState`(proto):Unknow→AwaitTranscribing→Transcribing→AwaitSummarizing→Summarizing→Completed→Readed,失败 TranscribeFail/SummarizFail。转写失败会**退还** `Meetintegral` 时长并冲正统计埋点。
## 语言码映射(core.go,易错)
输入统一是客户端 BCP-47(`zh-CN`/`en-US`…)。每个平台格式不同,core.go 逐家维护 map。**铁律:未识别语言一律返回原值,绝不 fallback 成 `zh`**——否则「未知语言→中文」会藏在调用栈深处。翻译时在 `TranslateProcess` 直接传 BCP-47,由各 provider 内部转换,不要提前转(会被二次转换误判)。
## AI 总结输入约定(含图片)
`AIProcess`(tasks.go)拼 `originalText`:把 `translate`(已翻译的分句)按 `[说话人]:内容` 拼接;若 `record.Remark`(用户创建时填的备注)非空,用语言中立的结构化标签包裹:`<remark>…</remark>\n\n<transcript>\n…\n</transcript>`——**不要硬编码某语言的提示词**,避免污染多语言总结。模板 `template.Template`/`template.Outline` 作为 system prompt,分别产出 `summary`/`overview`(两路并发)。
**图片注释(支持多张)**:请求 `EchomeetAddRecordReq.imageurls` 是 `repeated string`,DB `DBEchoMeetRecord.imageurls` 是 **string 列存 JSON 数组**(编码方式同 `original`:`utils.ToString` 写、`json.Unmarshal` 读)。`AIProcess` 解析出 url 列表后,作为多模态内容随 user 消息一并发给总结模型(要求所配模型支持视觉)。链路:`ChatMessage.Images []string` → 各 `Summarizer.Chat` → sys 层(豆包/通义/Azure 用 OpenAI 风格 `image_url` 直传;Gemini 在 `sys/google/gemini` 内逐张下载转 base64 内联 `inlineData`,单张失败仅跳过)。列表为空时完全走原文本路径,不影响既有行为。
## 重新生成 pb
改了 `apps/proto/echomeet/*.proto` 后需重生成(protoc-gen-go v1.36.6,已安装),并复刻源项目 `pb.py` 的 `@go_tags` 注入(覆盖 json 去掉 omitempty、追加 gorm/自定义 tag):
```bash
cd apps/services
protoc --go_out=pb --go_opt=paths=import -I ../proto ../proto/echomeet/echomeet_db.proto
protoc --go_out=pb --go_opt=paths=import -I ../proto ../proto/echomeet/echomeet_msg.proto
# 再对生成的 pb/echomeet_*.pb.go 执行 @go_tags 注入(见根 CLAUDE.md / 源项目 pb.py)
```
注意:HTTP 层用 jsoniter 按 **json tag** 收发、DB 用 **gorm tag**,protobuf 二进制 wire 不参与这些 API,所以字段的 json/gorm tag 必须正确。

6
apps/services/modules/echomeet/api_addrecord.go

@ -21,6 +21,11 @@ func (this *apiComp) AddRecord(session comm.IUserSession, req *pb.EchomeetAddRec
model *pb.DBEchoMeetRecord model *pb.DBEchoMeetRecord
err error err error
) )
// 注释图片列表与 original 同套编码:DB 用 string 列存 JSON 数组,空列表则留空串。
imageurls := ""
if len(req.Imageurls) > 0 {
imageurls = utils.ToString(req.Imageurls)
}
model = &pb.DBEchoMeetRecord{ model = &pb.DBEchoMeetRecord{
Uid: session.GetUserId(), Uid: session.GetUserId(),
Creationtime: time.Now().Unix(), Creationtime: time.Now().Unix(),
@ -30,6 +35,7 @@ func (this *apiComp) AddRecord(session comm.IUserSession, req *pb.EchomeetAddRec
Size: req.Size, Size: req.Size,
Rtype: req.Rtype, Rtype: req.Rtype,
Remark: req.Remark, Remark: req.Remark,
Imageurls: imageurls,
State: pb.DBEchoMeetRecordState_Unknow, State: pb.DBEchoMeetRecordState_Unknow,
} }
if err = this.module.model.addrecord(model); err != nil { if err = this.module.model.addrecord(model); err != nil {

2
apps/services/modules/echomeet/provider_ali.go

@ -99,7 +99,7 @@ func (this *aliSummarizer) Chat(ctx context.Context, messages []ChatMessage) (st
} }
msgs := make([]openai.ChatReq, 0, len(messages)) msgs := make([]openai.ChatReq, 0, len(messages))
for _, m := range messages { for _, m := range messages {
msgs = append(msgs, openai.ChatReq{Role: m.Role, Content: m.Content}) msgs = append(msgs, openai.ChatReq{Role: m.Role, Content: m.Content, Images: m.Images})
} }
resp, err := this.sys.Chat(ctx, msgs) resp, err := this.sys.Chat(ctx, msgs)
if err != nil { if err != nil {

2
apps/services/modules/echomeet/provider_bytedance.go

@ -123,7 +123,7 @@ func (this *doubaoSummarizer) Chat(ctx context.Context, messages []ChatMessage)
} }
msgs := make([]doubao.Message, 0, len(messages)) msgs := make([]doubao.Message, 0, len(messages))
for _, m := range messages { for _, m := range messages {
msgs = append(msgs, doubao.Message{Role: m.Role, Content: m.Content}) msgs = append(msgs, doubao.Message{Role: m.Role, Content: m.Content, Images: m.Images})
} }
resp, err := this.sys.Chat(ctx, msgs) resp, err := this.sys.Chat(ctx, msgs)
if err != nil { if err != nil {

2
apps/services/modules/echomeet/provider_google.go

@ -97,7 +97,7 @@ func (this *geminiSummarizer) Chat(ctx context.Context, messages []ChatMessage)
} }
msgs := make([]gemini.Message, 0, len(messages)) msgs := make([]gemini.Message, 0, len(messages))
for _, m := range messages { for _, m := range messages {
msgs = append(msgs, gemini.Message{Role: m.Role, Content: m.Content}) msgs = append(msgs, gemini.Message{Role: m.Role, Content: m.Content, Images: m.Images})
} }
resp, err := this.sys.Chat(ctx, msgs) resp, err := this.sys.Chat(ctx, msgs)
if err != nil { if err != nil {

2
apps/services/modules/echomeet/provider_microsoft.go

@ -99,7 +99,7 @@ func (this *microsoftSummarizer) Chat(ctx context.Context, messages []ChatMessag
} }
msgs := make([]openai.ChatReq, 0, len(messages)) msgs := make([]openai.ChatReq, 0, len(messages))
for _, m := range messages { for _, m := range messages {
msgs = append(msgs, openai.ChatReq{Role: m.Role, Content: m.Content}) msgs = append(msgs, openai.ChatReq{Role: m.Role, Content: m.Content, Images: m.Images})
} }
resp, err := this.sys.Chat(ctx, msgs) resp, err := this.sys.Chat(ctx, msgs)
if err != nil { if err != nil {

5
apps/services/modules/echomeet/providers.go

@ -56,10 +56,13 @@ type QueryResult struct {
Contexts []*pb.ContextStruct Contexts []*pb.ContextStruct
} }
// ChatMessage 对齐 sys/doubao / sys/google/gemini 的统一消息结构 // ChatMessage 对齐 sys/doubao / sys/google/gemini 的统一消息结构。
// Images 为可选的图片 url 列表(多模态总结用):非空时该条消息以「文本 + 图片」的多模态形式
// 发给模型;为空时退化为纯文本,行为与以前一致。要求所配模型支持视觉输入。
type ChatMessage struct { type ChatMessage struct {
Role string Role string
Content string Content string
Images []string
} }
// Transcriber 转写 provider 接口 // Transcriber 转写 provider 接口

13
apps/services/modules/echomeet/tasks.go

@ -435,19 +435,28 @@ func (this *tasksComp) AIProcess(task interface{}) {
return return
} }
this.module.Infof("AIProcess id:%d → service:%s", rec.Id, ServiceTypeName(summarizer.Name())) this.module.Infof("AIProcess id:%d → service:%s", rec.Id, ServiceTypeName(summarizer.Name()))
// 注释图片:用户创建记录时填写的 imageurls(JSON 数组字符串),解析后作为多模态内容随
// user 消息发给模型,让总结结合图片(白板/PPT/截图等)。为空/解析失败时 Images 为 nil,
// provider 退化为纯文本。
var userImages []string
if rec.Imageurls != "" {
if err := json.Unmarshal([]byte(rec.Imageurls), &userImages); err != nil {
this.module.Warnf("AIProcess id:%d 解析 imageurls 失败已忽略 err:%v", rec.Id, err)
}
}
wg.Add(2) wg.Add(2)
go func() { go func() {
defer wg.Done() defer wg.Done()
summary, err1 = summarizer.Chat(context.Background(), []ChatMessage{ summary, err1 = summarizer.Chat(context.Background(), []ChatMessage{
{Role: "system", Content: template.Template}, {Role: "system", Content: template.Template},
{Role: "user", Content: originalText}, {Role: "user", Content: originalText, Images: userImages},
}) })
}() }()
go func() { go func() {
defer wg.Done() defer wg.Done()
overview, err2 = summarizer.Chat(context.Background(), []ChatMessage{ overview, err2 = summarizer.Chat(context.Background(), []ChatMessage{
{Role: "system", Content: template.Outline}, {Role: "system", Content: template.Outline},
{Role: "user", Content: originalText}, {Role: "user", Content: originalText, Images: userImages},
}) })
}() }()
wg.Wait() wg.Wait()

13
apps/services/pb/echomeet_db.pb.go

@ -311,6 +311,7 @@ type DBEchoMeetRecord struct {
Processduration int64 `protobuf:"varint,26,opt,name=processduration,proto3" json:"processduration" gorm:"processduration"` //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 Processduration int64 `protobuf:"varint,26,opt,name=processduration,proto3" json:"processduration" gorm:"processduration"` //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒
Lastquerytime int64 `protobuf:"varint,27,opt,name=lastquerytime,proto3" json:"lastquerytime" gorm:"lastquerytime"` //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 Lastquerytime int64 `protobuf:"varint,27,opt,name=lastquerytime,proto3" json:"lastquerytime" gorm:"lastquerytime"` //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率
Remark string `protobuf:"bytes,28,opt,name=remark,proto3" json:"remark" gorm:"remark"` //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) Remark string `protobuf:"bytes,28,opt,name=remark,proto3" json:"remark" gorm:"remark"` //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结)
Imageurls string `protobuf:"bytes,29,opt,name=imageurls,proto3" json:"imageurls" gorm:"imageurls"` //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -541,6 +542,13 @@ func (x *DBEchoMeetRecord) GetRemark() string {
return "" return ""
} }
func (x *DBEchoMeetRecord) GetImageurls() string {
if x != nil {
return x.Imageurls
}
return ""
}
type ContextStruct struct { type ContextStruct struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meetingid uint64 `protobuf:"varint,1,opt,name=Meetingid,proto3" json:"meetingid"` //@go_tags(`json:"meetingid"`) 会议id Meetingid uint64 `protobuf:"varint,1,opt,name=Meetingid,proto3" json:"meetingid"` //@go_tags(`json:"meetingid"`) 会议id
@ -985,7 +993,7 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" +
"\aoutline\x18\n" + "\aoutline\x18\n" +
" \x01(\tR\aoutline\x12\x1a\n" + " \x01(\tR\aoutline\x12\x1a\n" +
"\btemplate\x18\v \x01(\tR\btemplate\x12\x12\n" + "\btemplate\x18\v \x01(\tR\btemplate\x12\x12\n" +
"\x04sort\x18\f \x01(\x05R\x04sort\"\xdf\x06\n" + "\x04sort\x18\f \x01(\x05R\x04sort\"\xfd\x06\n" +
"\x10DBEchoMeetRecord\x12\x0e\n" + "\x10DBEchoMeetRecord\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12,\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\x12,\n" +
@ -1019,7 +1027,8 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" +
"\tstarttime\x18\x19 \x01(\x03R\tstarttime\x12(\n" + "\tstarttime\x18\x19 \x01(\x03R\tstarttime\x12(\n" +
"\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" + "\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" +
"\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" + "\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" +
"\x06remark\x18\x1c \x01(\tR\x06remark\"\x99\x01\n" + "\x06remark\x18\x1c \x01(\tR\x06remark\x12\x1c\n" +
"\timageurls\x18\x1d \x01(\tR\timageurls\"\x99\x01\n" +
"\rContextStruct\x12\x1c\n" + "\rContextStruct\x12\x1c\n" +
"\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" + "\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" +
"\aContent\x18\x02 \x01(\tR\aContent\x12\x1c\n" + "\aContent\x18\x02 \x01(\tR\aContent\x12\x1c\n" +

27
apps/services/pb/echomeet_msg.pb.go

@ -727,13 +727,14 @@ func (*EchomeetPutRecordsResp) Descriptor() ([]byte, []int) {
type EchomeetAddRecordReq struct { type EchomeetAddRecordReq struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title"` //@go_tags(`json:"title"`) //标题 Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title"` //@go_tags(`json:"title"`) //标题
Rtype string `protobuf:"bytes,2,opt,name=rtype,proto3" json:"rtype"` //@go_tags(`json:"rtype"`) //类型 Rtype string `protobuf:"bytes,2,opt,name=rtype,proto3" json:"rtype"` //@go_tags(`json:"rtype"`) //类型
Filepath string `protobuf:"bytes,3,opt,name=filepath,proto3" json:"filepath"` //@go_tags(`json:"filepath"`) //音频文件路径 Filepath string `protobuf:"bytes,3,opt,name=filepath,proto3" json:"filepath"` //@go_tags(`json:"filepath"`) //音频文件路径
Original []*ContextStruct `protobuf:"bytes,4,rep,name=original,proto3" json:"original" gorm:"original"` //@go_tags(`gorm:"original" json:"original"`) 听写内容 Original []*ContextStruct `protobuf:"bytes,4,rep,name=original,proto3" json:"original" gorm:"original"` //@go_tags(`gorm:"original" json:"original"`) 听写内容
Seconds int32 `protobuf:"varint,5,opt,name=seconds,proto3" json:"seconds"` //@go_tags(`json:"seconds"`) //时长 Seconds int32 `protobuf:"varint,5,opt,name=seconds,proto3" json:"seconds"` //@go_tags(`json:"seconds"`) //时长
Size int64 `protobuf:"varint,6,opt,name=size,proto3" json:"size"` //@go_tags(`json:"size"`) //文件大小 Size int64 `protobuf:"varint,6,opt,name=size,proto3" json:"size"` //@go_tags(`json:"size"`) //文件大小
Remark string `protobuf:"bytes,7,opt,name=remark,proto3" json:"remark"` //@go_tags(`json:"remark"`) //用户备注(参与AI总结) Remark string `protobuf:"bytes,7,opt,name=remark,proto3" json:"remark"` //@go_tags(`json:"remark"`) //用户备注(参与AI总结)
Imageurls []string `protobuf:"bytes,8,rep,name=imageurls,proto3" json:"imageurls"` //@go_tags(`json:"imageurls"`) //注释图片url列表(作为多模态内容参与AI总结)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -817,6 +818,13 @@ func (x *EchomeetAddRecordReq) GetRemark() string {
return "" return ""
} }
func (x *EchomeetAddRecordReq) GetImageurls() []string {
if x != nil {
return x.Imageurls
}
return nil
}
type EchomeetAddRecordResp struct { type EchomeetAddRecordResp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Record *DBEchoMeetRecord `protobuf:"bytes,1,opt,name=record,proto3" json:"record"` //@go_tags(`json:"record"`) //会议记录 Record *DBEchoMeetRecord `protobuf:"bytes,1,opt,name=record,proto3" json:"record"` //@go_tags(`json:"record"`) //会议记录
@ -1888,7 +1896,7 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" +
"\x15EchomeetPutRecordsReq\x12+\n" + "\x15EchomeetPutRecordsReq\x12+\n" +
"\arecords\x18\x01 \x03(\v2\x11.DBEchoMeetRecordR\arecords\x12\x16\n" + "\arecords\x18\x01 \x03(\v2\x11.DBEchoMeetRecordR\arecords\x12\x16\n" +
"\x06delids\x18\x02 \x03(\tR\x06delids\"\x18\n" + "\x06delids\x18\x02 \x03(\tR\x06delids\"\x18\n" +
"\x16EchomeetPutRecordsResp\"\xd0\x01\n" + "\x16EchomeetPutRecordsResp\"\xee\x01\n" +
"\x14EchomeetAddRecordReq\x12\x14\n" + "\x14EchomeetAddRecordReq\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x14\n" +
"\x05rtype\x18\x02 \x01(\tR\x05rtype\x12\x1a\n" + "\x05rtype\x18\x02 \x01(\tR\x05rtype\x12\x1a\n" +
@ -1896,7 +1904,8 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" +
"\boriginal\x18\x04 \x03(\v2\x0e.ContextStructR\boriginal\x12\x18\n" + "\boriginal\x18\x04 \x03(\v2\x0e.ContextStructR\boriginal\x12\x18\n" +
"\aseconds\x18\x05 \x01(\x05R\aseconds\x12\x12\n" + "\aseconds\x18\x05 \x01(\x05R\aseconds\x12\x12\n" +
"\x04size\x18\x06 \x01(\x03R\x04size\x12\x16\n" + "\x04size\x18\x06 \x01(\x03R\x04size\x12\x16\n" +
"\x06remark\x18\a \x01(\tR\x06remark\"B\n" + "\x06remark\x18\a \x01(\tR\x06remark\x12\x1c\n" +
"\timageurls\x18\b \x03(\tR\timageurls\"B\n" +
"\x15EchomeetAddRecordResp\x12)\n" + "\x15EchomeetAddRecordResp\x12)\n" +
"\x06record\x18\x01 \x01(\v2\x11.DBEchoMeetRecordR\x06record\"A\n" + "\x06record\x18\x01 \x01(\v2\x11.DBEchoMeetRecordR\x06record\"A\n" +
"\x13EchomeetUpRecordReq\x12\x0e\n" + "\x13EchomeetUpRecordReq\x12\x0e\n" +

3
apps/services/sys/doubao/core.go

@ -6,6 +6,9 @@ type (
Message struct { Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
// Images 可选图片 url 列表:非空时以多模态格式(text + image_url)发送,用于豆包视觉模型。
// 为空时按纯文本发送,行为不变。
Images []string `json:"images,omitempty"`
} }
//回应流式切片 //回应流式切片
ChatResponseChoice struct { ChatResponseChoice struct {

37
apps/services/sys/doubao/doubao.go

@ -29,6 +29,31 @@ type DouBao struct {
client *arkruntime.Client client *arkruntime.Client
} }
// toContent 将统一 Message 转为火山方舟消息内容:
// 无图片用 StringValue(纯文本,保持原行为);有图片用 ListValue(text + image_url 多模态)。
func toContent(v Message) *model.ChatCompletionMessageContent {
if len(v.Images) == 0 {
return &model.ChatCompletionMessageContent{StringValue: volcengine.String(v.Content)}
}
parts := make([]*model.ChatCompletionMessageContentPart, 0, len(v.Images)+1)
if v.Content != "" {
parts = append(parts, &model.ChatCompletionMessageContentPart{
Type: model.ChatCompletionMessageContentPartTypeText,
Text: v.Content,
})
}
for _, url := range v.Images {
if url == "" {
continue
}
parts = append(parts, &model.ChatCompletionMessageContentPart{
Type: model.ChatCompletionMessageContentPartTypeImageURL,
ImageURL: &model.ChatMessageImageURL{URL: url},
})
}
return &model.ChatCompletionMessageContent{ListValue: parts}
}
func (this *DouBao) Chat(ctx context.Context, msgs []Message) (result *ChatResponseChoice, err error) { func (this *DouBao) Chat(ctx context.Context, msgs []Message) (result *ChatResponseChoice, err error) {
var ( var (
messages []*model.ChatCompletionMessage = make([]*model.ChatCompletionMessage, len(msgs)) messages []*model.ChatCompletionMessage = make([]*model.ChatCompletionMessage, len(msgs))
@ -38,10 +63,8 @@ func (this *DouBao) Chat(ctx context.Context, msgs []Message) (result *ChatRespo
stime := time.Now() stime := time.Now()
for i, v := range msgs { for i, v := range msgs {
messages[i] = &model.ChatCompletionMessage{ messages[i] = &model.ChatCompletionMessage{
Role: v.Role, Role: v.Role,
Content: &model.ChatCompletionMessageContent{ Content: toContent(v),
StringValue: volcengine.String(v.Content),
},
} }
} }
req = model.CreateChatCompletionRequest{ req = model.CreateChatCompletionRequest{
@ -84,10 +107,8 @@ func (this *DouBao) ChatForSteams(ctx context.Context, msgs []Message, choiceCha
stime := time.Now() stime := time.Now()
for i, v := range msgs { for i, v := range msgs {
messages[i] = &model.ChatCompletionMessage{ messages[i] = &model.ChatCompletionMessage{
Role: v.Role, Role: v.Role,
Content: &model.ChatCompletionMessageContent{ Content: toContent(v),
StringValue: volcengine.String(v.Content),
},
} }
} }
req = model.CreateChatCompletionRequest{ req = model.CreateChatCompletionRequest{

3
apps/services/sys/google/gemini/core.go

@ -7,6 +7,9 @@ type (
Message struct { Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
// Images 可选图片 url 列表:非空时下载并以 inlineData 形式随该消息发给 Gemini 视觉模型。
// 为空时按纯文本发送,行为不变。
Images []string `json:"images,omitempty"`
} }
// ChatResponseChoice 与 sys/doubao 保持一致 // ChatResponseChoice 与 sys/doubao 保持一致

91
apps/services/sys/google/gemini/gemini.go

@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"yunyan/lego/sys/log" "yunyan/lego/sys/log"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -35,7 +36,15 @@ func newSys(options Options) (sys *Gemini, err error) {
// ==================== 请求/响应结构 ==================== // ==================== 请求/响应结构 ====================
type genPart struct { type genPart struct {
Text string `json:"text"` Text string `json:"text,omitempty"`
InlineData *genInlineData `json:"inlineData,omitempty"`
}
// genInlineData 内联二进制数据(图片等)。Gemini REST 的 fileData.fileUri 只接受 Google 托管 URI,
// 任意公网图片 url 需下载后 base64 内联,故走 inlineData。
type genInlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"` // base64
} }
type genContent struct { type genContent struct {
@ -76,7 +85,10 @@ type generateContentResponse struct {
// - role=system → systemInstruction // - role=system → systemInstruction
// - role=user → contents[role=user] // - role=user → contents[role=user]
// - role=assistant/ai/model → contents[role=model] // - role=assistant/ai/model → contents[role=model]
func buildRequest(msgs []Message) *generateContentRequest { //
// user 消息带 Images 时,把每张图片下载、base64 后作为 inlineData 追加到该轮 parts;
// 单张下载失败仅跳过该图并记日志,不影响文本总结。
func (this *Gemini) buildRequest(ctx context.Context, msgs []Message) *generateContentRequest {
req := &generateContentRequest{ req := &generateContentRequest{
Contents: make([]genContent, 0, len(msgs)), Contents: make([]genContent, 0, len(msgs)),
} }
@ -91,10 +103,22 @@ func buildRequest(msgs []Message) *generateContentRequest {
Parts: []genPart{{Text: m.Content}}, Parts: []genPart{{Text: m.Content}},
}) })
default: // user / 其他 default: // user / 其他
req.Contents = append(req.Contents, genContent{ parts := make([]genPart, 0, len(m.Images)+1)
Role: "user", if m.Content != "" {
Parts: []genPart{{Text: m.Content}}, parts = append(parts, genPart{Text: m.Content})
}) }
for _, url := range m.Images {
if url == "" {
continue
}
inline, err := this.fetchInlineImage(ctx, url)
if err != nil {
this.options.Log.Warnf("gemini: 拉取图片失败已跳过 url=%s err=%v", url, err)
continue
}
parts = append(parts, genPart{InlineData: inline})
}
req.Contents = append(req.Contents, genContent{Role: "user", Parts: parts})
} }
} }
if len(sysParts) > 0 { if len(sysParts) > 0 {
@ -103,6 +127,57 @@ func buildRequest(msgs []Message) *generateContentRequest {
return req return req
} }
// fetchInlineImage 下载图片并转为 Gemini inlineData(base64 + mimeType)。
// mimeType 取响应头 Content-Type,缺失时回退按 url 后缀推断,再兜底 image/jpeg。
func (this *Gemini) fetchInlineImage(ctx context.Context, url string) (*genInlineData, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := this.client.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("status=%d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
mime := resp.Header.Get("Content-Type")
if i := strings.IndexByte(mime, ';'); i >= 0 { // 去掉 "image/png; charset=..." 之类后缀
mime = strings.TrimSpace(mime[:i])
}
if !strings.HasPrefix(mime, "image/") {
mime = mimeFromURL(url)
}
return &genInlineData{MimeType: mime, Data: base64.StdEncoding.EncodeToString(data)}, nil
}
// mimeFromURL 按 url 扩展名推断图片 mime,未知兜底 image/jpeg
func mimeFromURL(url string) string {
u := strings.ToLower(url)
if i := strings.IndexAny(u, "?#"); i >= 0 {
u = u[:i]
}
switch {
case strings.HasSuffix(u, ".png"):
return "image/png"
case strings.HasSuffix(u, ".webp"):
return "image/webp"
case strings.HasSuffix(u, ".gif"):
return "image/gif"
case strings.HasSuffix(u, ".heic"):
return "image/heic"
case strings.HasSuffix(u, ".heif"):
return "image/heif"
default:
return "image/jpeg"
}
}
func (this *Gemini) endpointFor(action string) string { func (this *Gemini) endpointFor(action string) string {
return fmt.Sprintf("%s/v1beta/models/%s:%s?key=%s", return fmt.Sprintf("%s/v1beta/models/%s:%s?key=%s",
strings.TrimRight(this.options.Endpoint, "/"), strings.TrimRight(this.options.Endpoint, "/"),
@ -114,7 +189,7 @@ func (this *Gemini) endpointFor(action string) string {
func (this *Gemini) Chat(ctx context.Context, msgs []Message) (result *ChatResponseChoice, err error) { func (this *Gemini) Chat(ctx context.Context, msgs []Message) (result *ChatResponseChoice, err error) {
stime := time.Now() stime := time.Now()
reqBody := buildRequest(msgs) reqBody := this.buildRequest(ctx, msgs)
body, err := json.Marshal(reqBody) body, err := json.Marshal(reqBody)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal request: %w", err) return nil, fmt.Errorf("marshal request: %w", err)
@ -171,7 +246,7 @@ func (this *Gemini) Chat(ctx context.Context, msgs []Message) (result *ChatRespo
func (this *Gemini) ChatForSteams(ctx context.Context, msgs []Message, choiceChan chan *ChatResponseChoice) (err error) { func (this *Gemini) ChatForSteams(ctx context.Context, msgs []Message, choiceChan chan *ChatResponseChoice) (err error) {
defer close(choiceChan) defer close(choiceChan)
stime := time.Now() stime := time.Now()
reqBody := buildRequest(msgs) reqBody := this.buildRequest(ctx, msgs)
body, err := json.Marshal(reqBody) body, err := json.Marshal(reqBody)
if err != nil { if err != nil {
return fmt.Errorf("marshal request: %w", err) return fmt.Errorf("marshal request: %w", err)

3
apps/services/sys/openai/core.go

@ -11,6 +11,9 @@ type (
ChatReq struct { ChatReq struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
// Images 可选图片 url 列表:非空时该消息以 OpenAI 多模态格式(text + image_url)发送,
// 用于视觉模型(如通义千问-VL / Azure OpenAI 视觉模型)。为空时按纯文本发送。
Images []string `json:"images,omitempty"`
} }
Parameters struct { Parameters struct {
Type string `json:"type"` Type string `json:"type"`

33
apps/services/sys/openai/openai.go

@ -34,6 +34,29 @@ func (this *Openai) RegisterTools(tool ITool) {
} }
// toChatMessage 将统一的 ChatReq 转为 go-openai 消息:
// 无图片时用 Content(纯文本,保持原行为);有图片时改用 MultiContent(text + image_url),
// 注意 OpenAI 协议要求多模态时 Content 必须为空,否则部分网关会报错。
func toChatMessage(v ChatReq) openai.ChatCompletionMessage {
if len(v.Images) == 0 {
return openai.ChatCompletionMessage{Role: v.Role, Content: v.Content}
}
parts := make([]openai.ChatMessagePart, 0, len(v.Images)+1)
if v.Content != "" {
parts = append(parts, openai.ChatMessagePart{Type: openai.ChatMessagePartTypeText, Text: v.Content})
}
for _, url := range v.Images {
if url == "" {
continue
}
parts = append(parts, openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{URL: url},
})
}
return openai.ChatCompletionMessage{Role: v.Role, MultiContent: parts}
}
func (this *Openai) Chat(ctx context.Context, msgs []ChatReq) (resp *ChatResponse, err error) { func (this *Openai) Chat(ctx context.Context, msgs []ChatReq) (resp *ChatResponse, err error) {
var ( var (
count int = 0 count int = 0
@ -45,10 +68,7 @@ func (this *Openai) Chat(ctx context.Context, msgs []ChatReq) (resp *ChatRespons
) )
messages = make([]openai.ChatCompletionMessage, len(msgs)) messages = make([]openai.ChatCompletionMessage, len(msgs))
for i, v := range msgs { for i, v := range msgs {
messages[i] = openai.ChatCompletionMessage{ messages[i] = toChatMessage(v)
Role: v.Role,
Content: v.Content,
}
} }
// 第一次请求(获取工具调用) // 第一次请求(获取工具调用)
message, err = this.sendMessage(ctx, messages, this.toolinfos) message, err = this.sendMessage(ctx, messages, this.toolinfos)
@ -99,10 +119,7 @@ func (this *Openai) ChatForSteams(ctx context.Context, msgs []ChatReq, choiceCha
) )
messages = make([]openai.ChatCompletionMessage, len(msgs)) messages = make([]openai.ChatCompletionMessage, len(msgs))
for i, v := range msgs { for i, v := range msgs {
messages[i] = openai.ChatCompletionMessage{ messages[i] = toChatMessage(v)
Role: v.Role,
Content: v.Content,
}
} }
// 第一次请求(获取工具调用) // 第一次请求(获取工具调用)
message, err = this.sendMessagForSteam(ctx, messages, this.toolinfos, choiceChan) message, err = this.sendMessagForSteam(ctx, messages, this.toolinfos, choiceChan)

Loading…
Cancel
Save