feat: 画图台并发重构 + 品牌定制 + 用户备注 + provider/UI 多项修复
画图台(并发出图):
- 不再锁定 UI:点「生成」开独立任务,可连续多次并发
- 结果网格一行5个、最多10张,进行中/成功/失败状态回显,刷新保留进行中
- 生图张数 1/2/3/4,各自独立计费出卡
- 点图=参考图(单张替换/多张替换末位);首尾帧模型点视频=抓末帧设为首帧,否则放大
- /logs 新增 statuses=pending,success 服务端过滤(status IN 专用 SQL)
品牌定制(设置→网站):
- 自定义 Logo 图片 + 子标题(公开页头部 + 管理侧栏)
- 邮件验证码标题改用站点名:{title} 邮箱验证码
提示词复制:
- 去掉复制按钮,点提示词文字即复制(预览/后台日志/图片管理/画图记录),统一弹「指令已复制」
- 新增 utils/clipboard.js:execCommand 回退,非安全上下文(http/IP)也能复制
用户管理:列表加「备注」列,新建/编辑可填改备注(默认空)
provider 修复:
- grok 401 正确判死封号(markTokenFailure 漏了 grok 池)
- grok 视频支持 15s
- custom 上游报错去敏感(抹掉上游 URL/IP,改英文短描述)
- custom 去掉额度耗尽锁定:429/欠费当临时错误,账号保持 active
UI/其它:
- 展示位弹窗浅色主题适配(tab 选中高亮、输入框边框)— 主题变量 + 中心补丁
- 自定义模型:时长可填任意秒数 + 15s 预设
- 首页设置/卡密弹窗去固定高度与滚动条
- 顶部菜单「记录」→「图片」
- 下线 Flow provider(代码移除)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -29,6 +30,32 @@ type Client struct{}
|
||||
|
||||
func NewClient() *Client { return &Client{} }
|
||||
|
||||
// sanitizeErr strips the upstream URL/host from a network error so a user's
|
||||
// private upstream URL never leaks into the event log / API response.
|
||||
func sanitizeErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
s := err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
||||
return "request timeout"
|
||||
case strings.Contains(s, "connection refused"):
|
||||
return "connection refused"
|
||||
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
||||
return "cannot reach upstream"
|
||||
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
||||
return "TLS error"
|
||||
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
||||
return "connection reset"
|
||||
}
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return strings.ToLower(ue.Op) + " upstream failed"
|
||||
}
|
||||
return "upstream request failed"
|
||||
}
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute} }
|
||||
|
||||
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
||||
@@ -82,7 +109,7 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom
|
||||
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
@@ -145,7 +172,7 @@ func (c *Client) GenerateVideo(ctx context.Context, baseURL, apiKey, model, prom
|
||||
case "failed", "error", "canceled", "cancelled":
|
||||
reason := stringValue(job["error"])
|
||||
if isCreditError(reason) {
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrQuotaExhausted, clip([]byte(reason), 160))
|
||||
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip([]byte(reason), 160))
|
||||
}
|
||||
return nil, "", fmt.Errorf("custom: video %s", clip([]byte(reason), 160))
|
||||
}
|
||||
@@ -171,7 +198,7 @@ func (c *Client) doJSON(ctx context.Context, method, url, apiKey string, body []
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
@@ -194,7 +221,7 @@ func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, erro
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
@@ -234,7 +261,7 @@ func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
@@ -249,12 +276,14 @@ func mapStatus(status int, body []byte) error {
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %d %s", ErrAuth, status, clip(body, 160))
|
||||
case status == 429:
|
||||
return fmt.Errorf("%w: 429 %s", ErrQuotaExhausted, clip(body, 160))
|
||||
// Custom upstreams have NO "quota exhausted" lock — a 429 is just rate
|
||||
// limiting, treated as a temporary error (fail over, account stays active).
|
||||
return fmt.Errorf("%w: 429 %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
case status >= 500:
|
||||
return fmt.Errorf("%w: %d %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
default:
|
||||
if isCreditError(string(body)) {
|
||||
return fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(body, 160))
|
||||
return fmt.Errorf("%w: %s", ErrTemporaryUpstream, clip(body, 160))
|
||||
}
|
||||
return fmt.Errorf("custom: %d %s", status, clip(body, 160))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user