200 lines
5.2 KiB
Go
200 lines
5.2 KiB
Go
package web
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
|
"git.ilapage.cn/OPC/chorus/portal/service"
|
|
)
|
|
|
|
//go:embed templates/*.html static/* static/vendor/*
|
|
var assets embed.FS
|
|
|
|
type Page struct {
|
|
Title string
|
|
DisplayName string
|
|
CSRFToken string
|
|
ReturnTo string
|
|
History []model.Generation
|
|
HistoryCursor string
|
|
HistoryHasMore bool
|
|
HistoryLimit int
|
|
Current *service.Detail
|
|
MaxPromptBytes int
|
|
MaxImages int
|
|
ImageGenerateAvailable bool
|
|
ImageEditAvailable bool
|
|
}
|
|
|
|
type Renderer struct{ templates *template.Template }
|
|
|
|
func NewRenderer() (*Renderer, error) {
|
|
functions := template.FuncMap{
|
|
"statusLabel": statusLabel, "statusTone": statusTone, "kindLabel": kindLabel, "kindIcon": kindIcon,
|
|
"shortPrompt": shortPrompt, "relativeTime": relativeTime, "terminal": func(status model.GenerationStatus) bool { return status.Terminal() },
|
|
"historyDuration": func(g model.Generation) string { return generationDuration(g, time.Now(), false) },
|
|
"resultDuration": func(g model.Generation) string { return generationDuration(g, time.Now(), true) },
|
|
"timeAttr": optionalTimeAttr,
|
|
"outputMetadata": outputMetadata,
|
|
"value": func(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
},
|
|
}
|
|
templates, err := template.New("portal").Funcs(functions).ParseFS(assets, "templates/*.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse portal templates: %w", err)
|
|
}
|
|
return &Renderer{templates: templates}, nil
|
|
}
|
|
|
|
func (r *Renderer) Render(writer io.Writer, name string, data any) error {
|
|
return r.templates.ExecuteTemplate(writer, name, data)
|
|
}
|
|
|
|
func Static() (fs.FS, error) { return fs.Sub(assets, "static") }
|
|
|
|
func statusLabel(status model.GenerationStatus) string {
|
|
switch status {
|
|
case model.StatusPending:
|
|
return "排队中"
|
|
case model.StatusRunning:
|
|
return "生成中"
|
|
case model.StatusSucceeded:
|
|
return "已完成"
|
|
case model.StatusFailed:
|
|
return "失败"
|
|
default:
|
|
return "未知"
|
|
}
|
|
}
|
|
func statusTone(status model.GenerationStatus) string {
|
|
switch status {
|
|
case model.StatusPending:
|
|
return "pending"
|
|
case model.StatusRunning:
|
|
return "running"
|
|
case model.StatusFailed:
|
|
return "failed"
|
|
default:
|
|
return "success"
|
|
}
|
|
}
|
|
func kindLabel(kind model.GenerationKind) string {
|
|
if kind == model.KindImage {
|
|
return "图片"
|
|
}
|
|
return "文本"
|
|
}
|
|
func kindIcon(kind model.GenerationKind) string {
|
|
if kind == model.KindImage {
|
|
return "image"
|
|
}
|
|
return "file-text"
|
|
}
|
|
func shortPrompt(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if utf8.RuneCountInString(value) <= 28 {
|
|
return value
|
|
}
|
|
return string([]rune(value)[:28]) + "..."
|
|
}
|
|
func relativeTime(value time.Time) string {
|
|
elapsed := time.Since(value)
|
|
switch {
|
|
case elapsed < time.Minute:
|
|
return "刚刚"
|
|
case elapsed < time.Hour:
|
|
return fmt.Sprintf("%d 分钟前", int(elapsed.Minutes()))
|
|
case elapsed < 24*time.Hour:
|
|
return fmt.Sprintf("%d 小时前", int(elapsed.Hours()))
|
|
default:
|
|
return value.Local().Format("01-02 15:04")
|
|
}
|
|
}
|
|
|
|
func generationDuration(generation model.Generation, now time.Time, includeLabel bool) string {
|
|
label := "总耗时"
|
|
end := now
|
|
if !generation.Status.Terminal() {
|
|
if generation.Status == model.StatusPending {
|
|
label = "已等待"
|
|
} else {
|
|
label = "已用时"
|
|
}
|
|
} else if generation.CompletedAt != nil {
|
|
end = *generation.CompletedAt
|
|
} else {
|
|
return "耗时未知"
|
|
}
|
|
if generation.CreatedAt.IsZero() || end.Before(generation.CreatedAt) {
|
|
return "时间异常"
|
|
}
|
|
value := formatDuration(end.Sub(generation.CreatedAt))
|
|
if includeLabel || !generation.Status.Terminal() {
|
|
return label + " " + value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func formatDuration(duration time.Duration) string {
|
|
if duration < time.Second {
|
|
return "<1秒"
|
|
}
|
|
seconds := int64(duration.Round(time.Second) / time.Second)
|
|
if seconds < 60 {
|
|
return fmt.Sprintf("%d秒", seconds)
|
|
}
|
|
minutes := seconds / 60
|
|
seconds %= 60
|
|
if minutes < 60 {
|
|
return fmt.Sprintf("%d分%02d秒", minutes, seconds)
|
|
}
|
|
hours := minutes / 60
|
|
minutes %= 60
|
|
return fmt.Sprintf("%d小时%02d分%02d秒", hours, minutes, seconds)
|
|
}
|
|
|
|
func optionalTimeAttr(value *time.Time) string {
|
|
if value == nil || value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
|
|
func outputMetadata(output model.GenerationOutput) string {
|
|
parts := make([]string, 0, 3)
|
|
if output.MIMEType != nil && strings.TrimSpace(*output.MIMEType) != "" {
|
|
parts = append(parts, strings.ToUpper(strings.TrimPrefix(strings.TrimSpace(*output.MIMEType), "image/")))
|
|
}
|
|
if output.Width != nil && output.Height != nil && *output.Width > 0 && *output.Height > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d x %d", *output.Width, *output.Height))
|
|
}
|
|
if output.SizeBytes != nil {
|
|
parts = append(parts, formatBytes(*output.SizeBytes))
|
|
}
|
|
if len(parts) == 0 {
|
|
return "图片结果"
|
|
}
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
|
|
func formatBytes(bytes uint64) string {
|
|
if bytes < 1024 {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
if bytes < 1024*1024 {
|
|
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
|
}
|