项目初始化

This commit is contained in:
2026-06-18 16:01:39 +08:00
commit ab2c76f971
42 changed files with 4474 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/.idea/*
+35
View File
@@ -0,0 +1,35 @@
# 阶段1: 构建
FROM golang:alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai
ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=auto
WORKDIR /build
COPY . .
RUN go mod download && go mod tidy
RUN go build -ldflags="-s -w" -o main ./main.go
# 阶段2: 运行
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata ffmpeg
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=builder /build/main .
COPY --from=builder /build/config.yml .
EXPOSE 3006
VOLUME ["/app/output"]
CMD ["./main"]
+57
View File
@@ -0,0 +1,57 @@
package http
import (
"fmt"
"reflect"
"regexp"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
var Httpserver = g.Server()
func init() {
err := gtime.SetTimeZone("Asia/Shanghai")
if err != nil {
panic("设置时区失败")
}
Httpserver.SetOpenApiPath("/api.json")
Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse)
}
// RouteRegister 根据控制器结构体名称自动注册路由
func RouteRegister(controllers []interface{}) {
re := regexp.MustCompile("[A-Z]")
for _, t := range controllers {
sName := reflect.ValueOf(t).Elem().Type().Name()
convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string {
return fmt.Sprintf("/%s", strings.ToLower(s))
})
if len(convertedStr) > 0 && convertedStr[0] == '/' {
convertedStr = convertedStr[1:]
}
Httpserver.Group("/"+convertedStr, func(group *ghttp.RouterGroup) {
group.Bind(t)
})
}
go Httpserver.Run()
}
// RouteRegisterRaw 注册原始路径路由(支持 :param 路径参数)
func RouteRegisterRaw(method, pattern string, handler ghttp.HandlerFunc) {
switch method {
case "GET":
Httpserver.BindHandler(pattern, handler)
case "POST":
Httpserver.BindHandler(pattern, handler)
case "PUT":
Httpserver.BindHandler(pattern, handler)
case "DELETE":
Httpserver.BindHandler(pattern, handler)
default:
Httpserver.BindHandler(pattern, handler)
}
}
+8
View File
@@ -0,0 +1,8 @@
database:
default:
name: short_drama.db
type: sqlite
server:
address: :3006
name: video
workerId: 1
+73
View File
@@ -0,0 +1,73 @@
module video-factory
go 1.26.1
require (
github.com/cloudwego/eino v0.9.5
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/jsonschema v1.0.3 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+228
View File
@@ -0,0 +1,228 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.9.5 h1:0Nftjx9gPek/2S/hzm38LVxSjk5/6mqRr3I9VKrKvm4=
github.com/cloudwego/eino v0.9.5/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9 h1:xCz/mp43JeWqupjPR3zLRArmwC6P29/6lTwbwh1yzYM=
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9/go.mod h1:slTGTuhzkzhNavf+1UtUg1FvUSA31iNAF+rq1mT4SnI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"context"
"embed"
"net/http"
commonHttp "video-factory/common/http"
"video-factory/shortdrama/controller"
"video-factory/shortdrama/service"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/net/ghttp"
)
//go:embed shortdrama/view/*.html
var viewFS embed.FS
func main() {
// ==================== HTML 页面路由 ====================
servePage := func(name string) ghttp.HandlerFunc {
return func(r *ghttp.Request) {
data, err := viewFS.ReadFile("shortdrama/view/" + name)
if err != nil {
r.Response.WriteStatus(http.StatusNotFound)
return
}
r.Response.Write(data)
}
}
commonHttp.Httpserver.BindHandler("/", servePage("index.html"))
// ==================== API 路由(通过 RouteRegister 自动注册,遵循 ai-agent 规范) ====================
commonHttp.RouteRegister([]interface{}{
controller.Drama,
controller.Config,
controller.Prompt,
})
// 为合并后的视频提供静态文件服务
commonHttp.Httpserver.AddStaticPath("/output", "output")
// 数据库迁移
service.DramaService.Migrate(context.Background())
// 初始化提示词表
_ = service.PromptService.Init(context.Background())
// 启动时恢复未完成的视频生成轮询
service.DramaService.StartVideoPoller(context.Background())
// 保持运行
select {}
}
BIN
View File
Binary file not shown.
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"context"
"fmt"
"github.com/cloudwego/eino-ext/components/model/qwen"
"github.com/cloudwego/eino/components/model"
"github.com/gogf/gf/v2/util/gconv"
)
// ModelConfig 模型配置 — 所有字段必须显式提供,无硬编码默认值
type ModelConfig struct {
ModelName string // 对话模型名
APIKey string // API密钥
BaseURL string // API地址
MaxTokens int // 最大Token数
Temperature float32 // 温度参数
ImageModel string // 图片模型名
}
// context keys
type ctxKey string
const (
ctxKeyAPIKey ctxKey = "api_key"
ctxKeyImageModel ctxKey = "image_model"
ctxKeyBaseURL ctxKey = "base_url"
)
// WithModelConfig 将模型配置注入 context,供工具函数读取
func WithModelConfig(ctx context.Context, cfg *ModelConfig) context.Context {
ctx = context.WithValue(ctx, ctxKeyAPIKey, cfg.APIKey)
ctx = context.WithValue(ctx, ctxKeyImageModel, cfg.ImageModel)
ctx = context.WithValue(ctx, ctxKeyBaseURL, cfg.BaseURL)
return ctx
}
// GetAPIKey 从 context 获取 API key
func GetAPIKey(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyAPIKey).(string); ok && v != "" {
return v
}
return ""
}
// GetImageModel 从 context 获取图片模型名
func GetImageModel(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyImageModel).(string); ok && v != "" {
return v
}
return ""
}
// GetBaseURL 从 context 获取 API 地址
func GetBaseURL(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyBaseURL).(string); ok && v != "" {
return v
}
return ""
}
// NewChatModel 根据配置初始化聊天模型
func NewChatModel(ctx context.Context, cfg *ModelConfig) (cm model.ChatModel, err error) {
if cfg == nil {
return nil, fmt.Errorf("模型配置不能为空")
}
if cfg.APIKey == "" {
return nil, fmt.Errorf("APIKey 未配置")
}
if cfg.ModelName == "" {
return nil, fmt.Errorf("模型名称未配置")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("API 地址未配置")
}
maxTokens := cfg.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
temperature := cfg.Temperature
if temperature <= 0 {
temperature = 0.8
}
config := &qwen.ChatModelConfig{
APIKey: cfg.APIKey,
Model: cfg.ModelName,
BaseURL: cfg.BaseURL,
MaxTokens: gconv.PtrInt(maxTokens),
Temperature: gconv.PtrFloat32(temperature),
}
cm, err = qwen.NewChatModel(ctx, config)
if err != nil {
return nil, fmt.Errorf("创建模型失败: %w", err)
}
return cm, nil
}
+139
View File
@@ -0,0 +1,139 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/frame/g"
)
// ReActAgent 实现 ReAct 模式的智能体
type ReActAgent struct {
model model.ChatModel
tools []*ToolInfo
systemPrompt string
maxStep int
}
// NewReActAgent 创建 ReAct 智能体
func NewReActAgent(ctx context.Context, chatModel model.ChatModel, tools []*ToolInfo, systemPrompt string, maxStep int) *ReActAgent {
return &ReActAgent{
model: chatModel,
tools: tools,
systemPrompt: systemPrompt,
maxStep: maxStep,
}
}
// Run 执行 ReAct 循环
// 标准流程: 思考 → 行动(调用工具) → 观察(工具结果) → 重复 → 最终回答
func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error) {
messages := []*schema.Message{
schema.SystemMessage(a.systemPrompt),
schema.UserMessage(userInput),
}
// 构建 toolInfos 传给模型
toolInfos := make([]*schema.ToolInfo, 0, len(a.tools))
for _, t := range a.tools {
toolInfos = append(toolInfos, t.ToEinoToolInfo())
}
g.Log().Infof(ctx, "ReAct 开始执行,maxStep=%d, tools=%d", a.maxStep, len(a.tools))
for step := 0; step < a.maxStep; step++ {
g.Log().Infof(ctx, "ReAct step %d/%d: 调用模型...", step+1, a.maxStep)
// 1. LLM 思考并决定行动
startTime := time.Now()
result, err := a.model.Generate(ctx, messages, model.WithTools(toolInfos))
elapsed := time.Since(startTime)
if err != nil {
return "", fmt.Errorf("step %d: 模型调用失败: %w", step, err)
}
g.Log().Infof(ctx, "ReAct step %d/%d: 模型返回 (耗时 %v), content_len=%d, ToolCalls=%d",
step+1, a.maxStep, elapsed, len(result.Content), len(result.ToolCalls))
messages = append(messages, result)
// 2. 检查是否有工具调用
if len(result.ToolCalls) == 0 {
// 没有工具调用 → 最终回答
g.Log().Infof(ctx, "ReAct step %d/%d: 无工具调用,返回最终结果 (content长度=%d)", step+1, a.maxStep, len(result.Content))
return result.Content, nil
}
// 3. 执行每个工具调用
for _, tc := range result.ToolCalls {
tool := a.findTool(tc.Function.Name)
if tool == nil {
g.Log().Warningf(ctx, "ReAct step %d: 未知工具: %s", step+1, tc.Function.Name)
toolResultMsg := &schema.Message{
Role: schema.Tool,
Content: fmt.Sprintf("未知工具: %s", tc.Function.Name),
ToolName: tc.Function.Name,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
continue
}
// 解析参数
var args map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
g.Log().Warningf(ctx, "ReAct step %d: 参数解析失败: %v", step+1, err)
toolResultMsg := &schema.Message{
Role: schema.Tool,
Content: fmt.Sprintf("参数解析失败: %v", err),
ToolName: tc.Function.Name,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
continue
}
g.Log().Infof(ctx, "ReAct step %d: 调用工具 %s, 参数: %s", step+1, tc.Function.Name, tc.Function.Arguments)
// 执行工具
toolStart := time.Now()
output, err := tool.Func(ctx, args)
toolElapsed := time.Since(toolStart)
if err != nil {
output = fmt.Sprintf("工具执行失败: %v", err)
g.Log().Warningf(ctx, "ReAct step %d: 工具 %s 执行失败 (耗时 %v): %v", step+1, tc.Function.Name, toolElapsed, err)
} else {
truncated := output
if len(truncated) > 200 {
truncated = truncated[:200] + "..."
}
g.Log().Infof(ctx, "ReAct step %d: 工具 %s 执行成功 (耗时 %v), 结果长度=%d, 预览: %s", step+1, tc.Function.Name, toolElapsed, len(output), truncated)
}
// 4. 观察工具结果
toolResultMsg := &schema.Message{
Role: schema.Tool,
Content: output,
Name: tc.Function.Name,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
}
}
g.Log().Errorf(ctx, "ReAct 达到最大步骤数 %d,生成未完成", a.maxStep)
return "", fmt.Errorf("达到最大步骤数 %d,生成未完成", a.maxStep)
}
func (a *ReActAgent) findTool(name string) *ToolInfo {
for _, t := range a.tools {
if t.Name == name {
return t
}
}
return nil
}
+449
View File
@@ -0,0 +1,449 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/cloudwego/eino/schema"
)
// ToolInfo 工具定义
type ToolInfo struct {
Name string
Description string
Parameters map[string]any
Func func(ctx context.Context, args map[string]any) (string, error)
}
// ToEinoToolInfo 转换为 Eino 的 ToolInfo 格式
func (t *ToolInfo) ToEinoToolInfo() *schema.ToolInfo {
params := make(map[string]*schema.ParameterInfo)
if paramsMap, ok := t.Parameters["properties"].(map[string]any); ok {
for key, val := range paramsMap {
if prop, ok := val.(map[string]any); ok {
desc, _ := prop["description"].(string)
pType, _ := prop["type"].(string)
params[key] = &schema.ParameterInfo{
Type: schema.DataType(pType),
Desc: desc,
Required: false,
}
}
}
}
if required, ok := t.Parameters["required"].([]any); ok {
for _, r := range required {
if rStr, ok := r.(string); ok {
if p, exists := params[rStr]; exists {
p.Required = true
}
}
}
}
return &schema.ToolInfo{
Name: t.Name,
Desc: t.Description,
ParamsOneOf: schema.NewParamsOneOfByParams(params),
}
}
// GetTools 获取 ReAct Agent 可用的所有工具
func GetTools() []*ToolInfo {
return []*ToolInfo{
parseScriptTool(),
analyzeScriptForEpisodeTool(),
generateCharacterImageTool(),
generateSceneImageTool(),
}
}
// ==================== Tool 1: 解析剧本 ====================
func parseScriptTool() *ToolInfo {
return &ToolInfo{
Name: "parse_script",
Description: "将原始剧本文本解析为结构化的剧集列表,支持用 --- 分隔的多集剧本",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"raw_script": map[string]any{
"type": "string",
"description": "原始剧本文本,多集用 --- 分隔",
},
},
"required": []string{"raw_script"},
},
Func: func(ctx context.Context, args map[string]any) (string, error) {
rawScript, _ := args["raw_script"].(string)
if rawScript == "" {
return "", fmt.Errorf("剧本内容不能为空")
}
// 按 --- 分割多集
episodeTexts := strings.Split(rawScript, "---")
type episodeInfo struct {
Index int `json:"index"`
Title string `json:"title"`
Script string `json:"script"`
}
var episodes []episodeInfo
for i, text := range episodeTexts {
text = strings.TrimSpace(text)
if text == "" {
continue
}
lines := strings.SplitN(text, "\n", 2)
title := strings.TrimSpace(lines[0])
// 去掉可能的序号前缀如 "第1集"、"第一集"、"Episode 1" 等
title = cleanEpisodeTitle(title)
content := ""
if len(lines) > 1 {
content = strings.TrimSpace(lines[1])
} else {
content = title
}
episodes = append(episodes, episodeInfo{
Index: i + 1,
Title: title,
Script: content,
})
}
result, _ := json.Marshal(map[string]any{
"episodes": episodes,
"total_episodes": len(episodes),
})
return string(result), nil
},
}
}
// ==================== Tool 2: 分析单集剧本 ====================
func analyzeScriptForEpisodeTool() *ToolInfo {
return &ToolInfo{
Name: "analyze_script_for_episode",
Description: "分析单集剧本内容,根据时长将剧本拆分为多个场景,识别出场演员及画面描述",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"episode_index": map[string]any{
"type": "integer",
"description": "剧集索引(从1开始)",
},
"episode_title": map[string]any{
"type": "string",
"description": "本集标题",
},
"script_content": map[string]any{
"type": "string",
"description": "本集剧本内容",
},
"duration": map[string]any{
"type": "integer",
"description": "本集总时长(秒)",
},
"characters": map[string]any{
"type": "string",
"description": "演员列表JSON,格式:[{\"name\":\"演员名\",\"description\":\"演员描述\"}]",
},
},
"required": []string{"episode_index", "script_content", "duration", "characters"},
},
Func: func(ctx context.Context, args map[string]any) (string, error) {
scriptContent, _ := args["script_content"].(string)
durationFloat, _ := args["duration"].(float64)
duration := int(durationFloat)
if scriptContent == "" {
return "", fmt.Errorf("剧本内容不能为空")
}
if duration <= 0 {
duration = 60 // 默认60秒
}
episodeIndex, _ := args["episode_index"].(float64)
title, _ := args["episode_title"].(string)
// 按空行或场景标记分割场景
sceneTexts := strings.Split(scriptContent, "\n\n")
type sceneInfo struct {
Index int `json:"index"`
Description string `json:"description"`
Lines string `json:"lines"`
Duration int `json:"duration"`
Characters []string `json:"characters"`
VisualDesc string `json:"visualDesc"`
}
var scenes []sceneInfo
totalScenes := len(sceneTexts)
if totalScenes == 0 {
totalScenes = 1
sceneTexts = []string{scriptContent}
}
// 推测出场演员
var characters []string
if charsRaw, ok := args["characters"].(string); ok && charsRaw != "" {
var chars []struct {
Name string `json:"name"`
}
json.Unmarshal([]byte(charsRaw), &chars)
for _, c := range chars {
characters = append(characters, c.Name)
}
}
perSceneDuration := duration / totalScenes
remainder := duration % totalScenes
for i, text := range sceneTexts {
text = strings.TrimSpace(text)
if text == "" {
continue
}
sceneDur := perSceneDuration
if i < remainder {
sceneDur++
}
// 提取第一行作为场景描述
lines := strings.SplitN(text, "\n", 2)
desc := strings.TrimSpace(lines[0])
content := ""
if len(lines) > 1 {
content = strings.TrimSpace(lines[1])
} else {
content = desc
}
// 匹配出场演员
var sceneChars []string
for _, c := range characters {
if strings.Contains(text, c) {
sceneChars = append(sceneChars, c)
}
}
visualDesc := fmt.Sprintf("场景%d:%s,画面风格根据剧本内容自动生成", i+1, desc)
scenes = append(scenes, sceneInfo{
Index: i + 1,
Description: desc,
Lines: content,
Duration: sceneDur,
Characters: sceneChars,
VisualDesc: visualDesc,
})
}
result, _ := json.Marshal(map[string]any{
"episode_index": int(episodeIndex),
"episode_title": title,
"total_scenes": len(scenes),
"total_duration": duration,
"scenes": scenes,
})
return string(result), nil
},
}
}
// ==================== Tool 3: 生成人物形象图 ====================
func generateCharacterImageTool() *ToolInfo {
return &ToolInfo{
Name: "generate_character_image",
Description: "根据演员描述生成人物形象图,返回图片的base64编码数据",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"character_name": map[string]any{
"type": "string",
"description": "演员名称",
},
"character_description": map[string]any{
"type": "string",
"description": "演员详细描述(外貌、服装、气质等)",
},
"style": map[string]any{
"type": "string",
"description": "整体风格(如:古装、现代、科幻)",
},
},
"required": []string{"character_name", "character_description", "style"},
},
Func: func(ctx context.Context, args map[string]any) (string, error) {
name, _ := args["character_name"].(string)
desc, _ := args["character_description"].(string)
style, _ := args["style"].(string)
prompt := fmt.Sprintf("演员:%s,描述:%s,风格:%s", name, desc, style)
imgBase64, err := generateRealImage(ctx, prompt)
if err != nil {
return "", fmt.Errorf("生成演员图片失败: %w", err)
}
result, _ := json.Marshal(map[string]any{
"character_name": name,
"image_base64": imgBase64,
"status": "success",
})
return string(result), nil
},
}
}
// ==================== Tool 4: 生成场景图 ====================
func generateSceneImageTool() *ToolInfo {
return &ToolInfo{
Name: "generate_scene_image",
Description: "根据场景的画面描述生成场景图片,返回图片的base64编码数据",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"episode_index": map[string]any{
"type": "integer",
"description": "剧集索引",
},
"scene_index": map[string]any{
"type": "integer",
"description": "场景索引",
},
"visual_description": map[string]any{
"type": "string",
"description": "画面描述(场景设定、演员动作、镜头角度等)",
},
"style": map[string]any{
"type": "string",
"description": "整体风格",
},
},
"required": []string{"visual_description", "style"},
},
Func: func(ctx context.Context, args map[string]any) (string, error) {
visualDesc, _ := args["visual_description"].(string)
style, _ := args["style"].(string)
episodeIdx, _ := args["episode_index"].(float64)
sceneIdx, _ := args["scene_index"].(float64)
prompt := fmt.Sprintf("画面描述:%s,风格:%s", visualDesc, style)
imgBase64, err := generateRealImage(ctx, prompt)
if err != nil {
return "", fmt.Errorf("生成场景图片失败: %w", err)
}
result, _ := json.Marshal(map[string]any{
"episode_index": int(episodeIdx),
"scene_index": int(sceneIdx),
"image_base64": imgBase64,
"status": "success",
})
return string(result), nil
},
}
}
// ==================== 工具函数 ====================
func cleanEpisodeTitle(title string) string {
prefixes := []string{"第", "Episode", "episode", "EP"}
for _, p := range prefixes {
if strings.HasPrefix(title, p) {
// 去掉序号前缀后取标题部分
parts := strings.SplitN(title, " ", 2)
if len(parts) > 1 {
return parts[1]
}
parts = strings.SplitN(title, "", 2)
if len(parts) > 1 {
return parts[1]
}
}
}
return title
}
func generateRealImage(ctx context.Context, prompt string) (string, error) {
imageModel := GetImageModel(ctx)
if imageModel == "" {
// 图片模型未配置,返回空字符串(不报错),避免 agent 反复重试
return "", nil
}
// 调用通义万相生成图片
apiKey := GetAPIKey(ctx)
url := "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
body := map[string]any{
"model": imageModel,
"input": map[string]any{
"messages": []map[string]any{
{
"role": "user",
"content": []map[string]string{
{"type": "text", "text": prompt},
},
},
},
},
"parameters": map[string]any{
"size": "1024*1364",
"n": 1,
"watermark": false,
},
}
payload, _ := json.Marshal(body)
httpClient := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result struct {
Output struct {
Choices []struct {
Message struct {
Content []struct {
Image string `json:"image"`
} `json:"content"`
} `json:"message"`
} `json:"choices"`
} `json:"output"`
Code string `json:"code"`
}
err = json.Unmarshal(data, &result)
if err != nil || len(result.Output.Choices) == 0 || result.Code != "" {
return "", fmt.Errorf("生成图片失败: %s", string(data))
}
imgBase64 := result.Output.Choices[0].Message.Content[0].Image
if imgBase64 == "" {
return "", fmt.Errorf("图片内容为空")
}
return imgBase64, nil
}
+10
View File
@@ -0,0 +1,10 @@
package public
const (
TableNameDrama = "short_drama"
TableNameCharacter = "short_drama_character"
TableNameEpisode = "short_drama_episode"
TableNameGenerationTask = "short_drama_generation_task"
TableNameModelConfig = "short_drama_model_config"
TableNamePrompt = "short_drama_prompt"
)
+14
View File
@@ -0,0 +1,14 @@
package consts
const (
EpisodeStatusPending = "pending"
EpisodeStatusGenerating = "generating"
EpisodeStatusReview = "review"
EpisodeStatusCompleted = "completed"
EpisodeStatusFailed = "failed"
TaskStatusGenerating = "generating"
TaskStatusReview = "review"
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
)
@@ -0,0 +1,21 @@
package controller
import (
"context"
"video-factory/shortdrama/model/dto"
"video-factory/shortdrama/service"
)
type config struct{}
var Config = new(config)
func (c *config) Get(ctx context.Context, req *dto.GetModelConfigReq) (res *dto.GetModelConfigRes, err error) {
cfg := service.ConfigService.Get(ctx)
return &dto.GetModelConfigRes{ModelConfig: cfg}, nil
}
func (c *config) Save(ctx context.Context, req *dto.SaveModelConfigReq) (res *struct{}, err error) {
return nil, service.ConfigService.Save(ctx, &req.ModelConfig)
}
+132
View File
@@ -0,0 +1,132 @@
package controller
import (
"context"
"fmt"
"video-factory/shortdrama/model/dto"
"video-factory/shortdrama/service"
)
type drama struct{}
var Drama = new(drama)
// ==================== Drama CRUD ====================
func (c *drama) List(ctx context.Context, req *dto.ListDramaReq) (res *dto.ListDramaRes, err error) {
list, err := service.DramaService.List(ctx)
if err != nil {
return nil, err
}
return &dto.ListDramaRes{List: list}, nil
}
func (c *drama) Create(ctx context.Context, req *dto.CreateDramaReq) (res *dto.CreateDramaRes, err error) {
id, err := service.DramaService.Create(ctx, req.Title, req.Style, int64(req.EpisodeDuration))
if err != nil {
return nil, err
}
return &dto.CreateDramaRes{Id: id}, nil
}
func (c *drama) Get(ctx context.Context, req *dto.GetDramaReq) (res *dto.GetDramaRes, err error) {
drama, characters, episodes, tasks, err := service.DramaService.Get(ctx, req.Id)
if err != nil {
return nil, err
}
return &dto.GetDramaRes{
Drama: drama,
Characters: characters,
Episodes: episodes,
Tasks: tasks,
}, nil
}
func (c *drama) Update(ctx context.Context, req *dto.UpdateDramaReq) (res *struct{}, err error) {
return nil, service.DramaService.Update(ctx, req.Id, req.Title, req.Style, int64(req.EpisodeDuration))
}
func (c *drama) Delete(ctx context.Context, req *dto.DeleteDramaReq) (res *struct{}, err error) {
return nil, service.DramaService.Delete(ctx, req.Id)
}
// ==================== Character CRUD ====================
func (c *drama) AddCharacter(ctx context.Context, req *dto.AddCharacterReq) (res *dto.GetDramaRes, err error) {
_, err = service.DramaService.AddCharacter(ctx, req.DramaId, req.Name, req.Description, req.VoiceType, req.PortraitUrl)
if err != nil {
return nil, err
}
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
}
func (c *drama) UpdateCharacter(ctx context.Context, req *dto.UpdateCharacterReq) (res *dto.GetDramaRes, err error) {
err = service.DramaService.UpdateCharacter(ctx, req.DramaId, req.CharId, req.Name, req.Description, req.VoiceType, req.PortraitUrl)
if err != nil {
return nil, err
}
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
}
func (c *drama) DeleteCharacter(ctx context.Context, req *dto.DeleteCharacterReq) (res *dto.GetDramaRes, err error) {
err = service.DramaService.DeleteCharacter(ctx, req.DramaId, req.CharId)
if err != nil {
return nil, err
}
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
}
// ==================== Episode CRUD ====================
func (c *drama) AddEpisode(ctx context.Context, req *dto.AddEpisodeReq) (res *dto.GetDramaRes, err error) {
_, err = service.DramaService.AddEpisode(ctx, req.DramaId, req.Title, req.Script, req.Index)
if err != nil {
return nil, err
}
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
}
func (c *drama) UpdateEpisode(ctx context.Context, req *dto.UpdateEpisodeReq) (res *dto.GetDramaRes, err error) {
err = service.DramaService.UpdateEpisode(ctx, req.DramaId, req.EpId, req.Title, req.Script, req.Index)
if err != nil {
return nil, err
}
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
}
func (c *drama) DeleteEpisode(ctx context.Context, req *dto.DeleteEpisodeReq) (res *dto.GetDramaRes, err error) {
err = service.DramaService.DeleteEpisode(ctx, req.DramaId, req.EpId)
if err != nil {
return nil, err
}
return c.Get(ctx, &dto.GetDramaReq{Id: req.DramaId})
}
// ==================== Generation ====================
func (c *drama) GenerateEpisode(ctx context.Context, req *dto.GenerateEpisodeReq) (res *struct{}, err error) {
return nil, service.DramaService.GenerateEpisode(ctx, req.DramaId, req.EpId)
}
func (c *drama) ContinueSegment(ctx context.Context, req *dto.ContinueSegmentReq) (res *struct{}, err error) {
return nil, service.DramaService.ContinueSegment(ctx, req.TaskId)
}
func (c *drama) FeedbackSegment(ctx context.Context, req *dto.FeedbackSegmentReq) (res *struct{}, err error) {
return nil, service.DramaService.FeedbackSegment(ctx, req.TaskId, req.Feedback)
}
func (c *drama) GetEpisodeTask(ctx context.Context, req *dto.GetEpisodeTaskReq) (res *dto.GetEpisodeTaskRes, err error) {
task, err := service.DramaService.GetEpisodeTask(ctx, req.EpId)
if err != nil {
return nil, err
}
return &dto.GetEpisodeTaskRes{Task: task}, nil
}
// ==================== 保留旧版 Generate(兼容) ====================
func (c *drama) Generate(ctx context.Context, req *struct{}) (res *struct{}, err error) {
return nil, fmt.Errorf("旧版接口已弃用,请使用单集生成")
}
@@ -0,0 +1,26 @@
package controller
import (
"context"
"video-factory/shortdrama/model/dto"
"video-factory/shortdrama/service"
)
type prompt struct{}
var Prompt = new(prompt)
func (c *prompt) List(ctx context.Context, req *dto.ListPromptReq) (res *dto.ListPromptRes, err error) {
list := service.PromptService.GetList(ctx)
return &dto.ListPromptRes{List: list}, nil
}
func (c *prompt) Get(ctx context.Context, req *dto.GetPromptReq) (res *dto.GetPromptRes, err error) {
p := service.PromptService.GetByName(ctx, req.Name)
return &dto.GetPromptRes{Prompt: p}, nil
}
func (c *prompt) Save(ctx context.Context, req *dto.SavePromptReq) (res *struct{}, err error) {
return nil, service.PromptService.Save(ctx, &req.Prompt)
}
+64
View File
@@ -0,0 +1,64 @@
package dao
import (
"context"
"video-factory/shortdrama/consts/public"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var Character = &characterDao{}
type characterDao struct{}
func (d *characterDao) Insert(ctx context.Context, data *entity.Character) (id int64, err error) {
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
delete(m, "created_at")
delete(m, "updated_at")
delete(m, "deleted_at")
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Data(m).Insert()
if err != nil {
return
}
return r.LastInsertId()
}
func (d *characterDao) GetOne(ctx context.Context, id int64) (res *entity.Character, err error) {
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("id", id).One()
if err != nil {
return
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
func (d *characterDao) Update(ctx context.Context, id int64, data *entity.Character) error {
_, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Data(data).Where("id", id).Update()
return err
}
func (d *characterDao) Delete(ctx context.Context, id int64) error {
_, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Unscoped().Where("id", id).Delete()
return err
}
func (d *characterDao) DeleteByDrama(ctx context.Context, dramaId int64) error {
_, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete()
return err
}
func (d *characterDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.Character, err error) {
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All()
if err != nil {
return
}
res = make([]*entity.Character, 0)
err = r.Structs(&res)
return
}
+154
View File
@@ -0,0 +1,154 @@
package dao
import (
"context"
"video-factory/shortdrama/consts/public"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var Drama = &dramaDao{}
type dramaDao struct{}
// AlreadyMigrated 标记是否已执行过加列迁移
var alreadyMigrated bool
func (d *dramaDao) AlterTableAddColumn(ctx context.Context) {
if alreadyMigrated {
return
}
alreadyMigrated = true
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Where("1=0").All()
if err != nil {
return
}
// 尝试添加 episode_count 列,列已存在时忽略错误
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameDrama+" ADD COLUMN episode_count INTEGER NOT NULL DEFAULT 0")
}
func (d *dramaDao) IncrementEpCount(ctx context.Context, dramaId int64) error {
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).
Data(g.Map{"episode_count": gdb.Raw("episode_count + 1")}).
Where("id", dramaId).Update()
return err
}
func (d *dramaDao) BackfillEpCount(ctx context.Context) error {
_, err := g.DB().Exec(ctx, "UPDATE "+public.TableNameDrama+" SET episode_count = (SELECT COUNT(*) FROM "+public.TableNameEpisode+" WHERE "+public.TableNameEpisode+".drama_id = "+public.TableNameDrama+".id)")
return err
}
func (d *dramaDao) DecrementEpCount(ctx context.Context, dramaId int64) error {
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).
Where("id", dramaId).Where("episode_count > 0").
Data(g.Map{"episode_count": gdb.Raw("episode_count - 1")}).
Update()
return err
}
func (d *dramaDao) Insert(ctx context.Context, data *entity.Drama) (id int64, err error) {
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
delete(m, "created_at")
delete(m, "updated_at")
delete(m, "deleted_at")
r, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Data(m).Insert()
if err != nil {
return
}
return r.LastInsertId()
}
func (d *dramaDao) GetOne(ctx context.Context, id int64) (res *entity.Drama, err error) {
r, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Where("id", id).One()
if err != nil {
return
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
func (d *dramaDao) Update(ctx context.Context, id int64, data *entity.Drama) error {
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Data(data).Where("id", id).Update()
return err
}
func (d *dramaDao) Delete(ctx context.Context, id int64) error {
_, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).Unscoped().Where("id", id).Delete()
return err
}
func (d *dramaDao) List(ctx context.Context) (res []*entity.Drama, err error) {
r, err := g.DB().Model(public.TableNameDrama).Ctx(ctx).OrderDesc("id").All()
if err != nil {
return
}
res = make([]*entity.Drama, 0)
err = r.Structs(&res)
return
}
// GetCharacters 查询短剧的所有演员
func (d *dramaDao) GetCharacters(ctx context.Context, dramaId int64) (res []*entity.Character, err error) {
r, err := g.DB().Model(public.TableNameCharacter).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("id").All()
if err != nil {
return
}
res = make([]*entity.Character, 0)
err = r.Structs(&res)
return
}
// GetEpisodes 查询短剧的所有剧集
func (d *dramaDao) GetEpisodes(ctx context.Context, dramaId int64) (res []*entity.Episode, err error) {
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("idx").All()
if err != nil {
return
}
res = make([]*entity.Episode, 0)
err = r.Structs(&res)
return
}
// ToMap 将 entity 转为 g.Map 用于部分字段更新
func (d *dramaDao) ToMap(e *entity.Drama) g.Map {
m := make(g.Map)
if e.Title != "" {
m["title"] = e.Title
}
if e.Style != "" {
m["style"] = e.Style
}
if e.EpisodeDuration > 0 {
m["episode_duration"] = e.EpisodeDuration
}
return m
}
// StructToMap 通用 entity 转 map(非空字段)
func StructToMap(obj any) g.Map {
m := make(g.Map)
b := gconv.Map(obj)
for k, v := range b {
switch v.(type) {
case string:
if v.(string) != "" {
m[gconv.String(k)] = v
}
case int:
if v.(int) > 0 {
m[gconv.String(k)] = v
}
default:
m[gconv.String(k)] = v
}
}
return m
}
+73
View File
@@ -0,0 +1,73 @@
package dao
import (
"context"
"video-factory/shortdrama/consts/public"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var Episode = &episodeDao{}
type episodeDao struct{}
func (d *episodeDao) Insert(ctx context.Context, data *entity.Episode) (id int64, err error) {
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
delete(m, "created_at")
delete(m, "updated_at")
delete(m, "deleted_at")
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(m).Insert()
if err != nil {
return
}
return r.LastInsertId()
}
func (d *episodeDao) GetOne(ctx context.Context, id int64) (res *entity.Episode, err error) {
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("id", id).One()
if err != nil {
return
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
func (d *episodeDao) Update(ctx context.Context, id int64, data *entity.Episode) error {
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(data).Where("id", id).Update()
return err
}
func (d *episodeDao) UpdateStatus(ctx context.Context, id int64, status string, videoUrl string) error {
m := g.Map{"status": status}
if videoUrl != "" {
m["video_url"] = videoUrl
}
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Data(m).Where("id", id).Update()
return err
}
func (d *episodeDao) Delete(ctx context.Context, id int64) error {
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Unscoped().Where("id", id).Delete()
return err
}
func (d *episodeDao) DeleteByDrama(ctx context.Context, dramaId int64) error {
_, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Unscoped().Where("drama_id", dramaId).Delete()
return err
}
func (d *episodeDao) ListByDrama(ctx context.Context, dramaId int64) (res []*entity.Episode, err error) {
r, err := g.DB().Model(public.TableNameEpisode).Ctx(ctx).Where("drama_id", dramaId).OrderAsc("idx").All()
if err != nil {
return
}
res = make([]*entity.Episode, 0)
err = r.Structs(&res)
return
}
+145
View File
@@ -0,0 +1,145 @@
package dao
import (
"context"
"video-factory/shortdrama/consts/public"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var GenerationTask = &generationTaskDao{}
type generationTaskDao struct{}
func (d *generationTaskDao) Insert(ctx context.Context, data *entity.GenerationTask) (id int64, err error) {
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
delete(m, "created_at")
delete(m, "updated_at")
// generation_task has no deleted_at column
// delete(m, "deleted_at")
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(m).Insert()
if err != nil {
return
}
return r.LastInsertId()
}
func (d *generationTaskDao) GetOne(ctx context.Context, id int64) (res *entity.GenerationTask, err error) {
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Where("id", id).One()
if err != nil {
return
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
// GetByEpisode 查询某集的最新生成任务
func (d *generationTaskDao) GetByEpisode(ctx context.Context, episodeId int64) (res *entity.GenerationTask, err error) {
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).
Where("episode_id", episodeId).
OrderDesc("id").One()
if err != nil {
return
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
func (d *generationTaskDao) Update(ctx context.Context, id int64, data *entity.GenerationTask) error {
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(data).Where("id", id).Update()
return err
}
// UpdateProgress 更新生成进度
func (d *generationTaskDao) UpdateProgress(ctx context.Context, id int64, currentStep int, totalSteps int, stepsData, status string) error {
m := g.Map{
"current_step": currentStep,
"total_steps": totalSteps,
"steps_data": stepsData,
"status": status,
}
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(m).Where("id", id).Update()
return err
}
// UpdateReview 更新为预览等待状态(用户审核当前段)
func (d *generationTaskDao) UpdateReview(ctx context.Context, id int64, currentStep int, stepsData string) error {
m := g.Map{
"current_step": currentStep,
"steps_data": stepsData,
"status": "review",
}
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(m).Where("id", id).Update()
return err
}
// UpdateStepsData 更新 steps_data 字段(后台轮询器用于回填视频 URL)
func (d *generationTaskDao) UpdateStepsData(ctx context.Context, id int64, stepsData string) error {
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{
"steps_data": stepsData,
}).Where("id", id).Update()
return err
}
// DeleteByEpisode 删除某集关联的所有生成任务
func (d *generationTaskDao) DeleteByEpisode(ctx context.Context, episodeId int64) error {
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Where("episode_id", episodeId).Delete()
return err
}
// DeleteByDrama 删除某短剧关联的所有生成任务
func (d *generationTaskDao) DeleteByDrama(ctx context.Context, dramaId int64) error {
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Where("drama_id", dramaId).Delete()
return err
}
// UpdateFailed 更新为失败状态
func (d *generationTaskDao) UpdateFailed(ctx context.Context, id int64, errMsg string) error {
_, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{
"status": "failed",
"error_message": errMsg,
}).Where("id", id).Update()
return err
}
// ListByEpisodeIds 查询多集的最新生成任务(按 episode_id 分组取最新)
func (d *generationTaskDao) ListByEpisodeIds(ctx context.Context, episodeIds []int64) (res []*entity.GenerationTask, err error) {
if len(episodeIds) == 0 {
return []*entity.GenerationTask{}, nil
}
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).
Where("episode_id in (?)", episodeIds).
OrderDesc("id").All()
if err != nil {
return
}
if r == nil {
return []*entity.GenerationTask{}, nil
}
err = r.Structs(&res)
return
}
// ListByStatus — 按状态查询所有生成任务
func (d *generationTaskDao) ListByStatus(ctx context.Context, status string) (res []*entity.GenerationTask, err error) {
r, err := g.DB().Model(public.TableNameGenerationTask).Ctx(ctx).
Where("status", status).
OrderDesc("id").All()
if err != nil {
return
}
if r == nil {
return []*entity.GenerationTask{}, nil
}
err = r.Structs(&res)
return
}
+115
View File
@@ -0,0 +1,115 @@
package dao
import (
"context"
"video-factory/shortdrama/consts/public"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelConfig = &modelConfigDao{}
type modelConfigDao struct{}
// CreateTable 创建模型配置表(首次运行时自动调用)
func (d *modelConfigDao) CreateTable(ctx context.Context) error {
sql := `CREATE TABLE IF NOT EXISTS ` + public.TableNameModelConfig + ` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_api_key TEXT NOT NULL DEFAULT '',
video_api_key TEXT NOT NULL DEFAULT '',
chat_base_url TEXT NOT NULL DEFAULT '',
chat_model_name TEXT NOT NULL DEFAULT '',
max_tokens INTEGER NOT NULL DEFAULT 0,
temperature REAL NOT NULL DEFAULT 0,
video_base_url TEXT NOT NULL DEFAULT '',
video_model_name TEXT NOT NULL DEFAULT '',
video_query_url TEXT NOT NULL DEFAULT '',
max_single_duration INTEGER NOT NULL DEFAULT 0,
min_single_duration INTEGER NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
)`
_, err := g.DB().Exec(ctx, sql)
return err
}
// AlterTableMigrate 迁移旧表:rename api_key → chat_api_keyadd video_api_key
var modelConfigMigrated bool
func (d *modelConfigDao) AlterTableMigrate(ctx context.Context) {
if modelConfigMigrated {
return
}
modelConfigMigrated = true
// 检查表是否存在
_, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Where("1=0").All()
if err != nil {
return
}
// 查询当前表结构
cols, _ := g.DB().GetAll(ctx, "PRAGMA table_info("+public.TableNameModelConfig+")")
if cols == nil {
return
}
hasOldColumn := false
hasVideoKey := false
for _, col := range cols {
name := col["name"].String()
if name == "api_key" {
hasOldColumn = true
}
if name == "video_api_key" {
hasVideoKey = true
}
}
if hasOldColumn {
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" RENAME COLUMN api_key TO chat_api_key")
}
if !hasVideoKey {
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN video_api_key TEXT NOT NULL DEFAULT ''")
}
hasQueryUrl := false
for _, col := range cols {
if col["name"].String() == "video_query_url" {
hasQueryUrl = true
break
}
}
if !hasQueryUrl {
_, _ = g.DB().Exec(ctx, "ALTER TABLE "+public.TableNameModelConfig+" ADD COLUMN video_query_url TEXT NOT NULL DEFAULT ''")
}
}
// GetFirst 获取第一条配置行
func (d *modelConfigDao) GetFirst(ctx context.Context) (res *entity.ModelConfig, err error) {
r, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).OrderAsc("id").Limit(1).One()
if err != nil {
return nil, err
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
// Save 保存配置:存在则更新,不存在则插入
func (d *modelConfigDao) Save(ctx context.Context, data *entity.ModelConfig) error {
existing, err := d.GetFirst(ctx)
if err != nil {
return err
}
if existing != nil {
data.Id = existing.Id
_, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(data).Where("id", existing.Id).Update()
return err
}
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
delete(m, "created_at")
delete(m, "updated_at")
_, err = g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(m).Insert()
return err
}
+65
View File
@@ -0,0 +1,65 @@
package dao
import (
"context"
"video-factory/shortdrama/consts/public"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var Prompt = &promptDao{}
type promptDao struct{}
// CreateTable 创建提示词表(首次运行时自动调用)
func (d *promptDao) CreateTable(ctx context.Context) error {
sql := `CREATE TABLE IF NOT EXISTS ` + public.TableNamePrompt + ` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
created_at DATETIME,
updated_at DATETIME
)`
_, err := g.DB().Exec(ctx, sql)
return err
}
// List 获取所有提示词列表
func (d *promptDao) List(ctx context.Context) (res []*entity.Prompt, err error) {
r, err := g.DB().Model(public.TableNamePrompt).Ctx(ctx).OrderAsc("id").All()
if err != nil {
return nil, err
}
err = r.Structs(&res)
return
}
// GetByName 根据名称获取提示词
func (d *promptDao) GetByName(ctx context.Context, name string) (res *entity.Prompt, err error) {
r, err := g.DB().Model(public.TableNamePrompt).Ctx(ctx).Where("name", name).Limit(1).One()
if err != nil {
return nil, err
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
// Save 保存提示词:存在则更新,不存在则插入
func (d *promptDao) Save(ctx context.Context, data *entity.Prompt) error {
existing, err := d.GetByName(ctx, data.Name)
if err != nil {
return err
}
if existing != nil {
data.Id = existing.Id
_, err = g.DB().Model(public.TableNamePrompt).Ctx(ctx).Data(data).Where("id", existing.Id).Update()
return err
}
_, err = g.DB().Model(public.TableNamePrompt).Ctx(ctx).Data(data).Insert()
return err
}
+137
View File
@@ -0,0 +1,137 @@
package model
import (
"encoding/json"
"strings"
)
// AgentOutput Agent 最终输出的 JSON 结构
type AgentOutput struct {
Title string `json:"title"`
TotalEpisodes int `json:"total_episodes"`
TotalDuration int `json:"total_duration"`
Episodes []AgentEpisode `json:"episodes"`
Characters []AgentCharacter `json:"characters"`
}
// AgentEpisode 剧集部分
type AgentEpisode struct {
Index int `json:"index"`
Title string `json:"title"`
Duration int `json:"duration"`
Scenes []AgentScene `json:"scenes"`
}
// AgentScene 场景部分
type AgentScene struct {
Index int `json:"index"`
Description string `json:"description"`
Lines string `json:"lines"`
Duration int `json:"duration"`
Characters []string `json:"characters"`
VisualDesc string `json:"visualDesc"`
ImageUrl string `json:"imageUrl"`
}
// AgentCharacter 演员部分
type AgentCharacter struct {
Name string `json:"name"`
Description string `json:"description"`
ImageUrl string `json:"imageUrl"`
}
// extractJSON 从可能含 markdown 代码块标记或额外说明文字的文本中提取 JSON 字符串
func extractJSON(s string) string {
// 尝试直接解析
if json.Valid([]byte(s)) {
return s
}
// 尝试从 ```json ... ``` 代码块中提取
const jsonPrefix = "```json"
const codeFence = "```"
if idx := strings.Index(s, jsonPrefix); idx >= 0 {
start := idx + len(jsonPrefix)
if end := strings.Index(s[start:], codeFence); end >= 0 {
trimmed := strings.TrimSpace(s[start : start+end])
if json.Valid([]byte(trimmed)) {
return trimmed
}
}
}
// 尝试从 ``` 代码块中提取
if idx := strings.Index(s, codeFence); idx >= 0 {
start := idx + len(codeFence)
if end := strings.Index(s[start:], codeFence); end >= 0 {
trimmed := strings.TrimSpace(s[start : start+end])
if json.Valid([]byte(trimmed)) {
return trimmed
}
}
}
// 最后手段:查找第一个 { 和最后一个 } 截取 JSON
if start := strings.Index(s, "{"); start >= 0 {
if end := strings.LastIndex(s, "}"); end > start {
candidate := s[start : end+1]
if json.Valid([]byte(candidate)) {
return candidate
}
}
}
return s
}
// ParseAgentOutput 解析 Agent 输出的 JSON 字符串为 SegmentOutput
// 如果解析失败,返回包含原始文本的 SegmentOutput
func ParseAgentOutput(jsonStr string, segIdx int) *SegmentOutput {
out := &SegmentOutput{
Index: segIdx,
Confirmed: false,
TextOutput: jsonStr,
}
extracted := extractJSON(jsonStr)
var agentOut AgentOutput
if err := json.Unmarshal([]byte(extracted), &agentOut); err != nil {
return out
}
// 提取演员
for _, c := range agentOut.Characters {
out.Characters = append(out.Characters, SegmentCharacter{
Name: c.Name,
Description: c.Description,
ImageBase64: stripImagePrefix(c.ImageUrl),
})
}
// 提取场景(取第一集,因为分段生成只涉及一集)
if len(agentOut.Episodes) > 0 {
for _, s := range agentOut.Episodes[0].Scenes {
out.Scenes = append(out.Scenes, SegmentScene{
Index: s.Index,
Description: s.Description,
Lines: s.Lines,
Duration: s.Duration,
Characters: s.Characters,
ImageBase64: stripImagePrefix(s.ImageUrl),
})
}
}
return out
}
// stripImagePrefix 去掉 "data:image/png;base64," 等前缀
func stripImagePrefix(s string) string {
for _, prefix := range []string{
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/webp;base64,",
"data:image/",
} {
if len(s) > len(prefix) && s[:len(prefix)] == prefix {
return s[len(prefix):]
}
}
return s
}
+30
View File
@@ -0,0 +1,30 @@
package dto
import (
"github.com/gogf/gf/v2/frame/g"
)
type AddCharacterReq struct {
g.Meta `path:"/character/add" method:"post" tags:"短剧管理" summary:"添加演员"`
DramaId int64 `json:"dramaId"`
Name string `json:"name"`
Description string `json:"description"`
VoiceType string `json:"voiceType"`
PortraitUrl string `json:"portraitUrl"`
}
type UpdateCharacterReq struct {
g.Meta `path:"/character/update" method:"post" tags:"短剧管理" summary:"更新演员"`
DramaId int64 `json:"dramaId"`
CharId int64 `json:"charId"`
Name string `json:"name"`
Description string `json:"description"`
VoiceType string `json:"voiceType"`
PortraitUrl string `json:"portraitUrl"`
}
type DeleteCharacterReq struct {
g.Meta `path:"/character/delete" method:"post" tags:"短剧管理" summary:"删除演员"`
DramaId int64 `json:"dramaId"`
CharId int64 `json:"charId"`
}
+20
View File
@@ -0,0 +1,20 @@
package dto
import (
"video-factory/shortdrama/model"
"github.com/gogf/gf/v2/frame/g"
)
type GetModelConfigReq struct {
g.Meta `path:"/model" method:"get" tags:"模型配置" summary:"获取模型配置"`
}
type GetModelConfigRes struct {
*model.ModelConfig
}
type SaveModelConfigReq struct {
g.Meta `path:"/model" method:"post" tags:"模型配置" summary:"保存模型配置"`
model.ModelConfig
}
+62
View File
@@ -0,0 +1,62 @@
package dto
import (
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
// ==================== List ====================
type ListDramaReq struct {
g.Meta `path:"/list" method:"get" tags:"短剧管理" summary:"短剧列表"`
}
type ListDramaRes struct {
List []*entity.Drama `json:"list"`
}
// ==================== Create ====================
type CreateDramaReq struct {
g.Meta `path:"/create" method:"post" tags:"短剧管理" summary:"创建短剧"`
Title string `json:"title"`
Style string `json:"style"`
EpisodeDuration int `json:"episodeDuration"`
}
type CreateDramaRes struct {
Id int64 `json:"id"`
}
// ==================== Get ====================
type GetDramaReq struct {
g.Meta `path:"/get" method:"get" tags:"短剧管理" summary:"获取短剧详情"`
Id int64 `json:"id"`
}
// GetDramaRes 包含短剧及其关联的演员、剧集和生成任务
type GetDramaRes struct {
*entity.Drama
Characters []*entity.Character `json:"characters"`
Episodes []*entity.Episode `json:"episodes"`
Tasks []*entity.GenerationTask `json:"tasks"`
}
// ==================== Update ====================
type UpdateDramaReq struct {
g.Meta `path:"/update" method:"post" tags:"短剧管理" summary:"更新短剧"`
Id int64 `json:"id"`
Title string `json:"title"`
Style string `json:"style"`
EpisodeDuration int `json:"episodeDuration"`
}
// ==================== Delete ====================
type DeleteDramaReq struct {
g.Meta `path:"/delete" method:"post" tags:"短剧管理" summary:"删除短剧"`
Id int64 `json:"id"`
}
+34
View File
@@ -0,0 +1,34 @@
package dto
import (
"github.com/gogf/gf/v2/frame/g"
)
type AddEpisodeReq struct {
g.Meta `path:"/episode/add" method:"post" tags:"短剧管理" summary:"添加剧集"`
DramaId int64 `json:"dramaId"`
Title string `json:"title"`
Script string `json:"script"`
Index int `json:"index"`
}
type UpdateEpisodeReq struct {
g.Meta `path:"/episode/update" method:"post" tags:"短剧管理" summary:"更新剧集"`
DramaId int64 `json:"dramaId"`
EpId int64 `json:"epId"`
Title string `json:"title"`
Script string `json:"script"`
Index int `json:"index"`
}
type DeleteEpisodeReq struct {
g.Meta `path:"/episode/delete" method:"post" tags:"短剧管理" summary:"删除剧集"`
DramaId int64 `json:"dramaId"`
EpId int64 `json:"epId"`
}
type GenerateEpisodeReq struct {
g.Meta `path:"/episode/generate" method:"post" tags:"短剧管理" summary:"单集生成视频"`
DramaId int64 `json:"dramaId"`
EpId int64 `json:"epId"`
}
+29
View File
@@ -0,0 +1,29 @@
package dto
import (
"video-factory/shortdrama/model"
"github.com/gogf/gf/v2/frame/g"
)
type ListPromptReq struct {
g.Meta `path:"/list" method:"get" tags:"提示词" summary:"获取提示词列表"`
}
type ListPromptRes struct {
List []*model.Prompt `json:"list"`
}
type GetPromptReq struct {
g.Meta `path:"/get" method:"get" tags:"提示词" summary:"获取单个提示词"`
Name string `json:"name"`
}
type GetPromptRes struct {
*model.Prompt
}
type SavePromptReq struct {
g.Meta `path:"/save" method:"post" tags:"提示词" summary:"保存提示词"`
model.Prompt
}
+31
View File
@@ -0,0 +1,31 @@
package dto
import (
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
// ContinueSegmentReq 继续下一段
type ContinueSegmentReq struct {
g.Meta `path:"/segment/continue" method:"post" tags:"短剧生成" summary:"确认当前段并继续下一段"`
TaskId int64 `json:"taskId"`
}
// FeedbackSegmentReq 反馈重新生成
type FeedbackSegmentReq struct {
g.Meta `path:"/segment/feedback" method:"post" tags:"短剧生成" summary:"为当前段提供反馈并重新生成"`
TaskId int64 `json:"taskId"`
Feedback string `json:"feedback"`
}
// GetEpisodeTaskReq 获取剧集当前任务
type GetEpisodeTaskReq struct {
g.Meta `path:"/episode/task" method:"get" tags:"短剧生成" summary:"获取某集当前生成任务"`
EpId int64 `json:"epId"`
}
// GetEpisodeTaskRes 获取某集当前任务的响应
type GetEpisodeTaskRes struct {
Task *entity.GenerationTask `json:"task"`
}
+15
View File
@@ -0,0 +1,15 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type Character struct {
Id int64 `orm:"id" json:"id"`
DramaId int64 `orm:"drama_id" json:"dramaId"`
Name string `orm:"name" json:"name"`
Description string `orm:"description" json:"description"`
VoiceType string `orm:"voice_type" json:"voiceType"`
PortraitUrl string `orm:"portrait_url" json:"portraitUrl"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt"`
}
+14
View File
@@ -0,0 +1,14 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type Drama struct {
Id int64 `orm:"id" json:"id"`
Title string `orm:"title" json:"title"`
Style string `orm:"style" json:"style"`
EpisodeDuration int64 `orm:"episode_duration" json:"episodeDuration"`
EpCount int `orm:"episode_count" json:"epCount"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt"`
}
+16
View File
@@ -0,0 +1,16 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type Episode struct {
Id int64 `orm:"id" json:"id"`
DramaId int64 `orm:"drama_id" json:"dramaId"`
Index int `orm:"idx" json:"index"`
Title string `orm:"title" json:"title"`
Script string `orm:"script" json:"script"`
Status string `orm:"status" json:"status"`
VideoUrl string `orm:"video_url" json:"videoUrl"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
DeletedAt *gtime.Time `orm:"deleted_at" json:"deletedAt"`
}
@@ -0,0 +1,16 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type GenerationTask struct {
Id int64 `orm:"id" json:"id"`
DramaId int64 `orm:"drama_id" json:"dramaId"`
EpisodeId int64 `orm:"episode_id" json:"episodeId"`
Status string `orm:"status" json:"status"`
CurrentStep int `orm:"current_step" json:"currentStep"`
TotalSteps int `orm:"total_steps" json:"totalSteps"`
StepsData string `orm:"steps_data" json:"stepsData"`
ErrorMessage string `orm:"error_message" json:"errorMessage"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
}
+22
View File
@@ -0,0 +1,22 @@
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
type ModelConfig struct {
Id int64 `orm:"id" json:"id"`
ChatApiKey string `orm:"chat_api_key" json:"chatApiKey"`
VideoApiKey string `orm:"video_api_key" json:"videoApiKey"`
ChatBaseUrl string `orm:"chat_base_url" json:"chatBaseUrl"`
ChatModelName string `orm:"chat_model_name" json:"chatModelName"`
MaxTokens int `orm:"max_tokens" json:"maxTokens"`
Temperature float64 `orm:"temperature" json:"temperature"`
VideoBaseUrl string `orm:"video_base_url" json:"videoBaseUrl"`
VideoModelName string `orm:"video_model_name" json:"videoModelName"`
VideoQueryUrl string `orm:"video_query_url" json:"videoQueryUrl"`
MaxSingleDuration int `orm:"max_single_duration" json:"maxSingleDuration"`
MinSingleDuration int `orm:"min_single_duration" json:"minSingleDuration"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
}
+14
View File
@@ -0,0 +1,14 @@
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
type Prompt struct {
Id int64 `orm:"id" json:"id"`
Name string `orm:"name" json:"name"`
Content string `orm:"content" json:"content"`
Remark string `orm:"remark" json:"remark"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt"`
}
+16
View File
@@ -0,0 +1,16 @@
package model
// ModelConfig 模型配置(config.yml 映射)
type ModelConfig struct {
ChatApiKey string `json:"chatApiKey"`
VideoApiKey string `json:"videoApiKey"`
ChatBaseURL string `json:"chatBaseUrl"`
ChatModelName string `json:"chatModelName"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
VideoBaseURL string `json:"videoBaseUrl"`
VideoModelName string `json:"videoModelName"`
VideoQueryURL string `json:"videoQueryUrl"`
MaxSingleDuration int `json:"maxSingleDuration"`
MinSingleDuration int `json:"minSingleDuration"`
}
+7
View File
@@ -0,0 +1,7 @@
package model
type Prompt struct {
Name string `json:"name"`
Content string `json:"content"`
Remark string `json:"remark"`
}
+38
View File
@@ -0,0 +1,38 @@
package model
// SegmentOutput 单段生成输出(用于预览和 JSON 序列化)
type SegmentOutput struct {
Index int `json:"index"`
Confirmed bool `json:"confirmed"`
TextOutput string `json:"textOutput"`
Scenes []SegmentScene `json:"scenes"`
Characters []SegmentCharacter `json:"characters"`
Feedback string `json:"feedback,omitempty"`
VideoURL string `json:"videoUrl"`
VideoTaskId string `json:"videoTaskId,omitempty"`
}
// StepsData StepsData 字段的完整 JSON 结构
type StepsData struct {
Segments []SegmentOutput `json:"segments"`
CurrentSegment int `json:"currentSegment"`
LastFrame string `json:"lastFrame"`
VideoTaskId string `json:"videoTaskId,omitempty"`
}
// SegmentScene 单段中的一个场景
type SegmentScene struct {
Index int `json:"index"`
Description string `json:"description"`
Lines string `json:"lines"`
Duration int `json:"duration"`
Characters []string `json:"characters"`
ImageBase64 string `json:"imageBase64"`
}
// SegmentCharacter 单段中的一个演员
type SegmentCharacter struct {
Name string `json:"name"`
Description string `json:"description"`
ImageBase64 string `json:"imageBase64"`
}
+104
View File
@@ -0,0 +1,104 @@
package service
import (
"context"
"video-factory/shortdrama/dao"
"video-factory/shortdrama/model"
"video-factory/shortdrama/model/entity"
)
// ConfigService 模型配置服务
type configService struct{}
var ConfigService = new(configService)
// Get 从数据库获取模型配置
func (s *configService) Get(ctx context.Context) *model.ModelConfig {
_ = dao.ModelConfig.CreateTable(ctx)
dao.ModelConfig.AlterTableMigrate(ctx)
cfg, err := dao.ModelConfig.GetFirst(ctx)
if err == nil && cfg != nil {
return s.entityToModel(cfg)
}
return &model.ModelConfig{}
}
// Save 保存模型配置到数据库
func (s *configService) Save(ctx context.Context, cfg *model.ModelConfig) error {
_ = dao.ModelConfig.CreateTable(ctx)
dao.ModelConfig.AlterTableMigrate(ctx)
existing, _ := dao.ModelConfig.GetFirst(ctx)
if existing != nil {
if cfg.ChatApiKey != "" {
existing.ChatApiKey = cfg.ChatApiKey
}
if cfg.VideoApiKey != "" {
existing.VideoApiKey = cfg.VideoApiKey
}
if cfg.ChatBaseURL != "" {
existing.ChatBaseUrl = cfg.ChatBaseURL
}
if cfg.ChatModelName != "" {
existing.ChatModelName = cfg.ChatModelName
}
if cfg.VideoBaseURL != "" {
existing.VideoBaseUrl = cfg.VideoBaseURL
}
if cfg.VideoModelName != "" {
existing.VideoModelName = cfg.VideoModelName
}
if cfg.VideoQueryURL != "" {
existing.VideoQueryUrl = cfg.VideoQueryURL
}
if cfg.MaxTokens > 0 {
existing.MaxTokens = cfg.MaxTokens
}
if cfg.Temperature > 0 {
existing.Temperature = cfg.Temperature
}
if cfg.MaxSingleDuration > 0 {
existing.MaxSingleDuration = cfg.MaxSingleDuration
}
if cfg.MinSingleDuration > 0 {
existing.MinSingleDuration = cfg.MinSingleDuration
}
return dao.ModelConfig.Save(ctx, existing)
}
return dao.ModelConfig.Save(ctx, s.modelToEntity(cfg))
}
func (s *configService) entityToModel(e *entity.ModelConfig) *model.ModelConfig {
return &model.ModelConfig{
ChatApiKey: e.ChatApiKey,
VideoApiKey: e.VideoApiKey,
ChatBaseURL: e.ChatBaseUrl,
ChatModelName: e.ChatModelName,
MaxTokens: e.MaxTokens,
Temperature: e.Temperature,
VideoBaseURL: e.VideoBaseUrl,
VideoModelName: e.VideoModelName,
VideoQueryURL: e.VideoQueryUrl,
MaxSingleDuration: e.MaxSingleDuration,
MinSingleDuration: e.MinSingleDuration,
}
}
func (s *configService) modelToEntity(cfg *model.ModelConfig) *entity.ModelConfig {
return &entity.ModelConfig{
ChatApiKey: cfg.ChatApiKey,
VideoApiKey: cfg.VideoApiKey,
ChatBaseUrl: cfg.ChatBaseURL,
ChatModelName: cfg.ChatModelName,
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
VideoBaseUrl: cfg.VideoBaseURL,
VideoModelName: cfg.VideoModelName,
VideoQueryUrl: cfg.VideoQueryURL,
MaxSingleDuration: cfg.MaxSingleDuration,
MinSingleDuration: cfg.MinSingleDuration,
}
}
+891
View File
@@ -0,0 +1,891 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"video-factory/shortdrama/agent"
"video-factory/shortdrama/dao"
"video-factory/shortdrama/model"
"video-factory/shortdrama/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
// Migrate 数据库迁移:加列、回填现有数据
func (s *dramaService) Migrate(ctx context.Context) {
dao.Drama.AlterTableAddColumn(ctx)
_ = dao.Drama.BackfillEpCount(ctx)
}
// ==================== Drama CRUD ====================
type dramaService struct{}
var DramaService = new(dramaService)
func (s *dramaService) Create(ctx context.Context, title, style string, episodeDuration int64) (int64, error) {
return dao.Drama.Insert(ctx, &entity.Drama{
Title: title,
Style: style,
EpisodeDuration: episodeDuration,
})
}
func (s *dramaService) List(ctx context.Context) ([]*entity.Drama, error) {
return dao.Drama.List(ctx)
}
func (s *dramaService) Get(ctx context.Context, id int64) (*entity.Drama, []*entity.Character, []*entity.Episode, []*entity.GenerationTask, error) {
d, err := dao.Drama.GetOne(ctx, id)
if err != nil {
return nil, nil, nil, nil, err
}
if d == nil {
return nil, nil, nil, nil, fmt.Errorf("短剧不存在: %d", id)
}
characters, err := dao.Character.ListByDrama(ctx, id)
if err != nil {
return nil, nil, nil, nil, err
}
episodes, err := dao.Episode.ListByDrama(ctx, id)
if err != nil {
return nil, nil, nil, nil, err
}
var episodeIds []int64
for _, ep := range episodes {
episodeIds = append(episodeIds, ep.Id)
}
var tasks []*entity.GenerationTask
if len(episodeIds) > 0 {
tasks, _ = dao.GenerationTask.ListByEpisodeIds(ctx, episodeIds)
}
return d, characters, episodes, tasks, nil
}
func (s *dramaService) Update(ctx context.Context, id int64, title, style string, episodeDuration int64) error {
d, err := dao.Drama.GetOne(ctx, id)
if err != nil {
return err
}
if d == nil {
return fmt.Errorf("短剧不存在: %d", id)
}
if title != "" {
d.Title = title
}
if style != "" {
d.Style = style
}
if episodeDuration > 0 {
d.EpisodeDuration = episodeDuration
}
return dao.Drama.Update(ctx, id, d)
}
func (s *dramaService) Delete(ctx context.Context, id int64) error {
_ = dao.Character.DeleteByDrama(ctx, id)
_ = dao.Episode.DeleteByDrama(ctx, id)
_ = dao.GenerationTask.DeleteByDrama(ctx, id)
return dao.Drama.Delete(ctx, id)
}
// ==================== Character CRUD ====================
func (s *dramaService) AddCharacter(ctx context.Context, dramaId int64, name, description, voiceType, portraitUrl string) (int64, error) {
return dao.Character.Insert(ctx, &entity.Character{
DramaId: dramaId,
Name: name,
Description: description,
VoiceType: voiceType,
PortraitUrl: portraitUrl,
})
}
func (s *dramaService) UpdateCharacter(ctx context.Context, dramaId, charId int64, name, description, voiceType, portraitUrl string) error {
c, err := dao.Character.GetOne(ctx, charId)
if err != nil {
return err
}
if c == nil {
return fmt.Errorf("演员不存在: %d", charId)
}
if name != "" {
c.Name = name
}
if description != "" {
c.Description = description
}
if voiceType != "" {
c.VoiceType = voiceType
}
if portraitUrl != "" {
c.PortraitUrl = portraitUrl
}
return dao.Character.Update(ctx, charId, c)
}
func (s *dramaService) DeleteCharacter(ctx context.Context, dramaId, charId int64) error {
return dao.Character.Delete(ctx, charId)
}
// ==================== Episode CRUD ====================
func (s *dramaService) AddEpisode(ctx context.Context, dramaId int64, title, script string, index int) (int64, error) {
id, err := dao.Episode.Insert(ctx, &entity.Episode{
DramaId: dramaId,
Index: index,
Title: title,
Script: script,
Status: "pending",
})
if err == nil {
_ = dao.Drama.IncrementEpCount(ctx, dramaId)
}
return id, err
}
func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, title, script string, index int) error {
e, err := dao.Episode.GetOne(ctx, epId)
if err != nil {
return err
}
if e == nil {
return fmt.Errorf("剧集不存在: %d", epId)
}
if title != "" {
e.Title = title
}
if script != "" {
e.Script = script
}
if index > 0 {
e.Index = index
}
return dao.Episode.Update(ctx, epId, e)
}
func (s *dramaService) DeleteEpisode(ctx context.Context, dramaId, epId int64) error {
_ = dao.GenerationTask.DeleteByEpisode(ctx, epId)
err := dao.Episode.Delete(ctx, epId)
if err == nil {
_ = dao.Drama.DecrementEpCount(ctx, dramaId)
}
return err
}
// ==================== Generation ====================
// GenerateEpisode 生成某集视频
func (s *dramaService) GenerateEpisode(ctx context.Context, dramaId, epId int64) error {
d, err := dao.Drama.GetOne(ctx, dramaId)
if err != nil {
return err
}
if d == nil {
return fmt.Errorf("短剧不存在")
}
ep, err := dao.Episode.GetOne(ctx, epId)
if err != nil {
return err
}
if ep == nil {
return fmt.Errorf("剧集不存在")
}
modelCfg := ConfigService.Get(ctx)
if modelCfg.ChatApiKey == "" || modelCfg.ChatModelName == "" {
return fmt.Errorf("模型未配置")
}
ctx = agent.WithModelConfig(ctx, &agent.ModelConfig{
APIKey: modelCfg.ChatApiKey,
BaseURL: modelCfg.ChatBaseURL,
})
task, err := dao.GenerationTask.GetByEpisode(ctx, epId)
if err != nil {
return err
}
startStep := 0
prevLastFrame := ""
if task != nil && task.Status == "failed" && task.CurrentStep > 0 {
startStep = task.CurrentStep
var sd map[string]any
if err := json.Unmarshal([]byte(task.StepsData), &sd); err == nil {
if lastFrame, ok := sd["lastFrame"]; ok {
prevLastFrame = fmt.Sprintf("%v", lastFrame)
}
}
task.Status = "generating"
task.ErrorMessage = ""
_ = dao.GenerationTask.Update(ctx, task.Id, task)
} else {
task = &entity.GenerationTask{
DramaId: dramaId,
EpisodeId: epId,
Status: "generating",
CurrentStep: 0,
StepsData: "{}",
}
id, err := dao.GenerationTask.Insert(ctx, task)
if err != nil {
return err
}
task.Id = id
}
_ = dao.Episode.UpdateStatus(ctx, epId, "generating", "")
go func() {
genCtx := context.Background()
genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{
APIKey: modelCfg.ChatApiKey,
BaseURL: modelCfg.ChatBaseURL,
})
if err := s.generateOneSegment(genCtx, d, ep, task.Id, startStep, prevLastFrame, ""); err != nil {
g.Log().Errorf(genCtx, "第%d集第%d段生成失败: %v", ep.Index, startStep+1, err)
_ = dao.GenerationTask.UpdateFailed(ctx, task.Id, err.Error())
_ = dao.Episode.UpdateStatus(ctx, epId, "failed", err.Error())
}
}()
return nil
}
// generateOneSegment 生成一段内容,完成后进入 review 状态
func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64, segIdx int, prevLastFrame, feedback string) error {
modelCfg := ConfigService.Get(ctx)
duration := int(d.EpisodeDuration)
if duration <= 0 {
duration = 60
}
maxSingle := modelCfg.MaxSingleDuration
minSingle := modelCfg.MinSingleDuration
if maxSingle <= 0 {
maxSingle = 30
}
if minSingle <= 0 {
minSingle = maxSingle
}
segDurs := calcSegmentDurations(duration, maxSingle, minSingle)
numSegments := len(segDurs)
segDur := segDurs[segIdx]
prevSummary := ""
task, err := dao.GenerationTask.GetOne(ctx, taskId)
if err == nil && task != nil && task.StepsData != "" && task.StepsData != "{}" {
var sd model.StepsData
if err := json.Unmarshal([]byte(task.StepsData), &sd); err == nil && segIdx > 0 {
if segIdx-1 < len(sd.Segments) && sd.Segments[segIdx-1].TextOutput != "" {
prevSummary = sd.Segments[segIdx-1].TextOutput
if len(prevSummary) > 500 {
prevSummary = prevSummary[:500]
}
}
}
}
result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, prevLastFrame, modelCfg, feedback, prevSummary)
if err != nil {
return err
}
segOutput := model.ParseAgentOutput(result, segIdx)
task, err = dao.GenerationTask.GetOne(ctx, taskId)
if err != nil {
return err
}
var sd model.StepsData
if task.StepsData != "" && task.StepsData != "{}" {
_ = json.Unmarshal([]byte(task.StepsData), &sd)
}
if sd.Segments == nil {
sd.Segments = make([]model.SegmentOutput, numSegments)
}
sd.Segments[segIdx] = *segOutput
sd.CurrentSegment = segIdx
lastFrame := prevLastFrame
for _, s := range segOutput.Scenes {
if s.ImageBase64 != "" {
lastFrame = s.ImageBase64
}
}
sd.LastFrame = lastFrame
stepsDataBytes, _ := json.Marshal(sd)
// 提交视频合成任务(只提交,不阻塞等待轮询结果)
taskID, submitErr := s.submitVideoTask(ctx, d, ep, segIdx, &sd.Segments[segIdx])
if submitErr != nil {
g.Log().Warningf(ctx, "第%d段视频提交失败,后台将继续重试: %v", segIdx+1, submitErr)
} else {
sd.Segments[segIdx].VideoTaskId = taskID
}
stepsDataBytes, _ = json.Marshal(sd)
if segIdx >= numSegments-1 {
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, segIdx, numSegments, string(stepsDataBytes), "generating")
g.Log().Infof(ctx, "第%d集所有段落剧情生成完成,等待视频生成", ep.Index)
} else {
nextSeg := segIdx + 1
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, nextSeg, numSegments, string(stepsDataBytes), "generating")
g.Log().Infof(ctx, "第%d集第%d段剧情生成完成,等待视频生成", ep.Index, segIdx+1)
}
return nil
}
// generateSegment 调用 Agent 生成一段,返回 agent 输出文本
func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode,
segIdx, segDur int, prevLastFrame string, modelCfg *model.ModelConfig, feedback string, prevSummary string) (string, error) {
chatModel, err := agent.NewChatModel(ctx, &agent.ModelConfig{
ModelName: modelCfg.ChatModelName,
APIKey: modelCfg.ChatApiKey,
BaseURL: modelCfg.ChatBaseURL,
MaxTokens: modelCfg.MaxTokens,
Temperature: float32(modelCfg.Temperature),
})
if err != nil {
return "", err
}
tools := agent.GetTools()
systemPrompt := s.buildSegPrompt(ctx, d, ep, segIdx, segDur, prevLastFrame)
reactAgent := agent.NewReActAgent(ctx, chatModel, tools, systemPrompt, 30)
userInput := s.buildSegUserInput(d, ep, segIdx, feedback, prevSummary)
result, err := reactAgent.Run(ctx, userInput)
if err != nil {
return "", err
}
return result, nil
}
func (s *dramaService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, prevLastFrame string) string {
frameLink := ""
if prevLastFrame != "" && segIdx > 0 {
frameLink = "\n【帧链接】上一段尾帧已作为本段首帧,保持视觉延续。"
}
return fmt.Sprintf(`%s
【分段生成】
当前第%d集第%d段,本段%d秒。%s
所有场景时长之和必须等于%d秒。`,
PromptService.GetSystemPrompt(ctx),
ep.Index, segIdx+1, segDur,
frameLink, segDur,
)
}
func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, segIdx int, feedback string, prevSummary string) string {
feedbackText := ""
if feedback != "" {
feedbackText = fmt.Sprintf("\n【用户反馈】\n%s\n请根据以上反馈调整本段内容。", feedback)
}
prevText := ""
if prevSummary != "" {
prevText = fmt.Sprintf("\n【前文摘要】\n%s", prevSummary)
}
return fmt.Sprintf(`【短剧信息】
标题:%s
风格:%s
【当前剧集】
第%d集:%s
剧本:%s%s%s`,
d.Title, d.Style,
ep.Index, ep.Title, ep.Script,
prevText, feedbackText,
)
}
// ContinueSegment 确认当前段并继续下一段
func (s *dramaService) ContinueSegment(ctx context.Context, taskId int64) error {
task, err := dao.GenerationTask.GetOne(ctx, taskId)
if err != nil {
return err
}
if task == nil {
return fmt.Errorf("任务不存在: %d", taskId)
}
if task.Status != "review" {
return fmt.Errorf("任务状态不是 review,无法继续")
}
modelCfg := ConfigService.Get(ctx)
var sd model.StepsData
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
return fmt.Errorf("解析 steps_data 失败: %w", err)
}
if sd.CurrentSegment < len(sd.Segments) {
sd.Segments[sd.CurrentSegment].Confirmed = true
}
nextSeg := sd.CurrentSegment + 1
if nextSeg >= len(sd.Segments) {
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, nextSeg, task.TotalSteps, task.StepsData, "merging")
g.Log().Infof(ctx, "第%d集所有段落确认完成,开始合成视频", task.Id)
go s.mergeEpisodeVideo(ctx, task)
return nil
}
stepsDataBytes, _ := json.Marshal(sd)
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, nextSeg, task.TotalSteps, string(stepsDataBytes), "generating")
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "generating", "")
ep, err := dao.Episode.GetOne(ctx, task.EpisodeId)
if err != nil {
return err
}
if ep == nil {
return fmt.Errorf("剧集不存在")
}
d, err := dao.Drama.GetOne(ctx, task.DramaId)
if err != nil {
return err
}
if d == nil {
return fmt.Errorf("短剧不存在")
}
go func() {
genCtx := context.Background()
genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{
APIKey: modelCfg.ChatApiKey,
BaseURL: modelCfg.ChatBaseURL,
})
if err := s.generateOneSegment(genCtx, d, ep, taskId, nextSeg, sd.LastFrame, ""); err != nil {
g.Log().Errorf(genCtx, "第%d集第%d段生成失败: %v", ep.Index, nextSeg+1, err)
_ = dao.GenerationTask.UpdateFailed(ctx, taskId, err.Error())
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "failed", err.Error())
}
}()
return nil
}
// FeedbackSegment 反馈并重新生成本段
func (s *dramaService) FeedbackSegment(ctx context.Context, taskId int64, feedback string) error {
task, err := dao.GenerationTask.GetOne(ctx, taskId)
if err != nil {
return err
}
if task == nil {
return fmt.Errorf("任务不存在: %d", taskId)
}
if task.Status != "review" {
return fmt.Errorf("任务状态不是 review,无法反馈")
}
var sd model.StepsData
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
return fmt.Errorf("解析 steps_data 失败: %w", err)
}
if sd.CurrentSegment < len(sd.Segments) {
sd.Segments[sd.CurrentSegment].Feedback = feedback
}
stepsDataBytes, _ := json.Marshal(sd)
_ = dao.GenerationTask.UpdateProgress(ctx, taskId, sd.CurrentSegment, task.TotalSteps, string(stepsDataBytes), "generating")
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "generating", "")
d, err := dao.Drama.GetOne(ctx, task.DramaId)
if err != nil {
return err
}
if d == nil {
return fmt.Errorf("短剧不存在")
}
ep, err := dao.Episode.GetOne(ctx, task.EpisodeId)
if err != nil {
return err
}
if ep == nil {
return fmt.Errorf("剧集不存在")
}
modelCfg := ConfigService.Get(ctx)
go func() {
genCtx := context.Background()
genCtx = agent.WithModelConfig(genCtx, &agent.ModelConfig{
APIKey: modelCfg.ChatApiKey,
BaseURL: modelCfg.ChatBaseURL,
})
if err := s.generateOneSegment(genCtx, d, ep, taskId, sd.CurrentSegment, sd.LastFrame, feedback); err != nil {
g.Log().Errorf(genCtx, "第%d集第%d段重新生成失败: %v", ep.Index, sd.CurrentSegment+1, err)
_ = dao.GenerationTask.UpdateFailed(ctx, taskId, err.Error())
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "failed", err.Error())
}
}()
return nil
}
// GetEpisodeTask 获取某集当前的活跃任务
func (s *dramaService) GetEpisodeTask(ctx context.Context, epId int64) (*entity.GenerationTask, error) {
return dao.GenerationTask.GetByEpisode(ctx, epId)
}
// ==================== Video Generation ====================
// submitVideoTask 提交视频合成任务,返回 task_id(不轮询等待)
func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx int, output *model.SegmentOutput) (string, error) {
modelCfg := ConfigService.Get(ctx)
if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseURL == "" || modelCfg.VideoModelName == "" {
return "", fmt.Errorf("视频模型未配置")
}
scenes := output.Scenes
if len(scenes) == 0 {
return "", fmt.Errorf("没有场景数据")
}
sceneDescs := make([]string, 0, len(scenes))
for _, s := range scenes {
sceneDescs = append(sceneDescs, s.Description)
}
prompt := fmt.Sprintf("短剧《%s》第%d集第%d段:%s", d.Title, ep.Index, segIdx+1, strings.Join(sceneDescs, ""))
body := map[string]any{
"model": modelCfg.VideoModelName,
"input": map[string]any{
"prompt": prompt,
},
"parameters": map[string]any{
"duration": 5,
"size": "1280*720",
},
}
payload, _ := json.Marshal(body)
httpClient := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", modelCfg.VideoBaseURL, bytes.NewBuffer(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DashScope-Async", "enable")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result struct {
Output struct {
TaskId string `json:"task_id"`
} `json:"output"`
Code string `json:"code"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("解析视频合成响应失败: %s", string(data))
}
if result.Code != "" {
return "", fmt.Errorf("视频合成请求失败: %s", string(data))
}
taskId := result.Output.TaskId
if taskId == "" {
return "", fmt.Errorf("视频合成任务ID为空")
}
return taskId, nil
}
// pollVideoTaskOnce 单次查询视频任务状态(不循环等待)
func (s *dramaService) pollVideoTaskOnce(ctx context.Context, modelCfg *model.ModelConfig, taskId string) (string, error) {
queryURL := strings.ReplaceAll(modelCfg.VideoQueryURL, "{task_id}", taskId)
req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var result struct {
Output struct {
TaskStatus string `json:"task_status"`
VideoUrl string `json:"video_url"`
} `json:"output"`
Code string `json:"code"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("解析视频任务状态失败: %s", string(data))
}
if !g.IsEmpty(result.Output.VideoUrl) {
return result.Output.VideoUrl, nil
}
return "", nil
}
// StartVideoPoller 启动后台视频轮询器(每 15 秒扫描一次)
func (s *dramaService) StartVideoPoller(ctx context.Context) {
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
g.Log().Infof(ctx, "后台视频轮询器已启动,间隔 15s")
for {
select {
case <-ticker.C:
s.pollPendingVideos(context.Background())
case <-ctx.Done():
return
}
}
}()
}
// pollPendingVideos 扫描 generating 状态的任务,对未完成的视频任务进行单次轮询
func (s *dramaService) pollPendingVideos(ctx context.Context) {
modelCfg := ConfigService.Get(ctx)
if modelCfg.VideoApiKey == "" || modelCfg.VideoQueryURL == "" {
return
}
tasks, err := dao.GenerationTask.ListByStatus(ctx, "generating")
if err != nil || len(tasks) == 0 {
return
}
for _, task := range tasks {
var sd model.StepsData
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
g.Log().Warningf(ctx, "轮询器: 任务 %d StepsData 解析失败: %v", task.Id, err)
continue
}
updated := false
for i := range sd.Segments {
seg := &sd.Segments[i]
if seg.VideoTaskId == "" || seg.VideoURL != "" {
continue
}
videoURL, err := s.pollVideoTaskOnce(ctx, modelCfg, seg.VideoTaskId)
if err != nil {
g.Log().Debugf(ctx, "任务 %d 第%d段视频未就绪: %v", task.Id, i+1, err)
continue
}
seg.VideoURL = videoURL
seg.VideoTaskId = ""
g.Log().Infof(ctx, "任务 %d 第%d段视频生成完成: %s", task.Id, i+1, videoURL)
updated = true
}
if !updated {
continue
}
stepsDataBytes, _ := json.Marshal(sd)
_ = dao.GenerationTask.UpdateStepsData(ctx, task.Id, string(stepsDataBytes))
_ = dao.GenerationTask.UpdateReview(ctx, task.Id, sd.CurrentSegment, string(stepsDataBytes))
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "review", "")
}
}
// ==================== Helpers ====================
// calcSegmentDurations 将总时长按最大/最小段长拆分为多段
func calcSegmentDurations(totalDuration, maxSingle, minSingle int) []int {
if totalDuration <= maxSingle {
return []int{totalDuration}
}
numSegments := int(math.Ceil(float64(totalDuration) / float64(maxSingle)))
if numSegments <= 0 {
numSegments = 1
}
base := totalDuration / numSegments
remainder := totalDuration % numSegments
durations := make([]int, numSegments)
for i := 0; i < numSegments; i++ {
durations[i] = base
if i < remainder {
durations[i]++
}
}
for i := range durations {
if durations[i] < minSingle && i > 0 {
borrow := minSingle - durations[i]
if durations[i-1]-borrow >= minSingle {
durations[i-1] -= borrow
durations[i] += borrow
}
}
}
return durations
}
// mergeEpisodeVideo 将确认完成的各段视频下载并合并为最终视频
func (s *dramaService) mergeEpisodeVideo(ctx context.Context, task *entity.GenerationTask) {
var sd model.StepsData
if err := json.Unmarshal([]byte(task.StepsData), &sd); err != nil {
g.Log().Errorf(ctx, "解析 steps_data 失败: %v", err)
return
}
outputDir := filepath.Join("output", fmt.Sprintf("ep_%d", task.EpisodeId))
if err := os.MkdirAll(outputDir, 0755); err != nil {
g.Log().Errorf(ctx, "创建输出目录失败: %v", err)
return
}
segFiles := make([]string, 0, len(sd.Segments))
for i, seg := range sd.Segments {
if seg.VideoURL == "" {
continue
}
localPath := filepath.Join(outputDir, fmt.Sprintf("seg_%d.mp4", i))
if err := s.downloadFile(ctx, seg.VideoURL, localPath); err != nil {
g.Log().Errorf(ctx, "下载第%d段视频失败: %v", i, err)
return
}
segFiles = append(segFiles, localPath)
}
if len(segFiles) == 0 {
g.Log().Errorf(ctx, "没有可合并的视频文件")
return
}
// 取尾帧
lastSegFile := segFiles[len(segFiles)-1]
lastFramePath := filepath.Join(outputDir, "last_frame.jpg")
if err := s.extractLastFrame(lastSegFile, lastFramePath); err != nil {
g.Log().Warningf(ctx, "提取尾帧失败: %v", err)
}
if len(segFiles) == 1 {
finalPath := filepath.Join(outputDir, "final.mp4")
if err := s.copyFile(lastSegFile, finalPath); err != nil {
g.Log().Errorf(ctx, "复制视频文件失败: %v", err)
return
}
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "completed", finalPath)
_ = dao.GenerationTask.UpdateProgress(ctx, task.Id, len(sd.Segments), task.TotalSteps, task.StepsData, "completed")
g.Log().Infof(ctx, "单段视频已完成: %s", finalPath)
return
}
finalPath := filepath.Join(outputDir, "final.mp4")
if err := s.concatVideos(segFiles, finalPath); err != nil {
g.Log().Errorf(ctx, "合并视频失败: %v", err)
_ = dao.GenerationTask.UpdateFailed(ctx, task.Id, "视频合并失败: "+err.Error())
return
}
_ = dao.Episode.UpdateStatus(ctx, task.EpisodeId, "completed", finalPath)
_ = dao.GenerationTask.UpdateProgress(ctx, task.Id, len(sd.Segments), task.TotalSteps, task.StepsData, "completed")
g.Log().Infof(ctx, "视频合并完成: %s", finalPath)
}
// extractLastFrame 使用 ffmpeg 提取视频尾帧
func (s *dramaService) extractLastFrame(videoPath, outputPath string) error {
cmd := exec.Command("ffmpeg", "-y", "-sseof", "-1", "-i", videoPath,
"-frames:v", "1", "-q:v", "2", outputPath)
return cmd.Run()
}
// concatVideos 使用 ffmpeg 合并多个视频
func (s *dramaService) concatVideos(inputs []string, output string) error {
// 生成 concat 文件列表
listPath := output + ".list"
f, err := os.Create(listPath)
if err != nil {
return err
}
for _, p := range inputs {
_, _ = f.WriteString(fmt.Sprintf("file '%s'\n", p))
}
_ = f.Close()
defer os.Remove(listPath)
cmd := exec.Command("ffmpeg", "-y", "-f", "concat", "-safe", "0",
"-i", listPath, "-c", "copy", output)
return cmd.Run()
}
// downloadFile 下载文件到本地
func (s *dramaService) downloadFile(ctx context.Context, url, dest string) error {
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// copyFile 复制文件
func (s *dramaService) copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"video-factory/shortdrama/dao"
"video-factory/shortdrama/model"
"video-factory/shortdrama/model/entity"
)
type promptService struct{}
var PromptService = new(promptService)
const PromptNameSystem = "system_prompt"
// GetSystemPrompt 获取系统提示词
func (s *promptService) GetSystemPrompt(ctx context.Context) string {
_ = dao.Prompt.CreateTable(ctx)
p, err := dao.Prompt.GetByName(ctx, PromptNameSystem)
if err == nil && p != nil && p.Content != "" {
return p.Content
}
return ""
}
// GetList 获取所有提示词
func (s *promptService) GetList(ctx context.Context) []*model.Prompt {
_ = dao.Prompt.CreateTable(ctx)
list, err := dao.Prompt.List(ctx)
if err != nil || len(list) == 0 {
return nil
}
res := make([]*model.Prompt, 0, len(list))
for _, e := range list {
res = append(res, s.entityToModel(e))
}
return res
}
// GetByName 根据名称获取提示词
func (s *promptService) GetByName(ctx context.Context, name string) *model.Prompt {
_ = dao.Prompt.CreateTable(ctx)
p, err := dao.Prompt.GetByName(ctx, name)
if err != nil || p == nil {
return nil
}
return s.entityToModel(p)
}
// Save 保存提示词
func (s *promptService) Save(ctx context.Context, cfg *model.Prompt) error {
_ = dao.Prompt.CreateTable(ctx)
return dao.Prompt.Save(ctx, s.modelToEntity(cfg))
}
const initialSystemPrompt = `你是一位专业的短剧导演和编剧,负责将用户提交的剧本转化为结构化的短剧制作方案。
## 你的能力
你拥有以下工具可以使用:
1. **parse_script**:将原始剧本文本解析为结构化剧集列表
- 输入:原始剧本(多集内容用 --- 分隔)
- 输出:JSON格式的剧集列表(包含每集的索引、标题和剧本内容)
2. **analyze_script_for_episode**:分析单集剧本,拆分为场景
- 输入:单集剧本内容、本集时长、演员列表
- 输出:JSON格式的场景列表(包含每个场景的描述、台词、时长、出场演员、画面描述)
3. **generate_character_image**:根据演员描述生成人物形象图
- 输入:演员名称、演员详细描述、风格
- 输出:包含图片base64编码的JSON
4. **generate_scene_image**:根据场景画面描述生成场景图
- 输入:剧集索引、场景索引、画面描述、风格
- 输出:包含图片base64编码的JSON
## 工作流程
你必须按照以下步骤有序执行:
### 第一步:解析剧本
使用 parse_script 工具解析原始剧本,获取结构化剧集列表。
### 第二步:逐集分析
对于每一集剧本:
1. 分析剧本内容,理解故事情节
2. 使用 analyze_script_for_episode 将本集拆分为多个场景
3. 合理安排场景时长,确保总时长符合用户要求
4. 识别每个场景的出场演员
### 第三步:生成人物形象
为剧本中的每个主要演员,调用 generate_character_image 生成人物形象图。
### 第四步:生成场景画面
为每个场景,调用 generate_scene_image 生成对应的场景画面。
### 第五步:输出最终方案
综合所有信息,输出完整的短剧制作方案。
## 重要规则
1. 严格按照工作流程执行,不要跳过任何步骤
2. 每个工具调用后,仔细分析返回结果再决定下一步
3. 所有输出必须使用中文
4. 最终输出必须包含完整的剧集结构、场景划分、演员信息和图片数据
5. 合理安排每个场景的时长,确保总时长等于用户指定的每集时长
6. 如果用户没有指定风格,默认使用"现代都市"风格
## 输出格式要求
在完成所有步骤后,输出格式必须是一个JSON对象,包含以下字段:
- title: 短剧标题
- total_episodes: 总集数
- total_duration: 总时长(秒)
- episodes: 剧集列表,每集包含:
- index: 集号
- title: 本集标题
- duration: 本集时长
- scenes: 场景列表,每个场景包含:
- index: 场景序号
- description: 场景描述
- lines: 台词/对白
- duration: 场景时长(秒)
- characters: 出场演员名列表
- visualDesc: 画面描述
- imageUrl: 场景图片(base64编码)
- characters: 演员列表,每个演员包含:
- name: 演员名
- description: 演员描述
- imageUrl: 演员形象图片(base64编码)
`
// Init 初始化提示词表并插入初始数据
func (s *promptService) Init(ctx context.Context) error {
_ = dao.Prompt.CreateTable(ctx)
existing, err := dao.Prompt.GetByName(ctx, PromptNameSystem)
if err != nil {
return err
}
if existing != nil {
return nil
}
return dao.Prompt.Save(ctx, &entity.Prompt{
Name: PromptNameSystem,
Content: initialSystemPrompt,
Remark: "短剧生成系统的 ReAct Agent 系统提示词",
})
}
func (s *promptService) entityToModel(e *entity.Prompt) *model.Prompt {
return &model.Prompt{
Name: e.Name,
Content: e.Content,
Remark: e.Remark,
}
}
func (s *promptService) modelToEntity(cfg *model.Prompt) *entity.Prompt {
return &entity.Prompt{
Name: cfg.Name,
Content: cfg.Content,
Remark: cfg.Remark,
}
}
+849
View File
@@ -0,0 +1,849 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>短剧管理</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:#f0f2f5; color:#333; padding:20px; }
.container { max-width:1000px; margin:0 auto; }
.header { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; }
.header h1 { font-size:24px; color:#1a1a2e; }
.header-actions { display:flex; gap:10px; }
.btn { display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border-radius:8px; font-size:14px; font-weight:600; cursor:pointer; text-decoration:none; border:none; transition:all .2s; }
.btn-primary { background:#4a6cf7; color:#fff; }
.btn-primary:hover { background:#3a5ce5; }
.btn-success { background:#e8f5e9; color:#2e7d32; }
.btn-success:hover { background:#c8e6c9; }
.btn-danger { background:#fee; color:#e44; }
.btn-danger:hover { background:#fdd; }
.btn-sm { padding:4px 10px; font-size:11px; }
.btn-outline { background:transparent; border:1px solid #d9d9d9; color:#555; }
.btn-outline:hover { border-color:#4a6cf7; color:#4a6cf7; }
.btn:disabled { opacity:0.5; cursor:not-allowed; }
.card { background:#fff; border-radius:12px; padding:20px; margin-bottom:16px; box-shadow:0 2px 8px rgba(0,0,0,0.06); display:flex; justify-content:space-between; align-items:center; }
.card.clickable { cursor:pointer; transition:box-shadow .2s, transform .2s; }
.card.clickable:hover { box-shadow:0 4px 16px rgba(0,0,0,0.1); transform:translateY(-1px); }
.card-info h3 { font-size:16px; color:#1a1a2e; margin-bottom:6px; }
.card-info .meta { font-size:13px; color:#888; display:flex; gap:16px; flex-wrap:wrap; }
.card-info .meta span { display:inline-flex; align-items:center; gap:4px; }
.card-actions { display:flex; gap:8px; flex-shrink:0; }
.tag { display:inline-block; padding:2px 10px; border-radius:20px; font-size:11px; font-weight:600; }
.tag-style { background:#eef1ff; color:#4a6cf7; }
.empty { text-align:center; padding:60px 20px; color:#999; }
.empty p { font-size:14px; margin-bottom:16px; }
/* 弹窗通用 */
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4); z-index:1000; justify-content:center; align-items:center; }
#confirmModal { z-index:1100; }
.modal-overlay.show { display:flex; }
.modal { background:#fff; border-radius:12px; padding:24px; max-width:400px; width:90%; box-shadow:0 20px 60px rgba(0,0,0,0.2); max-height:90vh; overflow-y:auto; }
.modal-wide { max-width:480px; }
.modal h3 { font-size:16px; margin-bottom:16px; }
.modal h4 { font-size:14px; margin-bottom:10px; color:#1a1a2e; }
.modal p { font-size:14px; color:#666; margin-bottom:20px; }
.modal-actions { display:flex; gap:10px; justify-content:flex-end; }
.form-group { margin-bottom:14px; }
.form-group:last-child { margin-bottom:0; }
.form-group label { display:block; font-size:12px; font-weight:600; color:#555; margin-bottom:4px; }
.form-group input, .form-group select { width:100%; padding:8px 10px; border:1px solid #d9d9d9; border-radius:6px; font-size:13px; outline:none; }
.form-group input:focus, .form-group select:focus { border-color:#4a6cf7; box-shadow:0 0 0 2px rgba(74,108,247,0.1); }
.row { display:flex; gap:12px; }
.row > * { flex:1; }
.help-text { font-size:12px; color:#999; margin-top:4px; }
.success-msg { display:none; text-align:center; padding:8px; background:#e8f5e9; border-radius:6px; color:#2e7d32; font-size:12px; margin-top:10px; }
.success-msg.show { display:block; }
.spinner { width:18px; height:18px; border:3px solid #e0e0e0; border-top-color:#4a6cf7; border-radius:50%; animation:spin .6s linear infinite; display:inline-block; }
@keyframes spin { to { transform:rotate(360deg); } }
.add-link { display:inline-flex; align-items:center; gap:4px; color:#4a6cf7; font-size:13px; cursor:pointer; margin-top:6px; }
.add-link:hover { text-decoration:underline; }
/* 剧集 */
.ep-list { display:grid; gap:10px; }
.ep-item { display:flex; gap:12px; align-items:flex-start; padding:14px 16px; background:#fafafa; border-radius:8px; border:1px solid #eee; }
.ep-item .index { width:28px; height:28px; border-radius:50%; background:#4a6cf7; color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600; flex-shrink:0; }
.ep-item .info { flex:1; }
.ep-item .info .title { font-size:14px; font-weight:600; }
.ep-item .info .script { font-size:12px; color:#888; margin-top:4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.ep-item .info .script.expanded { -webkit-line-clamp:unset; }
.ep-item .info .script-preview { font-size:12px; color:#888; margin-top:4px; cursor:pointer; }
.ep-item .info .script-preview:hover { color:#4a6cf7; }
.ep-item .info .status { font-size:11px; margin-top:4px; display:inline-flex; align-items:center; gap:4px; }
.ep-item .info .status.pending { color:#999; }
.ep-item .info .status.generating { color:#f90; }
.ep-item .info .status.completed { color:#090; }
.ep-item .info .status.failed { color:#e44; }
.ep-item .actions { display:flex; gap:6px; flex-shrink:0; flex-wrap:wrap; }
@media (max-width:640px) { .ep-item { flex-direction:column; } .ep-item .actions { align-self:flex-end; } }
/* 预览审核 */
.spinner-sm { display:inline-block; width:12px; height:12px; border:2px solid #f90; border-top-color:transparent; border-radius:50%; animation:spin .6s linear infinite; margin-right:4px; vertical-align:middle; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>短剧管理</h1>
<div class="header-actions">
<button class="btn btn-outline" onclick="showConfigModal()">模型配置</button>
<button class="btn btn-outline" onclick="showPromptModal()">提示词配置</button>
<button class="btn btn-primary" onclick="showDramaModal()">+ 新建短剧</button>
</div>
</div>
<div id="dramaList"></div>
<div class="empty" id="emptyState" style="display:none">
<p>暂无短剧,点击上方按钮创建第一个短剧</p>
<button class="btn btn-primary" onclick="showDramaModal()">+ 新建短剧</button>
</div>
</div>
<!-- 删除确认弹窗 -->
<div class="modal-overlay" id="confirmModal">
<div class="modal">
<h3 id="confirmTitle">确认删除</h3>
<p id="confirmMsg" style="font-size:14px;color:#666;margin-bottom:20px;">确定要删除这个短剧吗?此操作不可恢复。</p>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal()">取消</button>
<button class="btn btn-danger" id="confirmBtn">确认</button>
</div>
</div>
</div>
<!-- 短剧新增/编辑弹窗 -->
<div class="modal-overlay" id="dramaModal">
<div class="modal modal-wide">
<h3 id="dramaModalTitle">新建短剧</h3>
<div class="form-group">
<label>短剧标题</label>
<input type="text" id="modalTitle" placeholder="请输入短剧标题">
</div>
<div class="form-group">
<label>风格</label>
<select id="modalStyle">
<option value="现代都市">现代都市</option>
<option value="古装仙侠">古装仙侠</option>
<option value="科幻未来">科幻未来</option>
<option value="悬疑推理">悬疑推理</option>
<option value="古装">古装</option>
<option value="现代">现代</option>
<option value="科幻">科幻</option>
<option value="奇幻">奇幻</option>
</select>
</div>
<div class="form-group">
<label>每集时长(秒)</label>
<input type="number" id="modalEpisodeDuration" value="60" min="10" max="600">
</div>
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #eee;">
<h4>演员管理 <span style="font-size:12px;color:#888;" id="modalCharCount">0</span></h4>
<div id="modalCharList"></div>
<div class="add-link" onclick="showModalCharForm()">+ 添加演员</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="hideDramaModal()">取消</button>
<button class="btn btn-primary" id="saveDramaBtn" onclick="saveDrama()">保存</button>
</div>
</div>
</div>
<!-- 演员编辑弹窗 -->
<div class="modal-overlay" id="modalCharModal">
<div class="modal">
<h3 id="modalCharTitle">添加演员</h3>
<div class="form-group">
<label>演员名</label>
<input type="text" id="modalCharName" placeholder="请输入演员名">
</div>
<div class="form-group">
<label>声音类型</label>
<input type="text" id="modalCharVoice" placeholder="如:男中音">
</div>
<div class="form-group">
<label>演员描述</label>
<input type="text" id="modalCharDesc" placeholder="请输入演员描述">
</div>
<div class="form-group">
<label>形象图 URL</label>
<input type="text" id="modalCharPortrait" placeholder="可选">
</div>
<input type="hidden" id="editModalCharIdx" value="-1">
<div class="modal-actions">
<button class="btn btn-outline" onclick="hideModalCharForm()">取消</button>
<button class="btn btn-primary" onclick="saveModalChar()">保存</button>
</div>
</div>
</div>
<!-- 剧集管理弹窗 -->
<div class="modal-overlay" id="episodeModal">
<div class="modal" style="max-width:640px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<div>
<h3 id="epModalDramaTitle" style="margin-bottom:4px;">加载中...</h3>
<span id="epModalDramaMeta" style="font-size:12px;color:#888;"></span>
</div>
<button class="btn btn-outline btn-sm" onclick="hideEpisodeModal()">关闭</button>
</div>
<div id="epList" class="ep-list" style="max-height:50vh;overflow-y:auto;"></div>
<div class="add-link" onclick="showEpModal()" style="margin-top:8px;">+ 添加剧集</div>
</div>
</div>
<!-- 剧集编辑弹窗 -->
<div class="modal-overlay" id="epModal">
<div class="modal modal-wide">
<h3 id="epModalTitle">添加剧集</h3>
<div class="form-group">
<label>剧集标题</label>
<input type="text" id="epTitle" placeholder="请输入剧集标题">
</div>
<div class="form-group">
<label>集数序号</label>
<input type="number" id="epIndex" placeholder="如:1">
</div>
<div class="form-group">
<label>剧本内容</label>
<textarea id="epScript" placeholder="请输入剧本内容" rows="4" style="width:100%;padding:8px 10px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none;font-family:inherit;resize:vertical"></textarea>
</div>
<input type="hidden" id="editEpId" value="">
<div class="modal-actions">
<button class="btn btn-outline" onclick="hideEpModal()">取消</button>
<button class="btn btn-primary" id="saveEpBtn" onclick="saveEp()">保存</button>
</div>
</div>
</div>
<!-- 预览审核弹窗 -->
<div class="modal-overlay" id="reviewModal">
<div class="modal" style="max-width:640px;">
<h3 id="reviewModalTitle">预览审核</h3>
<div id="reviewContent" style="max-height:60vh;overflow-y:auto;">
<div id="reviewLoading" style="text-align:center;padding:40px;color:#888;">加载中...</div>
<div id="reviewSegments" style="display:none;"></div>
<div id="reviewBody" style="display:none;">
<div class="form-group" style="margin-bottom:12px;">
<label>反馈意见(可选)</label>
<textarea id="feedbackInput" placeholder="如需修改,请描述具体意见..." rows="3" style="width:100%;padding:8px 10px;border:1px solid #d9d9d9;border-radius:6px;font-size:13px;outline:none;font-family:inherit;resize:vertical"></textarea>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button class="btn btn-primary" id="continueBtn" onclick="continueSegment()">继续下一段</button>
<button class="btn btn-outline" id="feedbackBtn" onclick="feedbackSegment()">重新生成</button>
</div>
</div>
</div>
<div class="modal-actions" style="margin-top:12px;padding-top:12px;border-top:1px solid #eee;">
<button class="btn btn-outline" onclick="hideReviewPanel()">关闭</button>
<span id="reviewStatus" style="font-size:12px;color:#888;"></span>
</div>
</div>
</div>
<script>
let deleteId = null;
let editDramaId = null;
let modalCharacters = [];
let modalRemovedCharIds = [];
let confirmAction = null;
let currentDramaId = null;
let currentDrama = null;
let pollingInterval = null;
let currentPollEpId = null;
let currentTaskId = null;
// ==================== 短剧列表 ====================
async function loadList() {
try {
const resp = await fetch('/drama/list');
const data = await resp.json();
if (data.code !== 0) throw new Error(data.message);
renderList(data.data.list);
} catch (err) {
document.getElementById('dramaList').innerHTML = '<div class="card" style="color:red;cursor:default">加载失败: ' + err.message + '</div>';
}
}
function renderList(list) {
const container = document.getElementById('dramaList');
const empty = document.getElementById('emptyState');
if (!list || list.length === 0) {
container.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
container.innerHTML = list.map(d => {
const epCount = d.epCount || 0;
const epDuration = d.episodeDuration || '-';
return `<div class="card clickable" onclick="openEpisodeModal('${d.id}')">
<div class="card-info">
<h3>${escHtml(d.title)}</h3>
<div class="meta">
<span class="tag tag-style">${escHtml(d.style)}</span>
<span>${epCount} 集</span>
<span>每集 ${epDuration}s</span>
</div>
</div>
<div class="card-actions" onclick="event.stopPropagation()">
<button class="btn btn-sm btn-outline" onclick="openEpisodeModal('${d.id}')">剧集管理</button>
<button class="btn btn-sm btn-outline" onclick="editDrama('${d.id}')">编辑</button>
<button class="btn btn-sm btn-danger" onclick="confirmDelete('${d.id}','${escHtml(d.title)}')">删除</button>
</div>
</div>`;
}).join('');
}
// ==================== 短剧 CRUD ====================
function showDramaModal() {
editDramaId = null;
document.getElementById('dramaModalTitle').textContent = '新建短剧';
document.getElementById('modalTitle').value = '';
document.getElementById('modalStyle').value = '现代都市';
document.getElementById('modalEpisodeDuration').value = 60;
modalCharacters = [];
modalRemovedCharIds = [];
renderModalCharList();
document.getElementById('dramaModal').classList.add('show');
}
function editDrama(id) {
editDramaId = id;
document.getElementById('dramaModalTitle').textContent = '编辑短剧';
document.getElementById('dramaModal').classList.add('show');
fetch('/drama/get?id=' + id).then(r => r.json()).then(data => {
if (data.code !== 0) throw new Error(data.message);
const d = data.data;
document.getElementById('modalTitle').value = d.title || '';
document.getElementById('modalStyle').value = d.style || '';
document.getElementById('modalEpisodeDuration').value = d.episodeDuration || 60;
modalCharacters = (d.characters || []).map(function(c) {
return { id: c.id, name: c.name, description: c.description, voiceType: c.voiceType, portraitUrl: c.portraitUrl };
});
modalRemovedCharIds = [];
renderModalCharList();
}).catch(err => {
alert('加载短剧信息失败: ' + err.message);
});
}
function hideDramaModal() {
document.getElementById('dramaModal').classList.remove('show');
editDramaId = null;
}
async function saveDrama() {
const title = document.getElementById('modalTitle').value.trim();
if (!title) { alert('请输入短剧标题'); return; }
const body = {
title: title,
style: document.getElementById('modalStyle').value,
episodeDuration: parseInt(document.getElementById('modalEpisodeDuration').value) || 60,
};
const btn = document.getElementById('saveDramaBtn');
btn.disabled = true;
try {
let url;
if (editDramaId) { url = '/drama/update'; body.id = parseInt(editDramaId); }
else { url = '/drama/create'; }
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await resp.json();
if (data.code !== 0) throw new Error(data.message);
const dramaId = editDramaId ? parseInt(editDramaId) : data.data.id;
for (var i = 0; i < modalRemovedCharIds.length; i++) {
await fetch('/drama/character/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dramaId, charId: modalRemovedCharIds[i] }) });
}
for (var i = 0; i < modalCharacters.length; i++) {
var c = modalCharacters[i];
var charBody = { dramaId, name: c.name, description: c.description || '', voiceType: c.voiceType || '', portraitUrl: c.portraitUrl || '' };
if (c.id > 0) { charBody.charId = c.id; await fetch('/drama/character/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charBody) }); }
else { await fetch('/drama/character/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(charBody) }); }
}
hideDramaModal();
loadList();
} catch (err) {
alert('保存失败: ' + err.message);
} finally {
btn.disabled = false;
}
}
// ==================== 演员管理(弹窗内) ====================
function renderModalCharList() {
const list = document.getElementById('modalCharList');
document.getElementById('modalCharCount').textContent = modalCharacters.length;
if (modalCharacters.length === 0) { list.innerHTML = '<div style="color:#999;font-size:12px;padding:4px 0">暂无演员</div>'; return; }
list.innerHTML = modalCharacters.map(function(c, i) {
var initial = c.name ? c.name.charAt(0) : '?';
return '<div style="display:flex;gap:8px;align-items:center;padding:6px 8px;background:#fafafa;border-radius:6px;border:1px solid #eee;margin-bottom:4px;">'
+ '<div style="width:28px;height:28px;border-radius:50%;background:#eef1ff;display:flex;align-items:center;justify-content:center;font-size:12px;color:#4a6cf7;flex-shrink:0;">' + initial + '</div>'
+ '<div style="flex:1;font-size:12px;"><strong>' + escHtml(c.name) + '</strong>' + (c.voiceType ? ' · ' + escHtml(c.voiceType) : '') + '</div>'
+ '<div style="display:flex;gap:4px;flex-shrink:0;">'
+ '<button class="btn btn-sm btn-outline" onclick="editModalChar(' + i + ')" style="padding:2px 8px;font-size:11px;">编辑</button>'
+ '<button class="btn btn-sm btn-danger" onclick="removeModalChar(' + i + ')" style="padding:2px 8px;font-size:11px;">删除</button></div></div>';
}).join('');
}
function showModalCharForm() {
document.getElementById('modalCharTitle').textContent = '添加演员';
document.getElementById('editModalCharIdx').value = '-1';
document.getElementById('modalCharName').value = ''; document.getElementById('modalCharDesc').value = '';
document.getElementById('modalCharVoice').value = ''; document.getElementById('modalCharPortrait').value = '';
document.getElementById('modalCharModal').classList.add('show');
}
function hideModalCharForm() { document.getElementById('modalCharModal').classList.remove('show'); }
function editModalChar(idx) {
var c = modalCharacters[idx]; if (!c) return;
document.getElementById('modalCharTitle').textContent = '编辑演员';
document.getElementById('editModalCharIdx').value = idx;
document.getElementById('modalCharName').value = c.name || ''; document.getElementById('modalCharDesc').value = c.description || '';
document.getElementById('modalCharVoice').value = c.voiceType || ''; document.getElementById('modalCharPortrait').value = c.portraitUrl || '';
document.getElementById('modalCharModal').classList.add('show');
}
function saveModalChar() {
var name = document.getElementById('modalCharName').value.trim();
if (!name) { alert('请输入演员名'); return; }
var idx = parseInt(document.getElementById('editModalCharIdx').value);
var charData = { name, description: document.getElementById('modalCharDesc').value.trim(), voiceType: document.getElementById('modalCharVoice').value.trim(), portraitUrl: document.getElementById('modalCharPortrait').value.trim() };
if (idx >= 0) { Object.assign(modalCharacters[idx], charData); }
else { charData.id = 0; modalCharacters.push(charData); }
hideModalCharForm(); renderModalCharList();
}
function removeModalChar(idx) {
var c = modalCharacters[idx]; if (!c) return;
confirmAction = function() {
if (c.id > 0) modalRemovedCharIds.push(c.id);
modalCharacters.splice(idx, 1); renderModalCharList();
};
document.getElementById('confirmTitle').textContent = '删除演员';
document.getElementById('confirmMsg').textContent = '确定要删除演员「' + c.name + '」吗?';
document.getElementById('confirmBtn').className = 'btn btn-danger';
document.getElementById('confirmModal').classList.add('show');
}
// ==================== 删除短剧 ====================
function confirmDelete(id, title) {
deleteId = id;
document.getElementById('confirmTitle').textContent = '确认删除';
document.getElementById('confirmMsg').textContent = '确定要删除《' + title + '》吗?此操作不可恢复。';
document.getElementById('confirmBtn').className = 'btn btn-danger';
document.getElementById('confirmModal').classList.add('show');
}
function closeModal() {
document.getElementById('confirmModal').classList.remove('show');
deleteId = null;
confirmAction = null;
}
document.getElementById('confirmBtn').addEventListener('click', async function() {
if (confirmAction) { await confirmAction(); closeModal(); return; }
if (!deleteId) return;
try {
await fetch('/drama/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: parseInt(deleteId) }) });
closeModal(); loadList();
} catch (err) { alert('删除失败: ' + err.message); closeModal(); }
});
// ==================== 剧集管理 ====================
function openEpisodeModal(id) {
currentDramaId = id;
document.getElementById('episodeModal').classList.add('show');
document.getElementById('epModalDramaTitle').textContent = '加载中...';
document.getElementById('epModalDramaMeta').textContent = '';
loadEpisodeDrama(id);
}
function hideEpisodeModal() {
stopPolling();
document.getElementById('episodeModal').classList.remove('show');
currentDramaId = null;
currentDrama = null;
}
async function loadEpisodeDrama(id) {
try {
const resp = await fetch('/drama/get?id=' + id);
const data = await resp.json();
if (data.code !== 0) throw new Error(data.message);
currentDrama = data.data;
document.getElementById('epModalDramaTitle').textContent = currentDrama.title || '未命名短剧';
document.getElementById('epModalDramaMeta').textContent = (currentDrama.style || '') + ' · ' + (currentDrama.episodeDuration || '-') + 's/集';
renderEps();
} catch (err) {
document.getElementById('epModalDramaTitle').textContent = '加载失败';
alert('加载剧集失败: ' + err.message);
}
}
function renderEps() {
const list = document.getElementById('epList');
const eps = currentDrama.episodes || [];
if (eps.length === 0) {
list.innerHTML = '<div style="color:#999;font-size:13px;padding:8px 0">暂无剧集,点击下方添加</div>';
return;
}
var taskMap = {};
if (currentDrama.tasks) { currentDrama.tasks.forEach(function(t) { taskMap[t.episodeId] = t; }); }
eps.sort(function(a, b) { return (a.index || 0) - (b.index || 0); });
list.innerHTML = eps.map(function(ep) {
const status = ep.status || 'pending';
const statusLabels = { pending:'待生成', generating:'生成中...', review:'待审核', completed:'已完成', failed:'生成失败' };
const scriptPreview = ep.script ? (ep.script.length > 60 ? ep.script.substring(0,60) + '...' : ep.script) : '';
var segHtml = '';
if (status !== 'pending' && status !== 'failed') {
var task = taskMap[ep.id];
if (task && task.stepsData) { segHtml = segmentPreviews(task, ep.id); }
}
return '<div class="ep-item">'
+ '<div class="index">' + (ep.index || '-') + '</div>'
+ '<div class="info">'
+ '<div class="title">' + escHtml(ep.title || '未命名') + '</div>'
+ (scriptPreview ? '<div class="script-preview" onclick="toggleScript(this)" data-full="' + escAttr(ep.script) + '">' + escHtml(scriptPreview) + '</div>' : '')
+ '<div class="status ' + status + '">'
+ (status === 'generating' ? '<span class="spinner-sm"></span>' : '')
+ (statusLabels[status] || status)
+ (ep.videoUrl ? ' · <button class="btn btn-sm btn-outline" onclick="previewEpisode(\'' + ep.id + '\')">预览</button>' : '')
+ segHtml
+ '</div></div>'
+ '<div class="actions">'
+ (status === 'pending' || status === 'failed' ? '<button class="btn btn-sm btn-success" onclick="generateEp(\'' + ep.id + '\')">生成视频</button>' : '')
+ (status === 'review' ? '<button class="btn btn-sm btn-primary" onclick="openReviewPanel(\'' + ep.id + '\')">审核</button>' : '')
+ '<button class="btn btn-sm btn-outline" onclick="editEp(\'' + ep.id + '\')">编辑</button>'
+ '<button class="btn btn-sm btn-danger" onclick="deleteEp(\'' + ep.id + '\',\'' + escAttr(ep.title) + '\')">删除</button>'
+ '</div></div>';
}).join('');
}
function segmentPreviews(task, epId) {
var sd; try { sd = JSON.parse(task.stepsData); } catch(e) { return ""; }
var segs = sd.segments || [];
// 只显示已有实际内容的片段(已生成的,不是空占位)
var actualSegs = segs.filter(function(s) { return s.textOutput || (s.scenes && s.scenes.length > 0); });
if (actualSegs.length === 0) return "";
return '<span style="font-size:11px;color:#888;margin-left:6px;">(' + actualSegs.length + '段)</span>'
+ ' <span style="display:inline-flex;gap:4px;margin-left:4px;flex-wrap:wrap;">'
+ actualSegs.map(function(s, i) {
var hasVideo = s.videoUrl ? true : false;
var cls = hasVideo ? 'btn btn-sm btn-outline' : 'btn btn-sm btn-outline disabled';
var click = hasVideo ? 'onclick="previewSegment(\'' + epId + '\',' + s.index + ')"' : '';
return '<button class="' + cls + '" ' + click + ' style="font-size:11px;padding:1px 6px;">片段' + (s.index + 1) + '</button>';
}).join('') + '</span>';
}
function toggleScript(el) {
if (el.classList.contains('expanded')) { el.classList.remove('expanded'); el.textContent = el.textContent.substring(0, 60) + '...'; }
else { el.classList.add('expanded'); el.textContent = el.getAttribute('data-full') || el.textContent; }
}
// ==================== 剧集 CRUD ====================
function showEpModal() {
document.getElementById('epModalTitle').textContent = '添加剧集';
document.getElementById('editEpId').value = '';
document.getElementById('epTitle').value = ''; document.getElementById('epIndex').value = '';
document.getElementById('epScript').value = '';
document.getElementById('epModal').classList.add('show');
}
function hideEpModal() { document.getElementById('epModal').classList.remove('show'); }
function editEp(epId) {
const ep = (currentDrama.episodes || []).find(x => x.id == epId);
if (!ep) return;
document.getElementById('epModalTitle').textContent = '编辑剧集';
document.getElementById('editEpId').value = epId;
document.getElementById('epTitle').value = ep.title || '';
document.getElementById('epIndex').value = ep.index || '';
document.getElementById('epScript').value = ep.script || '';
document.getElementById('epModal').classList.add('show');
}
async function saveEp() {
const title = document.getElementById('epTitle').value.trim();
const index = parseInt(document.getElementById('epIndex').value);
if (!title) { alert('请输入剧集标题'); return; }
if (isNaN(index)) { alert('请输入有效序号'); return; }
const editEpId = document.getElementById('editEpId').value;
const body = { dramaId: parseInt(currentDramaId), title, script: document.getElementById('epScript').value.trim(), index };
if (editEpId) body.epId = parseInt(editEpId);
const btn = document.getElementById('saveEpBtn'); btn.disabled = true;
try {
const url = editEpId ? '/drama/episode/update' : '/drama/episode/add';
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await resp.json();
if (data.code !== 0) throw new Error(data.message);
currentDrama = data.data; hideEpModal(); renderEps();
} catch (err) { alert('保存剧集失败: ' + err.message); }
finally { btn.disabled = false; }
}
function deleteEp(epId, title) {
confirmAction = async function() {
try {
const resp = await fetch('/drama/episode/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dramaId: parseInt(currentDramaId), epId: parseInt(epId) }) });
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
currentDrama = data.data; renderEps();
} catch (err) { alert('删除剧集失败: ' + err.message); }
};
document.getElementById('confirmTitle').textContent = '删除剧集';
document.getElementById('confirmMsg').textContent = '确定要删除剧集「' + title + '」吗?';
document.getElementById('confirmBtn').className = 'btn btn-danger';
document.getElementById('confirmModal').classList.add('show');
}
// ==================== 生成与轮询 ====================
async function generateEp(epId) {
const btn = event.target; btn.disabled = true; btn.textContent = '生成中...';
try {
const resp = await fetch('/drama/episode/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dramaId: parseInt(currentDramaId), epId: parseInt(epId) }) });
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
startPolling(epId);
} catch (err) { alert('生成失败: ' + err.message); btn.disabled = false; btn.textContent = '生成视频'; }
}
function startPolling(epId) {
currentPollEpId = epId; pollEpisode();
pollingInterval = setInterval(pollEpisode, 2000);
}
function stopPolling() {
if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; }
currentPollEpId = null;
}
async function pollEpisode() {
if (!currentPollEpId) return;
try {
const resp = await fetch('/drama/get?id=' + currentDramaId);
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
currentDrama = data.data; renderEps();
const ep = (currentDrama.episodes || []).find(x => x.id == currentPollEpId);
if (!ep) { stopPolling(); return; }
if (ep.status === 'review') {
const taskResp = await fetch('/drama/episode/task?epId=' + currentPollEpId);
const taskData = await taskResp.json();
if (taskData.code === 0 && taskData.data && taskData.data.task) showReviewPanel(taskData.data.task);
} else if (ep.status === 'completed') { stopPolling(); showReviewPanel(null); loadEpisodeDrama(currentDramaId); }
else if (ep.status === 'failed') { stopPolling(); showReviewPanel(null); alert('生成失败: ' + (ep.errorMessage || '未知错误')); loadEpisodeDrama(currentDramaId); }
} catch (err) { console.error('轮询失败:', err); }
}
// ==================== 预览审核 ====================
function showReviewPanel(task) {
const modal = document.getElementById('reviewModal');
if (!task) { modal.classList.remove('show'); return; }
document.getElementById('reviewLoading').style.display = 'none';
document.getElementById('reviewSegments').style.display = 'block';
document.getElementById('reviewBody').style.display = 'block';
modal.classList.add('show');
document.getElementById('reviewModalTitle').textContent = '预览审核';
currentTaskId = task.id;
var stepsData = null; try { stepsData = JSON.parse(task.stepsData || '{}'); } catch(e) { stepsData = {}; }
var segments = stepsData.segments || [];
document.getElementById('reviewSegments').innerHTML = segments.map(function(seg, i) {
return '<div style="margin-bottom:16px;border-bottom:1px solid #eee;padding-bottom:16px;">'
+ '<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:8px;">第' + (i+1) + '段</div>'
+ (seg.videoUrl ? '<video src="' + seg.videoUrl + '" controls style="max-width:100%;max-height:400px;border-radius:8px;"></video>' : '<div style="color:#888;font-size:13px;">无视频</div>')
+ '</div>';
}).join('');
document.getElementById('feedbackInput').value = '';
document.getElementById('reviewStatus').textContent = '等待审核...';
}
function hideReviewPanel() { document.getElementById('reviewModal').classList.remove('show'); }
function openReviewPanel(epId) {
fetch('/drama/episode/task?epId=' + epId).then(r => r.json()).then(d => {
if (d.code !== 0) { alert(d.message); return; }
showReviewPanel(d.data.task);
}).catch(err => alert('加载审核数据失败: ' + err.message));
}
function previewEpisode(epId, segIdx) {
segIdx = segIdx !== undefined ? segIdx : -1;
fetch('/drama/episode/task?epId=' + epId).then(r => r.json()).then(d => {
if (d.code !== 0) { alert(d.message); return; }
var task = d.data.task; var sd; try { sd = JSON.parse(task.stepsData || '{}'); } catch(e) { sd = {}; }
var segments = sd.segments || [];
var html = '';
for (var i = 0; i < segments.length; i++) {
var seg = segments[i]; var isFocused = (segIdx >= 0 && i === segIdx);
var style = isFocused ? 'margin-bottom:16px;border:2px solid #4a6cf7;border-radius:8px;padding:16px;background:#f8f9ff;' : 'margin-bottom:16px;border-bottom:1px solid #eee;padding-bottom:16px;';
html += '<div style="' + style + '"><div style="font-size:13px;font-weight:600;color:#555;margin-bottom:8px;">第' + (i+1) + '段' + (isFocused ? ' <span style="color:#4a6cf7;">← 当前</span>' : '') + '</div>';
html += seg.videoUrl ? '<video src="' + seg.videoUrl + '" controls style="max-width:100%;max-height:400px;border-radius:8px;"></video>' : '<div style="color:#888;font-size:13px;">无视频</div>';
html += '</div>';
}
document.getElementById('reviewSegments').innerHTML = html;
document.getElementById('reviewSegments').style.display = 'block';
document.getElementById('reviewBody').style.display = 'none';
document.getElementById('reviewLoading').style.display = 'none';
document.getElementById('reviewModalTitle').textContent = '视频预览';
document.getElementById('reviewStatus').textContent = '';
document.getElementById('reviewModal').classList.add('show');
}).catch(err => alert('加载预览失败: ' + err.message));
}
function previewSegment(epId, segIdx) { previewEpisode(epId, segIdx); }
async function continueSegment() {
if (!currentTaskId) return;
const btn = document.getElementById('continueBtn'); btn.disabled = true; btn.textContent = '处理中...';
try {
const resp = await fetch('/drama/segment/continue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: currentTaskId }) });
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
hideReviewPanel(); document.getElementById('reviewStatus').textContent = '已确认,继续生成...';
} catch (err) { alert('操作失败: ' + err.message); }
finally { btn.disabled = false; btn.textContent = '继续下一段'; }
}
async function feedbackSegment() {
if (!currentTaskId) return;
const feedback = document.getElementById('feedbackInput').value.trim();
if (!feedback) { alert('请输入反馈意见'); return; }
const btn = document.getElementById('feedbackBtn'); btn.disabled = true; btn.textContent = '处理中...';
try {
const resp = await fetch('/drama/segment/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskId: currentTaskId, feedback }) });
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
hideReviewPanel(); document.getElementById('reviewStatus').textContent = '已提交反馈,重新生成...';
} catch (err) { alert('操作失败: ' + err.message); }
finally { btn.disabled = false; btn.textContent = '重新生成'; }
}
// ==================== 工具函数 ====================
function escHtml(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { if (!s) return ''; return s.replace(/'/g, '&#39;').replace(/"/g, '&quot;'); }
loadList();
// ==================== 模型配置弹窗 ====================
function showConfigModal() { document.getElementById('configModal').classList.add('show'); loadConfigForm(); }
function hideConfigModal() { document.getElementById('configModal').classList.remove('show'); }
async function loadConfigForm() {
try {
const resp = await fetch('/config/model'); const data = await resp.json();
if (data.code !== 0) throw new Error(data.message);
const cfg = data.data || {};
document.getElementById('cfg_chatApiKey').value = cfg.chatApiKey || '';
document.getElementById('cfg_chatBaseURL').value = cfg.chatBaseUrl || '';
document.getElementById('cfg_chatModelName').value = cfg.chatModelName || '';
document.getElementById('cfg_videoBaseURL').value = cfg.videoBaseUrl || '';
document.getElementById('cfg_videoQueryURL').value = cfg.videoQueryUrl || '';
document.getElementById('cfg_videoApiKey').value = cfg.videoApiKey || '';
document.getElementById('cfg_videoModelName').value = cfg.videoModelName || '';
if (cfg.maxTokens) document.getElementById('cfg_maxTokens').value = cfg.maxTokens;
if (cfg.temperature) document.getElementById('cfg_temperature').value = cfg.temperature;
if (cfg.maxSingleDuration) document.getElementById('cfg_maxSingleDuration').value = cfg.maxSingleDuration;
if (cfg.minSingleDuration) document.getElementById('cfg_minSingleDuration').value = cfg.minSingleDuration;
} catch (err) { console.error('加载配置失败:', err); }
}
async function saveConfig() {
const btn = document.getElementById('cfg_saveBtn'); const loading = document.getElementById('cfg_loading'); const success = document.getElementById('cfg_successMsg');
btn.disabled = true; loading.classList.add('show'); success.classList.remove('show');
try {
const body = {
chatApiKey: document.getElementById("cfg_chatApiKey").value.trim(),
videoApiKey: document.getElementById("cfg_videoApiKey").value.trim(),
chatBaseUrl: document.getElementById("cfg_chatBaseURL").value.trim(),
chatModelName: document.getElementById("cfg_chatModelName").value.trim(),
videoBaseUrl: document.getElementById("cfg_videoBaseURL").value.trim(),
videoQueryUrl: document.getElementById("cfg_videoQueryURL").value.trim(),
videoModelName: document.getElementById("cfg_videoModelName").value.trim(),
maxTokens: parseInt(document.getElementById("cfg_maxTokens").value) || 0,
temperature: parseFloat(document.getElementById("cfg_temperature").value) || 0,
maxSingleDuration: parseInt(document.getElementById("cfg_maxSingleDuration").value) || 30,
minSingleDuration: parseInt(document.getElementById("cfg_minSingleDuration").value) || 2,
};
const resp = await fetch('/config/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
success.classList.add('show'); setTimeout(() => success.classList.remove('show'), 3000);
} catch (err) { alert('保存失败: ' + err.message); }
finally { btn.disabled = false; loading.classList.remove('show'); }
}
// ==================== 提示词配置弹窗 ====================
const PROMPT_NAME = 'system_prompt';
function showPromptModal() { document.getElementById('promptModal').classList.add('show'); loadPromptForm(); }
function hidePromptModal() { document.getElementById('promptModal').classList.remove('show'); }
async function loadPromptForm() {
const ta = document.getElementById('promptContent'); ta.value = '加载中...';
try {
const resp = await fetch('/prompt/get?name=' + PROMPT_NAME); const data = await resp.json();
if (data.code !== 0) throw new Error(data.message);
ta.value = (data.data && data.data.content) || '';
} catch (err) { ta.value = '加载失败: ' + err.message; }
}
async function savePrompt() {
const btn = document.getElementById('prompt_saveBtn'); const loading = document.getElementById('prompt_loading'); const success = document.getElementById('prompt_successMsg');
btn.disabled = true; loading.classList.add('show'); success.classList.remove('show');
try {
const body = { name: PROMPT_NAME, content: document.getElementById('promptContent').value };
const resp = await fetch('/prompt/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await resp.json(); if (data.code !== 0) throw new Error(data.message);
success.classList.add('show'); setTimeout(() => success.classList.remove('show'), 3000);
} catch (err) { alert('保存失败: ' + err.message); }
finally { btn.disabled = false; loading.classList.remove('show'); }
}
</script>
<!-- ==================== 提示词配置弹窗 ==================== -->
<div class="modal-overlay" id="promptModal">
<div class="modal modal-wide" style="max-width:720px;">
<h3>提示词配置</h3>
<div class="help-text" style="margin-bottom:12px;">修改后保存即时生效,下次生成时使用新提示词</div>
<div class="form-group">
<textarea id="promptContent" rows="25" placeholder="加载中..." style="width:100%;min-height:300px;padding:12px;border:1px solid #d9d9d9;border-radius:8px;font-size:13px;font-family:monospace;line-height:1.6;outline:none;resize:vertical;"></textarea>
</div>
<div style="margin-top:16px;display:flex;gap:10px;justify-content:center;align-items:center;">
<div class="loading" id="prompt_loading" style="display:none;align-items:center;gap:6px;color:#888;font-size:13px;"><div class="spinner"></div><span>保存中...</span></div>
<button class="btn btn-primary" id="prompt_saveBtn" onclick="savePrompt()">保存提示词</button>
<button class="btn btn-outline" onclick="hidePromptModal()">取消</button>
</div>
<div class="success-msg" id="prompt_successMsg">提示词已保存</div>
</div>
</div>
<!-- 模型配置弹窗 -->
<div class="modal-overlay" id="configModal">
<div class="modal modal-wide" style="max-width:560px;">
<h3>模型配置</h3>
<div class="form-group"><label>对话 API Key</label><input type="password" id="cfg_chatApiKey" placeholder="sk-..."><div class="help-text">对话模型 API 密钥</div></div>
<h4 style="font-size:13px;margin:14px 0 8px;color:#1a1a2e;border-left:3px solid #4a6cf7;padding-left:8px;">对话模型(Agent 文字推理)</h4>
<div class="form-group"><label>API 地址</label><input type="text" id="cfg_chatBaseURL" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"></div>
<div class="form-group"><label>对话模型</label><input type="text" id="cfg_chatModelName" placeholder="qwen3.7-plus, gpt-4o, deepseek-chat 等"></div>
<h4 style="font-size:13px;margin:14px 0 8px;color:#1a1a2e;border-left:3px solid #4a6cf7;padding-left:8px;">视频生成模型</h4>
<div class="form-group"><label>视频 API Key</label><input type="password" id="cfg_videoApiKey" placeholder="sk-..."><div class="help-text">视频生成 API 密钥,可与对话模型不同</div></div>
<div class="form-group"><label>视频 API 地址</label><input type="text" id="cfg_videoBaseURL" placeholder="https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis"></div>
<div class="form-group"><label>视频模型</label><input type="text" id="cfg_videoModelName" placeholder="wan2.7-t2v"></div>
<div class="form-group"><label>视频查询地址</label><input type="text" id="cfg_videoQueryURL" placeholder="https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}"><div class="help-text">用 {task_id} 占位,轮询时自动替换</div></div>
<div class="row">
<div class="form-group"><label>Max Tokens</label><input type="number" id="cfg_maxTokens" value="4096" min="256" max="32768"></div>
<div class="form-group"><label>Temperature</label><input type="number" id="cfg_temperature" value="0.8" min="0" max="2" step="0.1"></div>
</div>
<div class="row">
<div class="form-group"><label>单段最小时长(秒)</label><input type="number" id="cfg_minSingleDuration" value="2" min="1" max="60"><div class="help-text">不足时从前面的段借</div></div>
<div class="form-group"><label>单段最大时长(秒)</label><input type="number" id="cfg_maxSingleDuration" value="30" min="5" max="300"><div class="help-text">超出会自动分段生成</div></div>
</div>
<div style="margin-top:16px;display:flex;gap:10px;justify-content:center;align-items:center;">
<div class="loading" id="cfg_loading" style="display:none;align-items:center;gap:6px;color:#888;font-size:13px;"><div class="spinner"></div><span>保存中...</span></div>
<button class="btn btn-primary" id="cfg_saveBtn" onclick="saveConfig()">保存配置</button>
<button class="btn btn-outline" onclick="hideConfigModal()">取消</button>
</div>
<div class="success-msg" id="cfg_successMsg">配置已保存</div>
</div>
</div>
</body>
</html>