diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..74c6520f --- /dev/null +++ b/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// # 每服务一份 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/.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 [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) + ``` + 就会被注册成路由 `_`(如 `api_getfactorys`)。新增接口 = 新建 `modules//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//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/...`,并核对接口签名是否符合上文反射注册约定。 diff --git a/apps/proto/echomeet/echomeet_db.proto b/apps/proto/echomeet/echomeet_db.proto index 5c224637..8e72cbb2 100644 --- a/apps/proto/echomeet/echomeet_db.proto +++ b/apps/proto/echomeet/echomeet_db.proto @@ -71,6 +71,7 @@ message DBEchoMeetRecord { int64 processduration = 26; //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 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{ diff --git a/apps/proto/echomeet/echomeet_msg.proto b/apps/proto/echomeet/echomeet_msg.proto index 9132f60c..220c396d 100644 --- a/apps/proto/echomeet/echomeet_msg.proto +++ b/apps/proto/echomeet/echomeet_msg.proto @@ -86,6 +86,7 @@ message EchomeetAddRecordReq{ int32 seconds = 5; //@go_tags(`json:"seconds"`) //时长 int64 size = 6; //@go_tags(`json:"size"`) //文件大小 string remark = 7; //@go_tags(`json:"remark"`) //用户备注(参与AI总结) + repeated string imageurls = 8; //@go_tags(`json:"imageurls"`) //注释图片url列表(作为多模态内容参与AI总结) } message EchomeetAddRecordResp{ diff --git a/apps/services/modules/echomeet/CLAUDE.md b/apps/services/modules/echomeet/CLAUDE.md new file mode 100644 index 00000000..3f777bbf --- /dev/null +++ b/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`(用户创建时填的备注)非空,用语言中立的结构化标签包裹:`…\n\n\n…\n`——**不要硬编码某语言的提示词**,避免污染多语言总结。模板 `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 必须正确。 diff --git a/apps/services/modules/echomeet/api_addrecord.go b/apps/services/modules/echomeet/api_addrecord.go index 5882b009..8b7e0f6c 100644 --- a/apps/services/modules/echomeet/api_addrecord.go +++ b/apps/services/modules/echomeet/api_addrecord.go @@ -21,6 +21,11 @@ func (this *apiComp) AddRecord(session comm.IUserSession, req *pb.EchomeetAddRec model *pb.DBEchoMeetRecord err error ) + // 注释图片列表与 original 同套编码:DB 用 string 列存 JSON 数组,空列表则留空串。 + imageurls := "" + if len(req.Imageurls) > 0 { + imageurls = utils.ToString(req.Imageurls) + } model = &pb.DBEchoMeetRecord{ Uid: session.GetUserId(), Creationtime: time.Now().Unix(), @@ -30,6 +35,7 @@ func (this *apiComp) AddRecord(session comm.IUserSession, req *pb.EchomeetAddRec Size: req.Size, Rtype: req.Rtype, Remark: req.Remark, + Imageurls: imageurls, State: pb.DBEchoMeetRecordState_Unknow, } if err = this.module.model.addrecord(model); err != nil { diff --git a/apps/services/modules/echomeet/provider_ali.go b/apps/services/modules/echomeet/provider_ali.go index 20c0114b..b5c8d4fd 100644 --- a/apps/services/modules/echomeet/provider_ali.go +++ b/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)) 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) if err != nil { diff --git a/apps/services/modules/echomeet/provider_bytedance.go b/apps/services/modules/echomeet/provider_bytedance.go index 1552ecbe..430a3f22 100644 --- a/apps/services/modules/echomeet/provider_bytedance.go +++ b/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)) 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) if err != nil { diff --git a/apps/services/modules/echomeet/provider_google.go b/apps/services/modules/echomeet/provider_google.go index e2ea340b..d9284d92 100644 --- a/apps/services/modules/echomeet/provider_google.go +++ b/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)) 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) if err != nil { diff --git a/apps/services/modules/echomeet/provider_microsoft.go b/apps/services/modules/echomeet/provider_microsoft.go index 20bb0421..dc62971a 100644 --- a/apps/services/modules/echomeet/provider_microsoft.go +++ b/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)) 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) if err != nil { diff --git a/apps/services/modules/echomeet/providers.go b/apps/services/modules/echomeet/providers.go index 8eb06431..fe44eb4e 100644 --- a/apps/services/modules/echomeet/providers.go +++ b/apps/services/modules/echomeet/providers.go @@ -56,10 +56,13 @@ type QueryResult struct { Contexts []*pb.ContextStruct } -// ChatMessage 对齐 sys/doubao / sys/google/gemini 的统一消息结构 +// ChatMessage 对齐 sys/doubao / sys/google/gemini 的统一消息结构。 +// Images 为可选的图片 url 列表(多模态总结用):非空时该条消息以「文本 + 图片」的多模态形式 +// 发给模型;为空时退化为纯文本,行为与以前一致。要求所配模型支持视觉输入。 type ChatMessage struct { Role string Content string + Images []string } // Transcriber 转写 provider 接口 diff --git a/apps/services/modules/echomeet/tasks.go b/apps/services/modules/echomeet/tasks.go index cb6a934c..0ce9d855 100644 --- a/apps/services/modules/echomeet/tasks.go +++ b/apps/services/modules/echomeet/tasks.go @@ -435,19 +435,28 @@ func (this *tasksComp) AIProcess(task interface{}) { return } 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) go func() { defer wg.Done() summary, err1 = summarizer.Chat(context.Background(), []ChatMessage{ {Role: "system", Content: template.Template}, - {Role: "user", Content: originalText}, + {Role: "user", Content: originalText, Images: userImages}, }) }() go func() { defer wg.Done() overview, err2 = summarizer.Chat(context.Background(), []ChatMessage{ {Role: "system", Content: template.Outline}, - {Role: "user", Content: originalText}, + {Role: "user", Content: originalText, Images: userImages}, }) }() wg.Wait() diff --git a/apps/services/pb/echomeet_db.pb.go b/apps/services/pb/echomeet_db.pb.go index bf1a014a..4fd458b6 100644 --- a/apps/services/pb/echomeet_db.pb.go +++ b/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"`) 处理耗时 秒 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总结) + 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 sizeCache protoimpl.SizeCache } @@ -541,6 +542,13 @@ func (x *DBEchoMeetRecord) GetRemark() string { return "" } +func (x *DBEchoMeetRecord) GetImageurls() string { + if x != nil { + return x.Imageurls + } + return "" +} + type ContextStruct struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + " \x01(\tR\aoutline\x12\x1a\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" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\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" + "\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\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" + "\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" + "\aContent\x18\x02 \x01(\tR\aContent\x12\x1c\n" + diff --git a/apps/services/pb/echomeet_msg.pb.go b/apps/services/pb/echomeet_msg.pb.go index 707ab841..d5193638 100644 --- a/apps/services/pb/echomeet_msg.pb.go +++ b/apps/services/pb/echomeet_msg.pb.go @@ -727,13 +727,14 @@ func (*EchomeetPutRecordsResp) Descriptor() ([]byte, []int) { type EchomeetAddRecordReq struct { state protoimpl.MessageState `protogen:"open.v1"` - 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"`) //类型 - 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"`) 听写内容 - 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"`) //文件大小 - Remark string `protobuf:"bytes,7,opt,name=remark,proto3" json:"remark"` //@go_tags(`json:"remark"`) //用户备注(参与AI总结) + 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"`) //类型 + 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"`) 听写内容 + 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"`) //文件大小 + 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 sizeCache protoimpl.SizeCache } @@ -817,6 +818,13 @@ func (x *EchomeetAddRecordReq) GetRemark() string { return "" } +func (x *EchomeetAddRecordReq) GetImageurls() []string { + if x != nil { + return x.Imageurls + } + return nil +} + type EchomeetAddRecordResp struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + "\arecords\x18\x01 \x03(\v2\x11.DBEchoMeetRecordR\arecords\x12\x16\n" + "\x06delids\x18\x02 \x03(\tR\x06delids\"\x18\n" + - "\x16EchomeetPutRecordsResp\"\xd0\x01\n" + + "\x16EchomeetPutRecordsResp\"\xee\x01\n" + "\x14EchomeetAddRecordReq\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x14\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" + "\aseconds\x18\x05 \x01(\x05R\aseconds\x12\x12\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" + "\x06record\x18\x01 \x01(\v2\x11.DBEchoMeetRecordR\x06record\"A\n" + "\x13EchomeetUpRecordReq\x12\x0e\n" + diff --git a/apps/services/sys/doubao/core.go b/apps/services/sys/doubao/core.go index 9e212a20..c6116bc8 100644 --- a/apps/services/sys/doubao/core.go +++ b/apps/services/sys/doubao/core.go @@ -6,6 +6,9 @@ type ( Message struct { Role string `json:"role"` Content string `json:"content"` + // Images 可选图片 url 列表:非空时以多模态格式(text + image_url)发送,用于豆包视觉模型。 + // 为空时按纯文本发送,行为不变。 + Images []string `json:"images,omitempty"` } //回应流式切片 ChatResponseChoice struct { diff --git a/apps/services/sys/doubao/doubao.go b/apps/services/sys/doubao/doubao.go index 959cb4d7..27957301 100644 --- a/apps/services/sys/doubao/doubao.go +++ b/apps/services/sys/doubao/doubao.go @@ -29,6 +29,31 @@ type DouBao struct { 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) { var ( 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() for i, v := range msgs { messages[i] = &model.ChatCompletionMessage{ - Role: v.Role, - Content: &model.ChatCompletionMessageContent{ - StringValue: volcengine.String(v.Content), - }, + Role: v.Role, + Content: toContent(v), } } req = model.CreateChatCompletionRequest{ @@ -84,10 +107,8 @@ func (this *DouBao) ChatForSteams(ctx context.Context, msgs []Message, choiceCha stime := time.Now() for i, v := range msgs { messages[i] = &model.ChatCompletionMessage{ - Role: v.Role, - Content: &model.ChatCompletionMessageContent{ - StringValue: volcengine.String(v.Content), - }, + Role: v.Role, + Content: toContent(v), } } req = model.CreateChatCompletionRequest{ diff --git a/apps/services/sys/google/gemini/core.go b/apps/services/sys/google/gemini/core.go index e4497a21..2ae13048 100644 --- a/apps/services/sys/google/gemini/core.go +++ b/apps/services/sys/google/gemini/core.go @@ -7,6 +7,9 @@ type ( Message struct { Role string `json:"role"` Content string `json:"content"` + // Images 可选图片 url 列表:非空时下载并以 inlineData 形式随该消息发给 Gemini 视觉模型。 + // 为空时按纯文本发送,行为不变。 + Images []string `json:"images,omitempty"` } // ChatResponseChoice 与 sys/doubao 保持一致 diff --git a/apps/services/sys/google/gemini/gemini.go b/apps/services/sys/google/gemini/gemini.go index 78193ca3..03936352 100644 --- a/apps/services/sys/google/gemini/gemini.go +++ b/apps/services/sys/google/gemini/gemini.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "yunyan/lego/sys/log" + "encoding/base64" "encoding/json" "fmt" "io" @@ -35,7 +36,15 @@ func newSys(options Options) (sys *Gemini, err error) { // ==================== 请求/响应结构 ==================== 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 { @@ -76,7 +85,10 @@ type generateContentResponse struct { // - role=system → systemInstruction // - role=user → contents[role=user] // - 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{ Contents: make([]genContent, 0, len(msgs)), } @@ -91,10 +103,22 @@ func buildRequest(msgs []Message) *generateContentRequest { Parts: []genPart{{Text: m.Content}}, }) default: // user / 其他 - req.Contents = append(req.Contents, genContent{ - Role: "user", - Parts: []genPart{{Text: m.Content}}, - }) + parts := make([]genPart, 0, len(m.Images)+1) + if 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 { @@ -103,6 +127,57 @@ func buildRequest(msgs []Message) *generateContentRequest { 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 { return fmt.Sprintf("%s/v1beta/models/%s:%s?key=%s", 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) { stime := time.Now() - reqBody := buildRequest(msgs) + reqBody := this.buildRequest(ctx, msgs) body, err := json.Marshal(reqBody) if err != nil { 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) { defer close(choiceChan) stime := time.Now() - reqBody := buildRequest(msgs) + reqBody := this.buildRequest(ctx, msgs) body, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("marshal request: %w", err) diff --git a/apps/services/sys/openai/core.go b/apps/services/sys/openai/core.go index fd4f093e..7b682784 100644 --- a/apps/services/sys/openai/core.go +++ b/apps/services/sys/openai/core.go @@ -11,6 +11,9 @@ type ( ChatReq struct { Role string `json:"role"` Content string `json:"content"` + // Images 可选图片 url 列表:非空时该消息以 OpenAI 多模态格式(text + image_url)发送, + // 用于视觉模型(如通义千问-VL / Azure OpenAI 视觉模型)。为空时按纯文本发送。 + Images []string `json:"images,omitempty"` } Parameters struct { Type string `json:"type"` diff --git a/apps/services/sys/openai/openai.go b/apps/services/sys/openai/openai.go index a383cb90..9e75c9c6 100644 --- a/apps/services/sys/openai/openai.go +++ b/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) { var ( count int = 0 @@ -45,10 +68,7 @@ func (this *Openai) Chat(ctx context.Context, msgs []ChatReq) (resp *ChatRespons ) messages = make([]openai.ChatCompletionMessage, len(msgs)) for i, v := range msgs { - messages[i] = openai.ChatCompletionMessage{ - Role: v.Role, - Content: v.Content, - } + messages[i] = toChatMessage(v) } // 第一次请求(获取工具调用) 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)) for i, v := range msgs { - messages[i] = openai.ChatCompletionMessage{ - Role: v.Role, - Content: v.Content, - } + messages[i] = toChatMessage(v) } // 第一次请求(获取工具调用) message, err = this.sendMessagForSteam(ctx, messages, this.toolinfos, choiceChan)