feat(release): migrate GeekAI v4.3.0 to open source

- Sync backend and frontend from GeekAI Plus v4.3.0

- Remove commercial License flows and update open-source deployment defaults

- Preserve Docker Compose deployment and bump image tags to v4.3.0

BREAKING CHANGE: commercial License configuration and related endpoints are removed
This commit is contained in:
RockYang
2026-08-11 14:51:20 +08:00
parent 37e024acea
commit 9ccff4efbc
219 changed files with 29212 additions and 10802 deletions
Binary file not shown.
+404
View File
@@ -0,0 +1,404 @@
package ppt
import (
"bytes"
"context"
_ "embed"
"fmt"
"geekai/core/types"
"image"
_ "image/gif"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode"
"github.com/jung-kurt/gofpdf/v2"
"github.com/ktye/pptx"
_ "golang.org/x/image/webp"
)
//go:embed embed/minimal.pptx
var minimalPptxTemplate []byte
const (
exportHTTPTimeout = 60 * time.Second
// 必须与 embed/minimal.pptx 中 p:sldSz 一致(当前模板为 4:310\"×7.5\"
slideEmuW pptx.Dimension = 9144000
slideEmuH pptx.Dimension = 6858000
)
// ExportFormat 导出类型
type ExportFormat string
const (
ExportFormatPDF ExportFormat = "pdf"
ExportFormatPPTX ExportFormat = "pptx"
)
// ExportMimeType 返回 Content-Type
func ExportMimeType(f ExportFormat) string {
switch f {
case ExportFormatPDF:
return "application/pdf"
case ExportFormatPPTX:
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
// ExportFileExt 返回文件扩展名(含点)
func ExportFileExt(f ExportFormat) string {
switch f {
case ExportFormatPDF:
return ".pdf"
case ExportFormatPPTX:
return ".pptx"
default:
return ""
}
}
// ParseExportFormat 解析 query format
func ParseExportFormat(s string) (ExportFormat, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "pdf":
return ExportFormatPDF, true
case "pptx", "ppt":
return ExportFormatPPTX, true
default:
return "", false
}
}
// SanitizeExportBaseName 用于下载文件名的主体(不含扩展名)
func SanitizeExportBaseName(title, taskID string) string {
s := strings.TrimSpace(title)
repl := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_",
)
s = repl.Replace(s)
var b strings.Builder
for _, r := range s {
if r == unicode.ReplacementChar || r < 32 {
continue
}
b.WriteRune(r)
}
s = strings.TrimSpace(b.String())
if s == "" {
s = strings.TrimSpace(taskID)
}
if len([]rune(s)) > 120 {
rs := []rune(s)
s = string(rs[:120])
}
return s
}
// ContentDispositionAttachment RFC 5987,兼容旧客户端
func ContentDispositionAttachment(filename string) string {
ascii := filename
for _, r := range filename {
if r > 127 || r == '"' || r == '\\' {
ascii = "export" + strings.ToLower(filepath.Ext(filename))
if ascii == "export" {
ascii = "export.bin"
}
break
}
}
return fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, ascii, url.PathEscape(filename))
}
// mapLocalUploadFile 将站点相对路径或完整 BaseURL 前缀映射为本地文件路径(local OSS)
func mapLocalUploadFile(raw string, local types.LocalStorageConfig) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || local.BasePath == "" {
return "", false
}
bp := filepath.Clean(local.BasePath)
bu := strings.TrimSuffix(strings.TrimSpace(local.BaseURL), "/")
if bu != "" && strings.HasPrefix(raw, bu) {
suffix := strings.TrimPrefix(strings.TrimPrefix(raw, bu), "/")
return filepath.Join(bp, suffix), true
}
if strings.HasPrefix(raw, local.BaseURL) {
return filepath.Join(bp, strings.TrimPrefix(raw, local.BaseURL)), true
}
return "", false
}
func originFromBaseURL(baseURL string) string {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
func defaultOriginFromListen(listen string) string {
listen = strings.TrimSpace(listen)
if listen == "" {
return ""
}
host, port, err := net.SplitHostPort(listen)
if err != nil {
if strings.HasPrefix(listen, ":") {
return "http://127.0.0.1" + listen
}
return ""
}
if host == "0.0.0.0" || host == "::" || host == "" {
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port)
}
// resolveAbsoluteImageURL 将可能为相对路径的地址转为可 HTTP 访问的绝对 URL
func resolveAbsoluteImageURL(raw string, local types.LocalStorageConfig, app *types.AppConfig) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return raw
}
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
return raw
}
if strings.HasPrefix(raw, "//") {
return "https:" + raw
}
origin := originFromBaseURL(local.BaseURL)
if origin == "" && app != nil {
origin = originFromBaseURL(app.StaticUrl)
}
if origin == "" && app != nil {
origin = defaultOriginFromListen(app.Listen)
}
if origin == "" {
return raw
}
if strings.HasPrefix(raw, "/") {
return strings.TrimSuffix(origin, "/") + raw
}
return strings.TrimSuffix(origin, "/") + "/" + raw
}
// BuildExportBytes 按幻灯片顺序拉取图片并生成 PDF 或 PPTX(oss/app 用于解析相对路径图片 URL)
func BuildExportBytes(ctx context.Context, slides []SlideData, format ExportFormat, oss types.OSSConfig, app *types.AppConfig) ([]byte, error) {
raws, err := fetchSlideImages(ctx, slides, oss, app)
if err != nil {
return nil, err
}
if len(raws) == 0 {
return nil, fmt.Errorf("没有可导出的幻灯片图片")
}
switch format {
case ExportFormatPDF:
return buildPDF(raws)
case ExportFormatPPTX:
return buildPPTX(raws)
default:
return nil, fmt.Errorf("不支持的导出格式")
}
}
func fetchSlideImages(ctx context.Context, slides []SlideData, oss types.OSSConfig, app *types.AppConfig) ([][]byte, error) {
cp := append([]SlideData(nil), slides...)
sort.Slice(cp, func(i, j int) bool { return cp[i].SlideIndex < cp[j].SlideIndex })
client := &http.Client{Timeout: exportHTTPTimeout}
var out [][]byte
for _, s := range cp {
u := strings.TrimSpace(s.ImageURL)
if u == "" {
continue
}
var body []byte
if oss.Active == "local" {
if fp, ok := mapLocalUploadFile(u, oss.Local); ok {
b, err := os.ReadFile(fp)
if err == nil {
if _, err := decodeImageBytes(b); err == nil {
body = b
}
}
}
}
if len(body) == 0 {
absURL := resolveAbsoluteImageURL(u, oss.Local, app)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, absURL, nil)
if err != nil {
return nil, fmt.Errorf("幻灯片 %d: %w", s.SlideIndex, err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("幻灯片 %d 下载失败: %w", s.SlideIndex, err)
}
body, err = io.ReadAll(io.LimitReader(resp.Body, 32<<20))
_ = resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("幻灯片 %d 读取失败: %w", s.SlideIndex, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("幻灯片 %d 下载失败: HTTP %d", s.SlideIndex, resp.StatusCode)
}
}
if _, err := decodeImageBytes(body); err != nil {
return nil, fmt.Errorf("幻灯片 %d 不是有效图片: %v", s.SlideIndex, err)
}
out = append(out, body)
}
return out, nil
}
func decodeImageBytes(b []byte) (image.Image, error) {
m, _, err := image.Decode(bytes.NewReader(b))
return m, err
}
// fitSlideBoundsEMU 在幻灯片 EMU 框(与模板 p:sldSz 一致)内按原图比例 contain 居中(不裁切)
func fitSlideBoundsEMU(imgW, imgH int) (x, y, w, h pptx.Dimension) {
if imgW <= 0 || imgH <= 0 {
return 0, 0, slideEmuW, slideEmuH
}
sw := float64(slideEmuW)
sh := float64(slideEmuH)
iw := float64(imgW)
ih := float64(imgH)
scale := sw / iw
if ih*scale > sh {
scale = sh / ih
}
wf := iw * scale
hf := ih * scale
w = pptx.Dimension(wf + 0.5)
h = pptx.Dimension(hf + 0.5)
x = pptx.Dimension((sw-wf)*0.5 + 0.5)
y = pptx.Dimension((sh-hf)*0.5 + 0.5)
return x, y, w, h
}
func buildPDF(images [][]byte) ([]byte, error) {
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.SetMargins(0, 0, 0)
pdf.SetAutoPageBreak(false, 0)
// 像素 → mm(按 96 DPI),再按页面对比缩放以 contain 放入整页
const pxPerMM = 96.0 / 25.4
for i, raw := range images {
im, err := decodeImageBytes(raw)
if err != nil {
return nil, fmt.Errorf("第 %d 页: %w", i+1, err)
}
b := im.Bounds()
pxW := float64(b.Dx())
pxH := float64(b.Dy())
if pxW <= 0 || pxH <= 0 {
return nil, fmt.Errorf("第 %d 页: 图片尺寸无效", i+1)
}
pdf.AddPage()
pageW, pageH := pdf.GetPageSize()
imgWmm := pxW / pxPerMM
imgHmm := pxH / pxPerMM
scale := pageW / imgWmm
if imgHmm*scale > pageH {
scale = pageH / imgHmm
}
w := imgWmm * scale
h := imgHmm * scale
x := (pageW - w) / 2
y := (pageH - h) / 2
name := fmt.Sprintf("slide%d", i)
opt := gofpdf.ImageOptions{ReadDpi: false}
tp := sniffImageType(raw)
if tp != "" {
opt.ImageType = tp
}
if pdf.RegisterImageOptionsReader(name, opt, bytes.NewReader(raw)) == nil {
return nil, fmt.Errorf("第 %d 页: 无法写入 PDF 图片", i+1)
}
pdf.ImageOptions(name, x, y, w, h, false, opt, 0, "")
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sniffImageType(b []byte) string {
if len(b) < 12 {
return ""
}
switch {
case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8:
return "jpg"
case len(b) >= 8 && string(b[0:8]) == "\x89PNG\r\n\x1a\n":
return "png"
case len(b) >= 6 && string(b[0:6]) == "GIF87a" || string(b[0:6]) == "GIF89a":
return "gif"
case len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP":
return "webp"
default:
return ""
}
}
func buildPPTX(images [][]byte) ([]byte, error) {
if len(minimalPptxTemplate) == 0 {
return nil, fmt.Errorf("内置 PPT 模板缺失")
}
tmp, err := os.CreateTemp("", "ppt-export-*.pptx")
if err != nil {
return nil, err
}
path := tmp.Name()
if _, err := tmp.Write(minimalPptxTemplate); err != nil {
_ = tmp.Close()
_ = os.Remove(path)
return nil, err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return nil, err
}
defer func() { _ = os.Remove(path) }()
f, err := pptx.Open(path)
if err != nil {
return nil, err
}
for _, raw := range images {
im, err := decodeImageBytes(raw)
if err != nil {
f.Abort()
return nil, err
}
b := im.Bounds()
ex, ey, ew, eh := fitSlideBoundsEMU(b.Dx(), b.Dy())
slide := pptx.Slide{
Images: []pptx.Image{
pptx.NewImage(im, ex, ey, ew, eh),
},
}
if err := f.Add(slide); err != nil {
f.Abort()
return nil, err
}
}
if err := f.Close(); err != nil {
return nil, err
}
return os.ReadFile(path)
}
+263
View File
@@ -0,0 +1,263 @@
package ppt
import (
"context"
"fmt"
"geekai/core/types"
"geekai/log"
"net/http"
"time"
"github.com/imroc/req/v3"
"github.com/volcengine/volcengine-go-sdk/service/arkruntime"
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
"github.com/volcengine/volcengine-go-sdk/volcengine"
"golang.org/x/time/rate"
)
var imageLogger = log.GetLogger()
// ImageGenerator 图片生成适配器接口
type ImageGenerator interface {
Provider() string
Generate(ctx context.Context, prompt string) (string, error)
// GenerateWithReference 图生图;referenceImages 为公网 URL 或 data:image/...;base64,...(本地文件应在调用前经 PrepareReferenceInputsForImg2Img 转换)
GenerateWithReference(ctx context.Context, prompt string, referenceImages []string) (string, error)
}
// Nano Banana 适配器(OpenAI DALL-E 风格 API
type nanoBananaImageGenerator struct {
client *req.Client
cfg types.PPTConfig
limiter *rate.Limiter
}
// Seedream 适配器(火山引擎 arkruntime SDK
type seedreamImageGenerator struct {
cfg types.PPTConfig
limiter *rate.Limiter
}
// NewImageGenerator 根据配置创建对应的图片生成适配器
func NewImageGenerator(cfg types.PPTConfig) (ImageGenerator, error) {
qps := cfg.QPSLimit
if qps <= 0 {
qps = 1
}
limiter := rate.NewLimiter(rate.Limit(qps), 1)
switch cfg.ActiveImageProvider {
case types.PPTImageProviderNanoBanana:
if cfg.NanoBananaApiURL == "" || cfg.NanoBananaApiKey == "" {
return nil, fmt.Errorf("nano banana api not configured")
}
return &nanoBananaImageGenerator{
client: req.C().SetTimeout(3 * time.Minute),
cfg: cfg,
limiter: limiter,
}, nil
case types.PPTImageProviderSeedream:
if cfg.SeedreamBaseURL == "" || cfg.SeedreamApiKey == "" || cfg.SeedreamModel == "" {
return nil, fmt.Errorf("seedream api not configured")
}
return &seedreamImageGenerator{
cfg: cfg,
limiter: limiter,
}, nil
default:
return nil, fmt.Errorf("unsupported image provider: %s", cfg.ActiveImageProvider)
}
}
func (g *nanoBananaImageGenerator) Provider() string {
return string(types.PPTImageProviderNanoBanana)
}
// nanoBananaReq 按 OpenAI DALL-E 风格 / Nano-banana API 文档
type nanoBananaReq struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
ResponseFormat string `json:"response_format,omitempty"` // url 或 b64_json
AspectRatio string `json:"aspect_ratio,omitempty"` // 1:1, 4:3, 3:4, 16:9, 9:16, 2:3, 3:2, 4:5, 5:4, 21:9
Image []string `json:"image,omitempty"` // 参考图 url 或 b64
}
// nanoBananaRes 响应为 data[].urlDALL-E 风格)
type nanoBananaRes struct {
Data []struct {
URL string `json:"url,omitempty"`
B64JSON string `json:"b64_json,omitempty"`
} `json:"data"`
}
type nanoBananaErr struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
func (g *nanoBananaImageGenerator) buildReqBody(prompt string, referenceImages []string) nanoBananaReq {
modelName := g.cfg.NanoBananaModel
if modelName == "" {
modelName = "nano-banana"
}
reqBody := nanoBananaReq{
Model: modelName,
Prompt: prompt,
}
if len(referenceImages) > 0 {
reqBody.Image = referenceImages
}
if g.cfg.NanoBananaResponseFormat != "" {
reqBody.ResponseFormat = g.cfg.NanoBananaResponseFormat
} else {
reqBody.ResponseFormat = "url"
}
if g.cfg.NanoBananaAspectRatio != "" {
reqBody.AspectRatio = g.cfg.NanoBananaAspectRatio
} else {
reqBody.AspectRatio = "16:9"
}
return reqBody
}
func (g *nanoBananaImageGenerator) Generate(ctx context.Context, prompt string) (string, error) {
return g.GenerateWithReference(ctx, prompt, nil)
}
func (g *nanoBananaImageGenerator) GenerateWithReference(ctx context.Context, prompt string, referenceImages []string) (string, error) {
reqBody := g.buildReqBody(prompt, referenceImages)
var (
result nanoBananaRes
errRes nanoBananaErr
)
do := func() (int, error) {
if err := g.limiter.Wait(ctx); err != nil {
return 0, err
}
imageLogger.Infof("nano banana generate image, api: %s", g.cfg.NanoBananaApiURL)
r, err := g.client.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("Authorization", "Bearer "+g.cfg.NanoBananaApiKey).
SetBody(reqBody).
SetSuccessResult(&result).
SetErrorResult(&errRes).
Post(g.cfg.NanoBananaApiURL)
if err != nil {
return 0, err
}
if r.IsErrorState() {
return r.StatusCode, fmt.Errorf("nano banana error: %s, %s", r.Status, errRes.Error.Message)
}
if len(result.Data) == 0 || result.Data[0].URL == "" {
return r.StatusCode, fmt.Errorf("nano banana returned empty data")
}
return r.StatusCode, nil
}
if err := callWithRetry(ctx, do); err != nil {
return "", err
}
return result.Data[0].URL, nil
}
func (g *seedreamImageGenerator) Provider() string {
return string(types.PPTImageProviderSeedream)
}
func (g *seedreamImageGenerator) Generate(ctx context.Context, prompt string) (string, error) {
return g.GenerateWithReference(ctx, prompt, nil)
}
func (g *seedreamImageGenerator) GenerateWithReference(ctx context.Context, prompt string, referenceImages []string) (string, error) {
if err := g.limiter.Wait(ctx); err != nil {
return "", err
}
client := arkruntime.NewClientWithApiKey(g.cfg.SeedreamApiKey, arkruntime.WithBaseUrl(g.cfg.SeedreamBaseURL))
size := g.cfg.SeedreamSize
if size == "" {
size = "1920x1080"
}
responseFormat := g.cfg.SeedreamResponseType
if responseFormat == "" {
responseFormat = "url"
}
generateReq := model.GenerateImagesRequest{
Model: g.cfg.SeedreamModel,
Prompt: prompt,
Size: volcengine.String(size),
ResponseFormat: volcengine.String(responseFormat),
Watermark: volcengine.Bool(g.cfg.SeedreamWatermark),
}
if len(referenceImages) > 0 {
generateReq.Image = referenceImages
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Duration(attempt) * 2 * time.Second):
}
}
imageLogger.Infof("seedream generate image, api: %s", g.cfg.SeedreamBaseURL)
if err := generateReq.NormalizeImages(); err != nil {
return "", fmt.Errorf("seedream normalize images: %w", err)
}
resp, err := client.GenerateImages(ctx, generateReq)
if err != nil {
lastErr = fmt.Errorf("seedream error: %w", err)
continue
}
if resp.Data == nil || len(resp.Data) == 0 {
lastErr = fmt.Errorf("seedream returned empty data")
continue
}
if resp.Data[0].Url == nil || *resp.Data[0].Url == "" {
lastErr = fmt.Errorf("seedream returned empty url")
continue
}
return *resp.Data[0].Url, nil
}
return "", lastErr
}
// callWithRetry 对 429 错误做指数退避重试
func callWithRetry(ctx context.Context, fn func() (int, error)) error {
var (
retries = 3
backoffs = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
lastError error
)
for i := 0; i < retries; i++ {
status, err := fn()
if err == nil {
return nil
}
lastError = err
// 仅对 429 做指数退避重试
if status != http.StatusTooManyRequests || i == retries-1 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoffs[i]):
}
}
return lastError
}
+373
View File
@@ -0,0 +1,373 @@
package ppt
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
// * Use of this source code is governed by a Apache-2.0 license
// * that can be found in the LICENSE file.
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"context"
"encoding/json"
"fmt"
"geekai/core/types"
"geekai/log"
"strings"
"time"
"github.com/imroc/req/v3"
)
var logger = log.GetLogger()
// slidePlan LLM 输出的分镜结构
type slidePlan struct {
SlideIndex int `json:"slide_index"`
Theme string `json:"theme"`
Title string `json:"title"`
Points []string `json:"points"`
ImagePrompt string `json:"image_prompt"`
}
// systemPrompt 图文并茂幻灯片:每页有插图且画面上含与主题一致的文字,文字量与生成模式相关
const systemPrompt = `
# Role
你是一位顶级的专业演示文稿(PPT)策划专家和 AI 图像提示词(Prompt)工程师。任务是根据用户提供的「内容大纲」或「设计要求」,生成一套逻辑清晰、视觉风格高度统一的幻灯片分镜数据。目标是生成**图文并茂**的幻灯片:每页既有文字又有插图,图片上直接呈现与本页内容一致的文字,而不是留白让用户后加文字。
# Rules
1. 全局风格锚定:根据大纲推断或遵循用户要求的全局视觉风格。所有配图必须严格遵循此风格。
2. 结构化拆解:合理拆分为多张幻灯片,单页最多 3-4 个简短要点。
3. 视觉转译(图文并茂):
- 为每页构思的 image_prompt 既要描述**插图画面**,也要明确**画面上应出现的文字**(如本页标题、要点或短句),与当页 theme、title、points 内容一致。不要描述留白或“用于排版文字的空间”。
- 图片中出现的所有文字必须使用与 theme、title、points **相同的语言**(即本次请求指定的输出语言)。
- 图片上文字的量由「生成模式」决定(见用户输入中的模式说明):
- **详细演示文稿**:图片上的说明文字可适当多一些,如本页要点、一两句说明。
- **演示用幻灯片**:图片上的文字尽量精简,如仅主标题或少量关键词,便于演讲时配合口述。
- image_prompt 须包含前缀「[全局风格描述]」,并清晰描述画面中的插图与文字内容(含具体要出现的文字及其语言),不要包含“不要在图片中生成任何文字”的约束。
4. 严格输出合法的纯 JSON 数组:
[
{"slide_index": 1, "theme": "...", "title": "...", "points": ["..."], "image_prompt": "..."}
]
禁止输出任何 Markdown 标记或多余文本。`
// notebookSystemPrompt 文档提炼:NotebookLM 风格输出 PPT 可用大纲文本(纯文本/Markdown)。
const notebookSystemPrompt = `
# Role
你是一位“NotebookLM 风格”的专业文档理解与提炼助手。任务是基于用户提供的「原始文档文本」和「设计要求」,提炼出可用于制作 PPT 的结构化大纲内容。
# Output Requirements
1. 输出必须是纯文本/Markdown(允许使用标题与列表),禁止输出任何 JSON。
2. 禁止输出代码块(不要出现代码块语法)。
3. 不要输出解释过程、不要复述提示词。
4. 大纲必须是“内容大纲/要点”,用于后续继续拆分成幻灯片,而不是直接输出最终幻灯片分镜。
# Rules
1. 文档优先:尽可能从原始文档中提取信息与措辞;若文档缺失关键点,则给出合理补全的“建议方向”,并明确标注为“(建议)”。
2. 贴合设计要求:根据设计要求调整大纲的语气、侧重点、术语风格,使内容更符合目标受众与整体风格。
3. 结构清晰:使用分层标题(例如:# 总主题、## 模块/章节、### 要点),并为每个模块给出 2-4 个要点句(可直接用于 PPT 每页标题/要点)。
4. 语言一致:所有输出语言必须与本次请求指定的语言一致。
`
// LLMClient 分镜 LLM 客户端
type LLMClient struct {
httpClient *req.Client
}
func NewLLMClient() *LLMClient {
return &LLMClient{
httpClient: req.C().SetTimeout(2 * time.Minute),
}
}
// GenerateSlides 调用大模型生成分镜列表。language 约束输出语言,mode 约束图中文字量,maxPages 约束恰好生成 N 页。
func (c *LLMClient) GenerateSlides(ctx context.Context, cfg types.PPTConfig, content, prompt, language, mode string, maxPages int) ([]slidePlan, error) {
if cfg.OutlineLLMApiURL == "" {
return nil, fmt.Errorf("outline LLM api url is empty")
}
if cfg.OutlineLLMApiKey == "" {
return nil, fmt.Errorf("outline LLM api key is empty")
}
if maxPages <= 0 {
maxPages = 10
}
if mode != "detailed" && mode != "slides" {
mode = "slides"
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// 动态 system:加入页数约束
systemContent := systemPrompt + fmt.Sprintf("\n\n# 页数约束\n请将内容拆分为恰好 %d 页的幻灯片分镜,保证逻辑完整、故事线连贯,不要多也不要少。输出 JSON 数组长度必须为 %d。", maxPages, maxPages)
// 组装用户输入
userContent := fmt.Sprintf("下面是用户提供的演示文稿大纲内容:\n\n%s", content)
if prompt != "" {
userContent = fmt.Sprintf("%s\n\n额外的设计要求:%s", userContent, prompt)
}
if language != "" {
langHint := "中文"
if language == "en" || language == "en-US" {
langHint = "英文"
} else if language == "zh-CN" || language == "zh" {
langHint = "中文"
} else {
langHint = "语言代码 " + language + " 对应的语言"
}
userContent = fmt.Sprintf("%s\n\n请用%s输出所有分镜内容(theme、title、points、image_prompt 等均使用该语言;图片中出现的文字也必须是%s)。", userContent, langHint, langHint)
}
modeHint := "演示用幻灯片"
if mode == "detailed" {
modeHint = "详细演示文稿"
}
userContent = fmt.Sprintf("%s\n\n本次生成模式为:%s。请按上述规则控制每页插图中文字的量。", userContent, modeHint)
modelName := cfg.OutlineLLMModel
if modelName == "" {
modelName = "gpt-5.2"
}
body := map[string]any{
"model": modelName,
"messages": []message{
{Role: "user", Content: systemContent + "\n\n" + userContent},
},
"temperature": 0.8,
}
var respBody struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
logger.Infof("generate PPT slides with outline LLM, api: %s", cfg.OutlineLLMApiURL)
r, err := c.httpClient.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("Authorization", "Bearer "+cfg.OutlineLLMApiKey).
SetBody(body).
SetSuccessResult(&respBody).
Post(cfg.OutlineLLMApiURL)
if err != nil {
return nil, fmt.Errorf("request outline LLM failed: %v", err)
}
if r.IsErrorState() {
return nil, fmt.Errorf("outline LLM returned error status: %s", r.Status)
}
if len(respBody.Choices) == 0 {
return nil, fmt.Errorf("outline LLM returned empty choices")
}
contentStr := respBody.Choices[0].Message.Content
var plans []slidePlan
if err := json.Unmarshal([]byte(contentStr), &plans); err != nil {
return nil, fmt.Errorf("parse outline LLM json failed: %v, raw: %s", err, contentStr)
}
return plans, nil
}
// GenerateNotebookContent 调用文档提炼 LLM,把 rawDocText -> PPT 可用的 content(大纲/结构化要点)。
func (c *LLMClient) GenerateNotebookContent(ctx context.Context, cfg types.PPTConfig, rawDocText, designPrompt, language string) (string, error) {
if cfg.OutlineLLMApiURL == "" {
return "", fmt.Errorf("outline LLM api url is empty")
}
if cfg.OutlineLLMApiKey == "" {
return "", fmt.Errorf("outline LLM api key is empty")
}
rawDocText = strings.TrimSpace(rawDocText)
if rawDocText == "" {
return "", fmt.Errorf("rawDocText is empty")
}
// 对超长输入做保守截断,避免请求体过大或上下文溢出。
// 这里按“字符数”截断,真实 token 仍可能超出,但作为兜底足够。
const maxChars = 25000
runes := []rune(rawDocText)
if len(runes) > maxChars {
rawDocText = string(runes[:maxChars])
}
langHint := "中文"
if language == "en" || language == "en-US" {
langHint = "英文"
} else if language == "zh-CN" || language == "zh" {
langHint = "中文"
} else if language != "" {
langHint = "语言代码 " + language + " 对应的语言"
}
maxSlides := cfg.MaxSlidesPerTask
if maxSlides <= 0 {
maxSlides = 10
}
userContent := fmt.Sprintf("原始文档文本如下(可能很长):\n\n%s", rawDocText)
if strings.TrimSpace(designPrompt) != "" {
userContent = fmt.Sprintf("%s\n\n设计要求(风格/受众/侧重点等):\n%s", userContent, designPrompt)
}
userContent = fmt.Sprintf(
"%s\n\n请用%s输出 PPT 大纲内容。该大纲应便于拆分为不超过 %d 页的 PPT。",
userContent,
langHint,
maxSlides,
)
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
systemContent := notebookSystemPrompt + fmt.Sprintf("\n\n# 语言约束\n输出语言:%s。", langHint)
modelName := cfg.OutlineLLMModel
if modelName == "" {
modelName = "gpt-4o-mini"
}
body := map[string]any{
"model": modelName,
"messages": []message{
{Role: "user", Content: systemContent + "\n\n" + userContent},
},
"temperature": 0.4,
}
var respBody struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
logger.Infof("generate PPT content with outline LLM, api: %s", cfg.OutlineLLMApiURL)
r, err := c.httpClient.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("Authorization", "Bearer "+cfg.OutlineLLMApiKey).
SetBody(body).
SetSuccessResult(&respBody).
Post(cfg.OutlineLLMApiURL)
if err != nil {
return "", fmt.Errorf("request outline LLM failed: %v", err)
}
if r.IsErrorState() {
return "", fmt.Errorf("outline LLM returned error status: %s", r.Status)
}
if len(respBody.Choices) == 0 {
return "", fmt.Errorf("outline LLM returned empty choices")
}
return strings.TrimSpace(respBody.Choices[0].Message.Content), nil
}
// titleSystemPrompt 根据大纲生成用于任务列表的短标题(单行纯文本)
const titleSystemPrompt = `
# Role
你是「演示文稿命名助手」。用户会提供一份 PPT 内容大纲(可能含 Markdown)。请根据大纲主题与受众,生成一个**适合出现在任务列表中的短标题**。
# Output Rules
1. 只输出**一行**纯文本,不要换行、不要编号、不要引号包裹。
2. 长度建议 **20 个字以内**(中文)或 **8 个英文单词以内**;若大纲极长,仍只给概括性标题。
3. 输出语言必须与本次指定的「输出语言」一致。
4. 不要输出“标题:”“Title:”等前缀,不要复述本说明。
`
// GeneratePPTTitle 调用大模型根据 content 生成列表用短标题;失败时由调用方降级。
func (c *LLMClient) GeneratePPTTitle(ctx context.Context, cfg types.PPTConfig, content, language string) (string, error) {
if cfg.OutlineLLMApiURL == "" {
return "", fmt.Errorf("outline LLM api url is empty")
}
if cfg.OutlineLLMApiKey == "" {
return "", fmt.Errorf("outline LLM api key is empty")
}
content = strings.TrimSpace(content)
if content == "" {
return "", fmt.Errorf("content is empty")
}
const maxChars = 10000
runes := []rune(content)
if len(runes) > maxChars {
content = string(runes[:maxChars])
}
langHint := "中文"
if language == "en" || language == "en-US" {
langHint = "英文"
} else if language == "zh-CN" || language == "zh" {
langHint = "中文"
} else if language != "" {
langHint = "语言代码 " + language + " 对应的语言"
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
userContent := fmt.Sprintf("输出语言:%s。\n\n下面是用户提供的 PPT 大纲内容,请只返回列表标题:\n\n%s", langHint, content)
systemContent := titleSystemPrompt + fmt.Sprintf("\n\n# 语言约束\n请用%s撰写标题。", langHint)
modelName := cfg.OutlineLLMModel
if modelName == "" {
modelName = "gpt-4o-mini"
}
body := map[string]any{
"model": modelName,
"messages": []message{
{Role: "user", Content: systemContent + "\n\n" + userContent},
},
"temperature": 0.5,
}
var respBody struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
logger.Infof("generate PPT list title with outline LLM, api: %s", cfg.OutlineLLMApiURL)
r, err := c.httpClient.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetHeader("Authorization", "Bearer "+cfg.OutlineLLMApiKey).
SetBody(body).
SetSuccessResult(&respBody).
Post(cfg.OutlineLLMApiURL)
if err != nil {
return "", fmt.Errorf("request outline LLM failed: %v", err)
}
if r.IsErrorState() {
return "", fmt.Errorf("outline LLM returned error status: %s", r.Status)
}
if len(respBody.Choices) == 0 {
return "", fmt.Errorf("outline LLM returned empty choices")
}
raw := strings.TrimSpace(respBody.Choices[0].Message.Content)
if idx := strings.IndexAny(raw, "\r\n"); idx >= 0 {
raw = strings.TrimSpace(raw[:idx])
}
raw = strings.Trim(raw, `"'「」`)
return raw, nil
}
+884
View File
@@ -0,0 +1,884 @@
package ppt
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
// * Use of this source code is governed by a Apache-2.0 license
// * that can be found in the LICENSE file.
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"context"
"errors"
"fmt"
"geekai/core/types"
"geekai/service"
"geekai/service/oss"
"geekai/store/model"
"geekai/store/vo"
"geekai/utils"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
)
// ErrInsufficientPower 用户算力不足以完成本次 PPT 任务(由 handler 映射文案)
var ErrInsufficientPower = errors.New("insufficient power for ppt task")
var (
// ErrPptTaskNotFound 表示任务不存在
ErrPptTaskNotFound = errors.New("ppt task not found")
// ErrPptTaskNotDeletable 表示任务状态不允许删除
ErrPptTaskNotDeletable = errors.New("ppt task not deletable")
// ErrPptTaskBusy 任务正在处理中
ErrPptTaskBusy = errors.New("ppt task is processing")
// ErrPptTaskNotResumable 无法继续生成(无分镜占位或已完成)
ErrPptTaskNotResumable = errors.New("ppt task cannot be resumed")
// ErrPptSlideNotFound 指定 slide_index 不存在
ErrPptSlideNotFound = errors.New("ppt slide not found")
// ErrPptSlideNoImage 该页尚无配图
ErrPptSlideNoImage = errors.New("ppt slide has no image")
// ErrPptInvalidVersionIndex 历史版本下标无效
ErrPptInvalidVersionIndex = errors.New("invalid slide version index")
)
// TaskStatus 任务状态
type TaskStatus string
const (
TaskStatusPending TaskStatus = "pending"
TaskStatusProcessing TaskStatus = "processing"
TaskStatusCompleted TaskStatus = "completed"
TaskStatusFailed TaskStatus = "failed"
)
// SlideData 单页 PPT 数据
type SlideData struct {
SlideIndex int `json:"slide_index"`
Theme string `json:"theme"`
Title string `json:"title"`
Points []string `json:"points"`
ImagePrompt string `json:"image_prompt"`
ImageURL string `json:"image_url"`
ImageHistory []vo.PPTSlideImageVersion `json:"image_history,omitempty"`
}
// Task PPT 生成任务(用于业务层与 API 返回,持久化在 DB)
type Task struct {
TaskID string `json:"task_id"`
UserID uint `json:"user_id"`
Status TaskStatus `json:"status"`
Content string `json:"content"`
Prompt string `json:"prompt"`
Language string `json:"language"`
Mode string `json:"mode"`
Pages int `json:"pages"`
Total int `json:"total_slides"`
Completed int `json:"completed_slides"`
Slides []SlideData `json:"slides"`
Title string `json:"title"`
Thumb string `json:"thumb"`
ErrorMessage string `json:"error_message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskSummaryMap 返回任务摘要 map(公共字段),供 handler 补充独有字段后返回。
func (t *Task) TaskSummaryMap() map[string]any {
return map[string]any{
"task_id": t.TaskID,
"status": t.Status,
"total_slides": t.Total,
"completed_slides": t.Completed,
"created_at": t.CreatedAt.Unix(),
"updated_at": t.UpdatedAt.Unix(),
"title": t.Title,
"thumb": t.Thumb,
}
}
// Progress 任务进度信息
type Progress struct {
Total int `json:"total_slides"`
Completed int `json:"completed_slides"`
}
// PptService PPT 任务与生成流程(持久化、LLM 分镜、生图、转存、算力)
type PptService struct {
db *gorm.DB
userService *service.UserService
uploadManager *oss.UploaderManager
llm *LLMClient
// slidesLock 全局互斥:串行化 slides JSON 的写库,避免并发覆盖(写库极短,可接受排队)
slidesLock sync.Mutex
}
// NewPptService 创建 PptService
func NewPptService(db *gorm.DB, userService *service.UserService, uploadManager *oss.UploaderManager) *PptService {
return &PptService{
db: db,
userService: userService,
uploadManager: uploadManager,
llm: NewLLMClient(),
}
}
// GenerateNotebookContent 将原始文档文本提炼成 PPT 可用的大纲 content。
func (s *PptService) GenerateNotebookContent(ctx context.Context, cfg types.PPTConfig, rawDocText, designPrompt, language string) (string, error) {
if s.llm == nil {
s.llm = NewLLMClient()
}
return s.llm.GenerateNotebookContent(ctx, cfg, rawDocText, designPrompt, language)
}
func slideToVO(s SlideData) vo.PPTSlideData {
return vo.PPTSlideData{
SlideIndex: s.SlideIndex,
Theme: s.Theme,
Title: s.Title,
Points: s.Points,
ImagePrompt: s.ImagePrompt,
ImageURL: s.ImageURL,
ImageHistory: vo.PPTSlideImageVersions(s.ImageHistory),
}
}
func voToSlide(s vo.PPTSlideData) SlideData {
return SlideData{
SlideIndex: s.SlideIndex,
Theme: s.Theme,
Title: s.Title,
Points: s.Points,
ImagePrompt: s.ImagePrompt,
ImageURL: s.ImageURL,
ImageHistory: []vo.PPTSlideImageVersion(s.ImageHistory),
}
}
func voSlidesToBiz(slides vo.PPTSlides) []SlideData {
out := make([]SlideData, len(slides))
for i, sv := range slides {
out[i] = voToSlide(sv)
}
return out
}
func voSlidesToBizNormalized(slides vo.PPTSlides) []SlideData {
out := make([]SlideData, len(slides))
for i, sv := range slides {
sd := voToSlide(sv)
normalizeSlideImageHistory(&sd)
out[i] = sd
}
return out
}
// normalizeSlideImageHistory 旧数据仅有 image_url 时补全 image_history,便于前端展示历史
func normalizeSlideImageHistory(s *SlideData) {
if strings.TrimSpace(s.ImageURL) != "" && len(s.ImageHistory) == 0 {
s.ImageHistory = []vo.PPTSlideImageVersion{
{ImageURL: s.ImageURL, Prompt: strings.TrimSpace(s.ImagePrompt)},
}
}
}
// DerivePPTThumbFromSlides 按 slide_index 升序取第一张有图 URL(与 vo.PPTSlides 规则一致)
func DerivePPTThumbFromSlides(slides []SlideData) string {
if len(slides) == 0 {
return ""
}
cp := make([]SlideData, len(slides))
copy(cp, slides)
sort.Slice(cp, func(i, j int) bool {
return cp[i].SlideIndex < cp[j].SlideIndex
})
for _, s := range cp {
if strings.TrimSpace(s.ImageURL) != "" {
return s.ImageURL
}
}
return ""
}
func truncateTitleRunes(s string, max int) string {
if max <= 0 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
func taskToModel(t *Task) *model.PPTJob {
now := time.Now()
job := &model.PPTJob{
TaskId: t.TaskID,
UserId: t.UserID,
Status: string(t.Status),
ErrMsg: t.ErrorMessage,
Prompt: t.Prompt,
Title: t.Title,
Thumb: t.Thumb,
Content: t.Content,
Params: vo.PPTParams{Language: t.Language, Mode: t.Mode, Pages: t.Pages},
Slides: nil,
TotalSlides: t.Total,
CompletedSlides: t.Completed,
CreatedAt: now,
UpdatedAt: now,
}
if t.CreatedAt.IsZero() {
job.CreatedAt = now
job.UpdatedAt = now
} else {
job.CreatedAt = t.CreatedAt
job.UpdatedAt = t.UpdatedAt
}
return job
}
func modelToTask(j *model.PPTJob) *Task {
slides := voSlidesToBizNormalized(j.Slides)
return &Task{
TaskID: j.TaskId,
UserID: j.UserId,
Status: TaskStatus(j.Status),
Content: j.Content,
Prompt: j.Prompt,
Title: j.Title,
Thumb: j.Thumb,
Language: j.Params.Language,
Mode: j.Params.Mode,
Pages: j.Params.Pages,
Total: j.TotalSlides,
Completed: j.CompletedSlides,
Slides: slides,
ErrorMessage: j.ErrMsg,
CreatedAt: j.CreatedAt,
UpdatedAt: j.UpdatedAt,
}
}
// BuildPendingTask 校验算力与页数,组装待写入的 Task(未落库)
func (s *PptService) BuildPendingTask(taskID string, userID uint, userPower int, content, prompt, language, mode string, reqPages int) (*Task, types.PPTConfig, error) {
cfg, err := s.loadPPTConfig()
if err != nil {
return nil, cfg, err
}
// 目标页数:用户指定时取 min(请求页数, 服务端上限);未指定(0)时按服务端上限作为默认生成规模
effectivePages := cfg.MaxSlidesPerTask
if reqPages > 0 {
effectivePages = reqPages
if effectivePages > cfg.MaxSlidesPerTask {
effectivePages = cfg.MaxSlidesPerTask
}
}
estimatePower := effectivePages * cfg.PowerCostPerSlide
if estimatePower > 0 && userPower < estimatePower {
return nil, cfg, ErrInsufficientPower
}
effectiveMode := mode
if effectiveMode != "detailed" && effectiveMode != "slides" {
effectiveMode = "slides"
}
task := &Task{
TaskID: taskID,
UserID: userID,
Status: TaskStatusPending,
Content: content,
Prompt: prompt,
Language: language,
Pages: effectivePages,
Mode: effectiveMode,
}
return task, cfg, nil
}
// CreateTask 创建新任务并写入数据库(调用大模型生成列表标题后落库)
func (s *PptService) CreateTask(ctx context.Context, task *Task, cfg types.PPTConfig) error {
if s.llm == nil {
s.llm = NewLLMClient()
}
title, err := s.llm.GeneratePPTTitle(ctx, cfg, task.Content, task.Language)
if err != nil {
logger.Warnf("GeneratePPTTitle failed task_id=%s: %v", task.TaskID, err)
title = "未命名演示文稿"
} else {
title = strings.TrimSpace(title)
if title == "" {
title = "未命名演示文稿"
}
}
task.Title = truncateTitleRunes(title, 255)
task.CreatedAt = time.Now()
task.UpdatedAt = task.CreatedAt
task.Status = TaskStatusPending
job := taskToModel(task)
return s.db.Create(job).Error
}
// GetTask 从数据库获取任务
func (s *PptService) GetTask(taskID string) (*Task, bool) {
var job model.PPTJob
err := s.db.Where("task_id = ?", taskID).First(&job).Error
if err != nil || job.TaskId == "" {
return nil, false
}
return modelToTask(&job), true
}
// UpdateStatus 更新任务状态
func (s *PptService) UpdateStatus(taskID string, status TaskStatus) {
s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).
Updates(map[string]interface{}{"status": string(status), "updated_at": time.Now()})
}
func countSlidesWithImage(slides []SlideData) int {
n := 0
for _, sl := range slides {
if strings.TrimSpace(sl.ImageURL) != "" {
n++
}
}
return n
}
func slidePlansToOutlines(plans []slidePlan) []SlideData {
out := make([]SlideData, len(plans))
for i, p := range plans {
out[i] = SlideData{
SlideIndex: p.SlideIndex,
Theme: p.Theme,
Title: p.Title,
Points: p.Points,
ImagePrompt: p.ImagePrompt,
ImageURL: "",
}
}
sort.Slice(out, func(i, j int) bool {
return out[i].SlideIndex < out[j].SlideIndex
})
return out
}
// saveSlidesOutline 分镜一出即落库:每页含 theme/title/points/image_promptimage_url 为空
func (s *PptService) saveSlidesOutline(taskID string, total int, slides []SlideData) error {
s.slidesLock.Lock()
defer s.slidesLock.Unlock()
voSlides := make(vo.PPTSlides, len(slides))
for i := range slides {
voSlides[i] = slideToVO(slides[i])
}
completed := countSlidesWithImage(slides)
return s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).Updates(map[string]interface{}{
"slides": voSlides,
"total_slides": total,
"completed_slides": completed,
"updated_at": time.Now(),
}).Error
}
// ApplySlideImage 按 slide_index 原地写入 image_url,并刷新 completed_slides、thumb
func (s *PptService) ApplySlideImage(taskID string, slide SlideData) error {
s.slidesLock.Lock()
defer s.slidesLock.Unlock()
var job model.PPTJob
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
return err
}
slides := job.Slides
found := false
for i := range slides {
if slides[i].SlideIndex == slide.SlideIndex {
slides[i].ImageURL = slide.ImageURL
if strings.TrimSpace(slide.ImageURL) != "" && len(slides[i].ImageHistory) == 0 {
slides[i].ImageHistory = vo.PPTSlideImageVersions{
{ImageURL: slide.ImageURL, Prompt: strings.TrimSpace(slide.ImagePrompt)},
}
}
found = true
break
}
}
if !found {
return fmt.Errorf("slide index %d not found", slide.SlideIndex)
}
job.Slides = slides
biz := voSlidesToBiz(slides)
return s.refreshJobMeta(&job, biz)
}
// refreshJobMeta 刷新 job 的 CompletedSlides/Thumb/UpdatedAt 并 Save。
// 调用前必须已持有 slidesLock。
func (s *PptService) refreshJobMeta(job *model.PPTJob, biz []SlideData) error {
job.CompletedSlides = countSlidesWithImage(biz)
job.Thumb = DerivePPTThumbFromSlides(biz)
job.UpdatedAt = time.Now()
return s.db.Save(job).Error
}
func (s *PptService) validateSlideOutline(task *Task) error {
if task.Total <= 0 {
return ErrPptTaskNotResumable
}
if len(task.Slides) < task.Total {
return ErrPptTaskNotResumable
}
seen := make(map[int]bool, len(task.Slides))
for _, sl := range task.Slides {
seen[sl.SlideIndex] = true
}
for i := 1; i <= task.Total; i++ {
if !seen[i] {
return ErrPptTaskNotResumable
}
}
return nil
}
func slidesNeedingImages(task *Task) []SlideData {
var need []SlideData
for _, sl := range task.Slides {
if strings.TrimSpace(sl.ImageURL) == "" {
need = append(need, sl)
}
}
sort.Slice(need, func(i, j int) bool {
return need[i].SlideIndex < need[j].SlideIndex
})
return need
}
func (s *PptService) userPower(userID uint) (int, error) {
var u model.User
if err := s.db.Where("id = ?", userID).First(&u).Error; err != nil {
return 0, err
}
return u.Power, nil
}
// runSlideImageJobs 为给定幻灯片列表并发生图(每张成功后 ApplySlideImage
func (s *PptService) runSlideImageJobs(ctx context.Context, task *Task, cfg types.PPTConfig, generator ImageGenerator, slides []SlideData) error {
if len(slides) == 0 {
return nil
}
if cfg.MaxConcurrentRequests <= 0 {
cfg.MaxConcurrentRequests = 3
}
group, ctx := errgroup.WithContext(ctx)
group.SetLimit(cfg.MaxConcurrentRequests)
for _, item := range slides {
slide := item
group.Go(func() error {
imgURL, err := generator.Generate(ctx, slide.ImagePrompt)
if err != nil {
return err
}
storedURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(imgURL, ".png", false)
if err != nil {
return fmt.Errorf("转存图片失败:%w", err)
}
full := slide
full.ImageURL = storedURL
if err := s.ApplySlideImage(task.TaskID, full); err != nil {
return err
}
if cfg.PowerCostPerSlide > 0 {
err = s.userService.DecreasePower(task.UserID, cfg.PowerCostPerSlide, model.PowerLog{
Type: types.PowerConsume,
Model: generator.Provider(),
Remark: fmt.Sprintf("PPT 任务 %s 第 %d 页图片生成", task.TaskID, slide.SlideIndex),
})
if err != nil {
return fmt.Errorf("扣减算力失败:%v", err)
}
}
return nil
})
}
return group.Wait()
}
// startSlideImageGenerationAsync 在后台为 missing 页并发生图;若 setProcessingBeforeRun 为 true 则先置为 processing(用户主动 resume)。
func (s *PptService) startSlideImageGenerationAsync(task *Task, missing []SlideData, setProcessingBeforeRun bool) error {
if len(missing) == 0 {
return nil
}
cfg, err := s.loadPPTConfig()
if err != nil {
return err
}
cost := len(missing) * cfg.PowerCostPerSlide
if cost > 0 {
power, err := s.userPower(task.UserID)
if err != nil {
return err
}
if power < cost {
return ErrInsufficientPower
}
}
generator, err := NewImageGenerator(cfg)
if err != nil {
return fmt.Errorf("初始化图片生成器失败:%w", err)
}
if setProcessingBeforeRun {
s.UpdateStatus(task.TaskID, TaskStatusProcessing)
}
t := task
go func() {
bg := context.Background()
if err := s.runSlideImageJobs(bg, t, cfg, generator, missing); err != nil {
s.MarkAsFailed(task.TaskID, fmt.Sprintf("图片生成失败:%v", err))
return
}
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
}()
return nil
}
// RecoverStaleProcessingTasks 进程启动时扫描 DB 中仍为 processing 且存在缺图页的任务,重新拉起生图协程(用于服务中断后的恢复)。
func (s *PptService) RecoverStaleProcessingTasks() {
var jobs []model.PPTJob
if err := s.db.Where("status = ?", string(TaskStatusProcessing)).Find(&jobs).Error; err != nil {
logger.Warnf("PPT recover: list processing jobs failed: %v", err)
return
}
for i := range jobs {
task := modelToTask(&jobs[i])
missing := slidesNeedingImages(task)
if len(missing) == 0 {
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
logger.Infof("PPT recover: task %s was processing but all slides had images, marked completed", task.TaskID)
continue
}
if err := s.validateSlideOutline(task); err != nil {
logger.Warnf("PPT recover: task %s skip (invalid outline): %v", task.TaskID, err)
continue
}
if err := s.startSlideImageGenerationAsync(task, missing, false); err != nil {
if errors.Is(err, ErrInsufficientPower) {
logger.Warnf("PPT recover: task %s skip (insufficient power for %d slides)", task.TaskID, len(missing))
continue
}
logger.Warnf("PPT recover: task %s failed to restart: %v", task.TaskID, err)
continue
}
logger.Infof("PPT recover: restarted image generation for task %s (%d slides)", task.TaskID, len(missing))
}
}
// ResumeTask 继续为缺图页生图(需完整分镜占位;processing 时返回 ErrPptTaskBusy
func (s *PptService) ResumeTask(ctx context.Context, taskID string, userID uint) error {
task, ok := s.GetTask(taskID)
if !ok {
return ErrPptTaskNotFound
}
if task.UserID != userID {
return ErrPptTaskNotFound
}
if task.Status == TaskStatusProcessing {
return ErrPptTaskBusy
}
if task.Status == TaskStatusCompleted {
return ErrPptTaskNotResumable
}
if err := s.validateSlideOutline(task); err != nil {
return err
}
missing := slidesNeedingImages(task)
if len(missing) == 0 {
s.UpdateStatus(taskID, TaskStatusCompleted)
return nil
}
return s.startSlideImageGenerationAsync(task, missing, true)
}
// MarkAsFailed 标记任务失败
func (s *PptService) MarkAsFailed(taskID string, msg string) {
s.db.Model(&model.PPTJob{}).Where("task_id = ?", taskID).
Updates(map[string]interface{}{"status": string(TaskStatusFailed), "err_msg": msg, "updated_at": time.Now()})
}
// DeleteTask 删除任务并删除关联生成图片
// 仅允许删除 completed / failed 状态的任务,避免并发任务生成过程被打断。
func (s *PptService) DeleteTask(taskID string, userID uint) error {
var job model.PPTJob
if err := s.db.Where("task_id = ? AND user_id = ?", taskID, userID).First(&job).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || job.TaskId == "" {
return ErrPptTaskNotFound
}
return err
}
if job.Status != string(TaskStatusCompleted) && job.Status != string(TaskStatusFailed) {
return ErrPptTaskNotDeletable
}
// 删除所有幻灯片对应的图片对象。
uploader := s.uploadManager.GetUploadHandler()
for _, slide := range job.Slides {
if slide.ImageURL == "" {
continue
}
if err := uploader.Delete(slide.ImageURL); err != nil {
// 图片可能已过期/不存在/对象已被清理,此时不应阻断“删除任务记录”的主流程。
// 这里只记录日志,确保数据库记录被删除后前端认为任务删除成功。
logger.Warnf("delete ppt image failed (task_id=%s, url=%s): %v", taskID, slide.ImageURL, err)
}
}
// 最后删除任务记录(slides 会随之从数据库消失)。
return s.db.Where("task_id = ? AND user_id = ?", taskID, userID).Delete(&model.PPTJob{}).Error
}
// List 返回所有任务列表(从数据库按创建时间倒序),供调用方按用户/状态过滤与分页
func (s *PptService) List() []*Task {
var jobs []model.PPTJob
s.db.Order("created_at DESC").Find(&jobs)
tasks := make([]*Task, 0, len(jobs))
for i := range jobs {
tasks = append(tasks, modelToTask(&jobs[i]))
}
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
})
return tasks
}
// ListUserTasks 当前用户的任务分页列表(缺 title/thumb 时补全并写库)
func (s *PptService) ListUserTasks(ctx context.Context, userID uint, page, pageSize int) ([]*Task, int) {
all := s.List()
filtered := make([]*Task, 0, len(all))
for _, t := range all {
if t.UserID == userID {
filtered = append(filtered, t)
}
}
total := len(filtered)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
slice := filtered[start:end]
cfg, cfgErr := s.loadPPTConfig()
if cfgErr != nil {
logger.Warnf("ListUserTasks loadPPTConfig: %v", cfgErr)
}
if s.llm == nil {
s.llm = NewLLMClient()
}
for _, t := range slice {
s.ensureTaskMeta(ctx, t, cfg)
}
return slice, total
}
// EnsureTaskMeta 对外暴露标题/缩略图补全逻辑,供管理端列表/详情复用。
func (s *PptService) EnsureTaskMeta(ctx context.Context, task *Task) {
if task == nil {
return
}
cfg, cfgErr := s.loadPPTConfig()
if cfgErr != nil {
logger.Warnf("EnsureTaskMeta loadPPTConfig: %v", cfgErr)
}
if s.llm == nil {
s.llm = NewLLMClient()
}
s.ensureTaskMeta(ctx, task, cfg)
}
func (s *PptService) ensureTaskMeta(ctx context.Context, task *Task, cfg types.PPTConfig) {
updates := map[string]interface{}{}
if strings.TrimSpace(task.Title) == "" && strings.TrimSpace(task.Content) != "" {
title := "未命名演示文稿"
if cfg.OutlineLLMApiURL != "" && cfg.OutlineLLMApiKey != "" {
ti, err := s.llm.GeneratePPTTitle(ctx, cfg, task.Content, task.Language)
if err != nil {
logger.Warnf("ensureTaskMeta GeneratePPTTitle task_id=%s: %v", task.TaskID, err)
} else {
ti = strings.TrimSpace(ti)
if ti != "" {
title = truncateTitleRunes(ti, 255)
}
}
}
task.Title = title
updates["title"] = task.Title
}
if task.Thumb == "" && len(task.Slides) > 0 {
thumb := DerivePPTThumbFromSlides(task.Slides)
if thumb != "" {
task.Thumb = thumb
updates["thumb"] = thumb
}
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
_ = s.db.Model(&model.PPTJob{}).Where("task_id = ?", task.TaskID).Updates(updates).Error
}
}
// ListAdminJobs 管理后台任务列表(筛选 + 分页)
func (s *PptService) ListAdminJobs(ctx context.Context, page, pageSize, filterUserID int, status string) ([]*Task, int) {
items := s.List()
filtered := make([]*Task, 0, len(items))
for _, t := range items {
if filterUserID > 0 && int(t.UserID) != filterUserID {
continue
}
if status != "" && string(t.Status) != status {
continue
}
filtered = append(filtered, t)
}
total := len(filtered)
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
slice := filtered[start:end]
for _, t := range slice {
s.EnsureTaskMeta(ctx, t)
}
return slice, total
}
// Stats 任务状态统计(管理后台)
func (s *PptService) Stats() (total, completed, processing, failed, pending int64) {
for _, t := range s.List() {
total++
switch t.Status {
case TaskStatusCompleted:
completed++
case TaskStatusProcessing:
processing++
case TaskStatusFailed:
failed++
case TaskStatusPending:
pending++
}
}
return
}
// RunTask 执行 PPT 生成:分镜、并发生图、转存图片、扣算力
func (s *PptService) RunTask(ctx context.Context, task *Task, cfg types.PPTConfig) {
s.UpdateStatus(task.TaskID, TaskStatusProcessing)
maxPages := task.Pages
if maxPages <= 0 {
maxPages = cfg.MaxSlidesPerTask
}
plans, err := s.llm.GenerateSlides(ctx, cfg, task.Content, task.Prompt, task.Language, task.Mode, maxPages)
if err != nil {
s.MarkAsFailed(task.TaskID, fmt.Sprintf("生成分镜失败:%v", err))
return
}
if len(plans) == 0 {
s.MarkAsFailed(task.TaskID, "分镜结果为空")
return
}
total := len(plans)
if cfg.MaxSlidesPerTask > 0 && total > cfg.MaxSlidesPerTask {
plans = plans[:cfg.MaxSlidesPerTask]
total = len(plans)
}
outlines := slidePlansToOutlines(plans)
if err := s.saveSlidesOutline(task.TaskID, total, outlines); err != nil {
s.MarkAsFailed(task.TaskID, fmt.Sprintf("保存分镜占位失败:%v", err))
return
}
generator, err := NewImageGenerator(cfg)
if err != nil {
s.MarkAsFailed(task.TaskID, fmt.Sprintf("初始化图片生成器失败:%v", err))
return
}
if err := s.runSlideImageJobs(ctx, task, cfg, generator, outlines); err != nil {
s.MarkAsFailed(task.TaskID, fmt.Sprintf("图片生成失败:%v", err))
return
}
s.UpdateStatus(task.TaskID, TaskStatusCompleted)
}
func (s *PptService) loadPPTConfig() (types.PPTConfig, error) {
var cfgModel model.Config
var pptCfg types.PPTConfig
err := s.db.Where("name", types.ConfigKeyPPT).First(&cfgModel).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
pptCfg.MaxSlidesPerTask = 30
pptCfg.MaxConcurrentRequests = 3
pptCfg.QPSLimit = 1
pptCfg.PowerCostPerSlide = 0
return pptCfg, nil
}
return pptCfg, err
}
err = utils.JsonDecode(cfgModel.Value, &pptCfg)
if err != nil {
return pptCfg, err
}
legacyMax10 := pptCfg.MaxSlidesPerTask == 10
if pptCfg.MaxSlidesPerTask <= 0 {
pptCfg.MaxSlidesPerTask = 30
}
if legacyMax10 {
// 与前端 PPT 页数控件 max=30 对齐;历史默认 10 会导致用户选择 12/15 仍被截断为 10
pptCfg.MaxSlidesPerTask = 30
}
if pptCfg.MaxConcurrentRequests <= 0 {
pptCfg.MaxConcurrentRequests = 3
}
if pptCfg.QPSLimit <= 0 {
pptCfg.QPSLimit = 1
}
if legacyMax10 {
val := utils.JsonEncode(pptCfg)
_ = s.db.Model(&model.Config{}).Where("name = ?", types.ConfigKeyPPT).Update("value", val)
}
return pptCfg, nil
}
+50
View File
@@ -0,0 +1,50 @@
package ppt
import (
"encoding/base64"
"fmt"
"geekai/core/types"
"net/http"
"net/url"
"os"
"strings"
)
// PrepareReferenceInputsForImg2Img 将幻灯片参考图转为第三方 API 可消费的输入:本地存储时读文件并转为 data URI(base64),公网 URL 原样传递。
func PrepareReferenceInputsForImg2Img(rawURL string, oss types.OSSConfig, app *types.AppConfig) ([]string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, fmt.Errorf("empty reference image url")
}
if strings.HasPrefix(rawURL, "data:") {
return []string{rawURL}, nil
}
if oss.Active == "local" {
if fp, ok := mapLocalUploadFile(rawURL, oss.Local); ok {
b, err := os.ReadFile(fp)
if err != nil {
return nil, fmt.Errorf("read local reference image: %w", err)
}
if _, err := decodeImageBytes(b); err != nil {
return nil, fmt.Errorf("reference is not a valid image: %w", err)
}
mime := http.DetectContentType(b)
if !strings.HasPrefix(mime, "image/") {
mime = "image/png"
}
dataURI := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b))
return []string{dataURI}, nil
}
}
if u, err := url.Parse(rawURL); err == nil && u.Scheme != "" && u.Host != "" {
return []string{rawURL}, nil
}
abs := resolveAbsoluteImageURL(rawURL, oss.Local, app)
if strings.TrimSpace(abs) == "" {
return nil, fmt.Errorf("cannot resolve reference image url")
}
return []string{abs}, nil
}
+182
View File
@@ -0,0 +1,182 @@
package ppt
import (
"context"
"fmt"
"geekai/core/types"
"geekai/store/model"
"geekai/store/vo"
"strings"
)
// EditSlideImage 基于当前激活图做图生图,追加 image_history 并将 image_url 设为新版。
func (s *PptService) EditSlideImage(ctx context.Context, taskID string, userID uint, slideIndex int, prompt string, oss types.OSSConfig, app *types.AppConfig) ([]SlideData, error) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return nil, fmt.Errorf("请输入修改说明")
}
task, ok := s.GetTask(taskID)
if !ok {
return nil, ErrPptTaskNotFound
}
if task.UserID != userID {
return nil, ErrPptTaskNotFound
}
refURL := ""
for _, sl := range task.Slides {
if sl.SlideIndex == slideIndex {
normalizeSlideImageHistory(&sl)
refURL = strings.TrimSpace(sl.ImageURL)
break
}
}
if refURL == "" {
if slideExists(task.Slides, slideIndex) {
return nil, ErrPptSlideNoImage
}
return nil, ErrPptSlideNotFound
}
cfg, err := s.loadPPTConfig()
if err != nil {
return nil, err
}
power, err := s.userPower(userID)
if err != nil {
return nil, err
}
if cfg.PowerCostPerSlide > 0 && power < cfg.PowerCostPerSlide {
return nil, ErrInsufficientPower
}
generator, err := NewImageGenerator(cfg)
if err != nil {
return nil, err
}
refInputs, err := PrepareReferenceInputsForImg2Img(refURL, oss, app)
if err != nil {
return nil, fmt.Errorf("准备参考图失败:%w", err)
}
imgURL, err := generator.GenerateWithReference(ctx, prompt, refInputs)
if err != nil {
return nil, err
}
storedURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(imgURL, ".png", false)
if err != nil {
return nil, fmt.Errorf("转存图片失败:%w", err)
}
if err := s.applySlideImageEdit(taskID, slideIndex, storedURL, prompt); err != nil {
return nil, err
}
if cfg.PowerCostPerSlide > 0 {
err = s.userService.DecreasePower(userID, cfg.PowerCostPerSlide, model.PowerLog{
Type: types.PowerConsume,
Model: generator.Provider(),
Remark: fmt.Sprintf("PPT 任务 %s 第 %d 页图生图编辑", taskID, slideIndex),
})
if err != nil {
return nil, fmt.Errorf("扣减算力失败:%v", err)
}
}
task2, _ := s.GetTask(taskID)
return task2.Slides, nil
}
func slideExists(slides []SlideData, slideIndex int) bool {
for _, sl := range slides {
if sl.SlideIndex == slideIndex {
return true
}
}
return false
}
func (s *PptService) applySlideImageEdit(taskID string, slideIndex int, newURL string, editPrompt string) error {
s.slidesLock.Lock()
defer s.slidesLock.Unlock()
var job model.PPTJob
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
return err
}
slides := job.Slides
found := false
for i := range slides {
if slides[i].SlideIndex != slideIndex {
continue
}
found = true
sd := voToSlide(slides[i])
normalizeSlideImageHistory(&sd)
if strings.TrimSpace(sd.ImageURL) == "" {
return ErrPptSlideNoImage
}
sd.ImageHistory = append(sd.ImageHistory, vo.PPTSlideImageVersion{
ImageURL: newURL,
Prompt: editPrompt,
})
sd.ImageURL = newURL
slides[i] = slideToVO(sd)
break
}
if !found {
return ErrPptSlideNotFound
}
job.Slides = slides
biz := voSlidesToBiz(slides)
return s.refreshJobMeta(&job, biz)
}
// SetActiveSlideVersion 将 image_url 切换为 image_history[versionIndex]。
func (s *PptService) SetActiveSlideVersion(taskID string, userID uint, slideIndex int, versionIndex int) ([]SlideData, error) {
task, ok := s.GetTask(taskID)
if !ok {
return nil, ErrPptTaskNotFound
}
if task.UserID != userID {
return nil, ErrPptTaskNotFound
}
s.slidesLock.Lock()
defer s.slidesLock.Unlock()
var job model.PPTJob
if err := s.db.Where("task_id = ?", taskID).First(&job).Error; err != nil {
return nil, err
}
slides := job.Slides
found := false
for i := range slides {
if slides[i].SlideIndex != slideIndex {
continue
}
found = true
sd := voToSlide(slides[i])
normalizeSlideImageHistory(&sd)
hist := sd.ImageHistory
if versionIndex < 0 || versionIndex >= len(hist) {
return nil, ErrPptInvalidVersionIndex
}
sd.ImageURL = hist[versionIndex].ImageURL
slides[i] = slideToVO(sd)
break
}
if !found {
return nil, ErrPptSlideNotFound
}
job.Slides = slides
biz := voSlidesToBiz(slides)
if err := s.refreshJobMeta(&job, biz); err != nil {
return nil, err
}
out := voSlidesToBizNormalized(job.Slides)
return out, nil
}