mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix: comprehensive Sora & GDrive code review fixes
P0 fixes: - StorageType: UploadFromURL now returns actual storageType, fixing GDrive tasks being incorrectly recorded as "s3" - Concurrency: selectAccount returns ReleaseFunc to caller, fixing premature slot release before task creation - Race condition: applyRecoveredToken uses copy-on-write for account.Credentials map to prevent concurrent write panic - GetCameoStatus: use direct HTTP call to populate missing fields (StatusMessage, InstructionSetHint, InstructionSet) - Frontend: progress bar uses reactive now.value instead of non-reactive Date.now() P1 fixes: - EditImage: pass req.Image through to CreateImageRequest - ListPending: add LIMIT 200 to prevent unbounded queries - Worker encapsulation: add ListPendingTasks/UpdateTask proxy methods to SoraTaskService, worker no longer accesses repo directly - Frontend polling: add retry counter (3 attempts) before giving up P2-P3 fixes: - QuotaBar: handle both quota_source and source field names - i18n: replace hardcoded Chinese labels in SoraPromptBar and example prompts in SoraGeneratePage with i18n keys - Fix regenrate → regenerate i18n typo - SoraMediaPreview: skip Escape handler when not visible - SoraGeneratePage: void handleGenerate promise in handleRetry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -350,9 +350,10 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
if h.objectStorage != nil && h.objectStorage.Enabled(ctx) {
|
||||
keys := make([]string, 0, len(urls))
|
||||
var totalSize int64
|
||||
var actualStorageType string
|
||||
allOK := true
|
||||
for _, u := range urls {
|
||||
key, size, err := h.objectStorage.UploadFromURL(ctx, userID, u)
|
||||
key, size, st, err := h.objectStorage.UploadFromURL(ctx, userID, u)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 对象存储上传失败 type=%s err=%v", h.objectStorage.StorageType(), err)
|
||||
allOK = false
|
||||
@@ -364,6 +365,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
}
|
||||
keys = append(keys, key)
|
||||
totalSize += size
|
||||
actualStorageType = st
|
||||
}
|
||||
if allOK && len(keys) > 0 {
|
||||
accessURLs := make([]string, 0, len(keys))
|
||||
@@ -378,7 +380,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
accessURLs = append(accessURLs, accessURL)
|
||||
}
|
||||
if allOK && len(accessURLs) > 0 {
|
||||
return accessURLs[0], accessURLs, h.objectStorage.StorageType(), keys, totalSize
|
||||
return accessURLs[0], accessURLs, actualStorageType, keys, totalSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -697,7 +699,7 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
var totalSize int64
|
||||
|
||||
for _, sourceURL := range sourceURLs {
|
||||
objectKey, fileSize, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
|
||||
objectKey, fileSize, _, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
|
||||
if uploadErr != nil {
|
||||
if len(uploadedKeys) > 0 {
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
|
||||
@@ -43,10 +43,11 @@ func NewSoraVideosHandler(
|
||||
}
|
||||
|
||||
func (h *SoraVideosHandler) CreateVideo(c *gin.Context) {
|
||||
apiKey, account, ok := h.selectAccount(c, "")
|
||||
apiKey, account, release, ok := h.selectAccount(c, "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
body, err := readBody(c)
|
||||
if err != nil {
|
||||
@@ -224,10 +225,11 @@ func (h *SoraVideosHandler) resolveContentURL(c *gin.Context, task *service.Sora
|
||||
}
|
||||
|
||||
func (h *SoraVideosHandler) CreateImage(c *gin.Context) {
|
||||
apiKey, account, ok := h.selectAccount(c, "")
|
||||
apiKey, account, release, ok := h.selectAccount(c, "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
body, err := readBody(c)
|
||||
if err != nil {
|
||||
@@ -257,10 +259,11 @@ func (h *SoraVideosHandler) CreateImage(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SoraVideosHandler) EditImage(c *gin.Context) {
|
||||
apiKey, account, ok := h.selectAccount(c, "")
|
||||
apiKey, account, release, ok := h.selectAccount(c, "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
body, err := readBody(c)
|
||||
if err != nil {
|
||||
@@ -287,6 +290,7 @@ func (h *SoraVideosHandler) EditImage(c *gin.Context) {
|
||||
imageReq := &service.CreateImageRequest{
|
||||
Model: req.Model,
|
||||
Prompt: req.Prompt,
|
||||
Image: req.Image,
|
||||
Size: req.Size,
|
||||
ResponseFormat: req.ResponseFormat,
|
||||
N: 1,
|
||||
@@ -312,10 +316,10 @@ func (h *SoraVideosHandler) getAPIKey(c *gin.Context) (*service.APIKey, bool) {
|
||||
return apiKey, true
|
||||
}
|
||||
|
||||
func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*service.APIKey, *service.Account, bool) {
|
||||
func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*service.APIKey, *service.Account, func(), bool) {
|
||||
apiKey, ok := h.getAPIKey(c)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(
|
||||
@@ -323,12 +327,14 @@ func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*servic
|
||||
)
|
||||
if err != nil {
|
||||
soraErrorResponse(c, http.StatusServiceUnavailable, "server_error", "No available accounts")
|
||||
return nil, nil, false
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
|
||||
releaseFunc := func() {}
|
||||
if selection.ReleaseFunc != nil {
|
||||
defer selection.ReleaseFunc()
|
||||
releaseFunc = selection.ReleaseFunc
|
||||
}
|
||||
return apiKey, selection.Account, true
|
||||
return apiKey, selection.Account, releaseFunc, true
|
||||
}
|
||||
|
||||
func (h *SoraVideosHandler) selectAccountByID(c *gin.Context, accountID int64) (*service.Account, error) {
|
||||
|
||||
@@ -85,7 +85,7 @@ func (r *SoraTaskRepository) Update(ctx context.Context, task *service.SoraTask)
|
||||
|
||||
func (r *SoraTaskRepository) ListPending(ctx context.Context) ([]*service.SoraTask, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+soraTaskColumns+` FROM sora_tasks WHERE status IN ('queued', 'in_progress') ORDER BY created_at ASC`,
|
||||
`SELECT `+soraTaskColumns+` FROM sora_tasks WHERE status IN ('queued', 'in_progress') ORDER BY created_at ASC LIMIT 200`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -297,29 +297,29 @@ func (s *SoraGDriveStorage) IsHealthy(ctx context.Context) bool {
|
||||
}
|
||||
|
||||
// UploadFromURL 从上游 URL 下载并上传到 Google Drive。
|
||||
// 返回 Google Drive 文件 ID 作为 objectKey。
|
||||
func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, error) {
|
||||
// 返回 Google Drive 文件 ID 作为 objectKey、文件大小、存储类型。
|
||||
func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
|
||||
srv, cfg, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
return "", 0, "", err
|
||||
}
|
||||
|
||||
// 下载源文件
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("create download request: %w", err)
|
||||
return "", 0, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("download from upstream: %w", err)
|
||||
return "", 0, "", fmt.Errorf("download from upstream: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, &UpstreamDownloadError{StatusCode: resp.StatusCode}
|
||||
return "", 0, "", &UpstreamDownloadError{StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
// 推断文件扩展名和 MIME
|
||||
@@ -358,7 +358,7 @@ func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sou
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("gdrive upload: %w", err)
|
||||
return "", 0, "", fmt.Errorf("gdrive upload: %w", err)
|
||||
}
|
||||
|
||||
fileSize := cr.BytesRead
|
||||
@@ -379,7 +379,7 @@ func (s *SoraGDriveStorage) UploadFromURL(ctx context.Context, userID int64, sou
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.sora_gdrive", "[SoraGDrive] 上传完成 fileID=%s size=%d", created.Id, fileSize)
|
||||
return created.Id, fileSize, nil
|
||||
return created.Id, fileSize, SoraStorageTypeGDrive, nil
|
||||
}
|
||||
|
||||
// DeleteObjects 删除一组 Google Drive 文件。
|
||||
|
||||
@@ -15,8 +15,8 @@ type SoraObjectStorage interface {
|
||||
TestConnection(ctx context.Context) error
|
||||
|
||||
// UploadFromURL 从上游 URL 下载并上传到存储。
|
||||
// 返回 object key(S3 key 或 GDrive file ID)、文件大小。
|
||||
UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, err error)
|
||||
// 返回 object key(S3 key 或 GDrive file ID)、文件大小、实际使用的存储类型。
|
||||
UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, storageType string, err error)
|
||||
|
||||
// DeleteObjects 删除一组存储对象。
|
||||
DeleteObjects(ctx context.Context, objectKeys []string) error
|
||||
|
||||
@@ -212,29 +212,29 @@ func (s *SoraS3Storage) GenerateObjectKey(prefix string, userID int64, ext strin
|
||||
}
|
||||
|
||||
// UploadFromURL 从上游 URL 下载并流式上传到 S3。
|
||||
// 返回 S3 object key。
|
||||
func (s *SoraS3Storage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, error) {
|
||||
// 返回 S3 object key、文件大小、存储类型。
|
||||
func (s *SoraS3Storage) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
|
||||
client, cfg, err := s.getClient(ctx)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
return "", 0, "", err
|
||||
}
|
||||
|
||||
// 下载源文件
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("create download request: %w", err)
|
||||
return "", 0, "", fmt.Errorf("create download request: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("download from upstream: %w", err)
|
||||
return "", 0, "", fmt.Errorf("download from upstream: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, &UpstreamDownloadError{StatusCode: resp.StatusCode}
|
||||
return "", 0, "", &UpstreamDownloadError{StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
// 推断文件扩展名
|
||||
@@ -275,14 +275,14 @@ func (s *SoraS3Storage) UploadFromURL(ctx context.Context, userID int64, sourceU
|
||||
_ = writer.CloseWithError(copyErr)
|
||||
uploadErr := <-uploadErrCh
|
||||
if copyErr != nil {
|
||||
return "", 0, fmt.Errorf("stream upload copy failed: %w", copyErr)
|
||||
return "", 0, "", fmt.Errorf("stream upload copy failed: %w", copyErr)
|
||||
}
|
||||
if uploadErr != nil {
|
||||
return "", 0, fmt.Errorf("s3 upload: %w", uploadErr)
|
||||
return "", 0, "", fmt.Errorf("s3 upload: %w", uploadErr)
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.sora_s3", "[SoraS3] 上传完成 key=%s size=%d", objectKey, written)
|
||||
return objectKey, written, nil
|
||||
return objectKey, written, SoraStorageTypeS3, nil
|
||||
}
|
||||
|
||||
func buildSoraS3Client(ctx context.Context, cfg *SoraS3Settings) (*s3.Client, string, error) {
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestTestConnection_GetClientError(t *testing.T) {
|
||||
|
||||
func TestUploadFromURL_GetClientError(t *testing.T) {
|
||||
s := NewSoraS3Storage(nil)
|
||||
_, _, err := s.UploadFromURL(context.Background(), 1, "https://example.com/file.mp4")
|
||||
_, _, _, err := s.UploadFromURL(context.Background(), 1, "https://example.com/file.mp4")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -316,20 +316,36 @@ func (c *SoraSDKClient) GetCameoStatus(ctx context.Context, account *Account, ca
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sdkClient, err := c.getSDKClient(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status, err := sdkClient.GetCameoStatus(ctx, token, cameoID)
|
||||
|
||||
// 直接调用 Sora 后端 API 而非 SDK,以获取 SDK 未暴露的字段
|
||||
// (status_message、instruction_set_hint、instruction_set)。
|
||||
path := "/project_y/cameos/in_progress/" + cameoID
|
||||
raw, err := c.doSoraBackendJSON(ctx, account, http.MethodGet, path, token, "", nil)
|
||||
if err != nil {
|
||||
return nil, c.wrapSDKError(err, account)
|
||||
}
|
||||
return &SoraCameoStatus{
|
||||
Status: status.Status,
|
||||
DisplayNameHint: status.DisplayNameHint,
|
||||
UsernameHint: status.UsernameHint,
|
||||
ProfileAssetURL: status.ProfileAssetURL,
|
||||
}, nil
|
||||
|
||||
return parseCameoStatusFromRaw(raw), nil
|
||||
}
|
||||
|
||||
// parseCameoStatusFromRaw 从原始 JSON 解析 SoraCameoStatus,
|
||||
// 包含 SDK 未暴露的 status_message / instruction_set_hint / instruction_set 字段。
|
||||
func parseCameoStatusFromRaw(raw []byte) *SoraCameoStatus {
|
||||
result := gjson.ParseBytes(raw)
|
||||
cameoStatus := &SoraCameoStatus{
|
||||
Status: strings.TrimSpace(result.Get("status").String()),
|
||||
StatusMessage: strings.TrimSpace(result.Get("status_message").String()),
|
||||
DisplayNameHint: strings.TrimSpace(result.Get("display_name_hint").String()),
|
||||
UsernameHint: strings.TrimSpace(result.Get("username_hint").String()),
|
||||
ProfileAssetURL: strings.TrimSpace(result.Get("profile_asset_url").String()),
|
||||
}
|
||||
if v := result.Get("instruction_set_hint"); v.Exists() {
|
||||
cameoStatus.InstructionSetHint = v.Value()
|
||||
}
|
||||
if v := result.Get("instruction_set"); v.Exists() {
|
||||
cameoStatus.InstructionSet = v.Value()
|
||||
}
|
||||
return cameoStatus
|
||||
}
|
||||
|
||||
func (c *SoraSDKClient) DownloadCharacterImage(ctx context.Context, account *Account, imageURL string) ([]byte, error) {
|
||||
@@ -925,26 +941,32 @@ func (c *SoraSDKClient) exchangeSessionToken(ctx context.Context, account *Accou
|
||||
return accessToken, expiresAt, nil
|
||||
}
|
||||
|
||||
// applyRecoveredToken 将恢复的 token 写入账号内存和数据库
|
||||
// applyRecoveredToken 将恢复的 token 写入账号内存和数据库。
|
||||
// 使用 copy-on-write 避免并发 map 写入 panic:创建新 map 后整体替换指针。
|
||||
func (c *SoraSDKClient) applyRecoveredToken(ctx context.Context, account *Account, accessToken, refreshToken, expiresAt, sessionToken string) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
if account.Credentials == nil {
|
||||
account.Credentials = make(map[string]any)
|
||||
|
||||
// Copy-on-write: 复制旧 map 并写入新值,最后整体替换
|
||||
oldCreds := account.Credentials
|
||||
newCreds := make(map[string]any, len(oldCreds)+4)
|
||||
for k, v := range oldCreds {
|
||||
newCreds[k] = v
|
||||
}
|
||||
if strings.TrimSpace(accessToken) != "" {
|
||||
account.Credentials["access_token"] = accessToken
|
||||
newCreds["access_token"] = accessToken
|
||||
}
|
||||
if strings.TrimSpace(refreshToken) != "" {
|
||||
account.Credentials["refresh_token"] = refreshToken
|
||||
newCreds["refresh_token"] = refreshToken
|
||||
}
|
||||
if strings.TrimSpace(expiresAt) != "" {
|
||||
account.Credentials["expires_at"] = expiresAt
|
||||
newCreds["expires_at"] = expiresAt
|
||||
}
|
||||
if strings.TrimSpace(sessionToken) != "" {
|
||||
account.Credentials["session_token"] = sessionToken
|
||||
newCreds["session_token"] = sessionToken
|
||||
}
|
||||
account.Credentials = newCreds
|
||||
|
||||
if c.accountRepo != nil {
|
||||
if err := c.accountRepo.Update(ctx, account); err != nil && c.debugEnabled() {
|
||||
|
||||
@@ -75,10 +75,10 @@ func (r *SoraStorageRouter) TestConnection(ctx context.Context) error {
|
||||
return backend.TestConnection(ctx)
|
||||
}
|
||||
|
||||
func (r *SoraStorageRouter) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, error) {
|
||||
func (r *SoraStorageRouter) UploadFromURL(ctx context.Context, userID int64, sourceURL string) (string, int64, string, error) {
|
||||
backend := r.activeBackend(ctx)
|
||||
if backend == nil {
|
||||
return "", 0, fmt.Errorf("no storage backend available")
|
||||
return "", 0, "", fmt.Errorf("no storage backend available")
|
||||
}
|
||||
return backend.UploadFromURL(ctx, userID, sourceURL)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type CreateVideoRequest struct {
|
||||
type CreateImageRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
@@ -262,6 +263,16 @@ func (s *SoraTaskService) GetTaskByID(ctx context.Context, taskID string) (*Sora
|
||||
return s.repo.GetByID(ctx, taskID)
|
||||
}
|
||||
|
||||
// ListPendingTasks returns tasks in queued or in_progress status.
|
||||
func (s *SoraTaskService) ListPendingTasks(ctx context.Context) ([]*SoraTask, error) {
|
||||
return s.repo.ListPending(ctx)
|
||||
}
|
||||
|
||||
// UpdateTask persists task state changes.
|
||||
func (s *SoraTaskService) UpdateTask(ctx context.Context, task *SoraTask) error {
|
||||
return s.repo.Update(ctx, task)
|
||||
}
|
||||
|
||||
func (s *SoraTaskService) GetAccountByID(ctx context.Context, accountID int64) (*Account, error) {
|
||||
return s.accountRepo.GetByID(ctx, accountID)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (w *SoraTaskWorker) pollAll() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
tasks, err := w.taskService.repo.ListPending(ctx)
|
||||
tasks, err := w.taskService.ListPendingTasks(ctx)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.sora_task_worker", "[PollAll] list pending tasks error: %v", err)
|
||||
return
|
||||
@@ -137,14 +137,14 @@ func (w *SoraTaskWorker) tryStoreMedia(ctx context.Context, task *SoraTask) {
|
||||
}
|
||||
|
||||
if w.objectStorage != nil && w.objectStorage.Enabled(ctx) {
|
||||
key, _, err := w.objectStorage.UploadFromURL(ctx, 0, task.VideoURL)
|
||||
key, _, storageType, err := w.objectStorage.UploadFromURL(ctx, 0, task.VideoURL)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.sora_task_worker",
|
||||
"[StoreMedia] task=%s object storage upload error: %v", task.ID, err)
|
||||
} else {
|
||||
task.StoredKey = key
|
||||
task.StorageType = w.objectStorage.StorageType()
|
||||
if updateErr := w.taskService.repo.Update(ctx, task); updateErr != nil {
|
||||
task.StorageType = storageType
|
||||
if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
|
||||
logger.LegacyPrintf("service.sora_task_worker",
|
||||
"[StoreMedia] task=%s update stored_key error: %v", task.ID, updateErr)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func (w *SoraTaskWorker) tryStoreMedia(ctx context.Context, task *SoraTask) {
|
||||
if len(stored) > 0 && stored[0] != task.VideoURL {
|
||||
task.StoredKey = stored[0]
|
||||
task.StorageType = "local"
|
||||
if updateErr := w.taskService.repo.Update(ctx, task); updateErr != nil {
|
||||
if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
|
||||
logger.LegacyPrintf("service.sora_task_worker",
|
||||
"[StoreMedia] task=%s update stored_key error: %v", task.ID, updateErr)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func (w *SoraTaskWorker) markTaskFailed(ctx context.Context, task *SoraTask, mes
|
||||
task.ErrorMessage = message
|
||||
task.ErrorType = "server_error"
|
||||
task.CompletedAt = &now
|
||||
if updateErr := w.taskService.repo.Update(ctx, task); updateErr != nil {
|
||||
if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
|
||||
logger.LegacyPrintf("service.sora_task_worker",
|
||||
"[PollOne] task=%s update failed: %v", task.ID, updateErr)
|
||||
}
|
||||
|
||||
@@ -67,15 +67,16 @@ const activeGenerations = ref<SoraGeneration[]>([])
|
||||
const generating = ref(false)
|
||||
const showNoStorageToast = ref(false)
|
||||
let pollTimers: Record<number, ReturnType<typeof setTimeout>> = {}
|
||||
let pollRetries: Record<number, number> = {}
|
||||
const promptBarRef = ref<InstanceType<typeof SoraPromptBar> | null>(null)
|
||||
|
||||
// 示例提示词
|
||||
const examplePrompts = [
|
||||
'一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
|
||||
'无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
|
||||
'赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
|
||||
'水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
|
||||
]
|
||||
const examplePrompts = computed(() => [
|
||||
t('sora.examplePrompt1'),
|
||||
t('sora.examplePrompt2'),
|
||||
t('sora.examplePrompt3'),
|
||||
t('sora.examplePrompt4')
|
||||
])
|
||||
|
||||
// 活跃任务统计
|
||||
const activeTaskCount = computed(() =>
|
||||
@@ -189,13 +190,22 @@ async function pollGeneration(id: number) {
|
||||
checkStatusTransition(activeGenerations.value[idx], gen)
|
||||
activeGenerations.value[idx] = gen
|
||||
}
|
||||
pollRetries[id] = 0
|
||||
if (gen.status === 'pending' || gen.status === 'generating') {
|
||||
schedulePolling(id)
|
||||
} else {
|
||||
delete pollTimers[id]
|
||||
delete pollRetries[id]
|
||||
}
|
||||
} catch {
|
||||
delete pollTimers[id]
|
||||
const retries = (pollRetries[id] || 0) + 1
|
||||
pollRetries[id] = retries
|
||||
if (retries > 3) {
|
||||
delete pollTimers[id]
|
||||
delete pollRetries[id]
|
||||
} else {
|
||||
schedulePolling(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +275,7 @@ async function handleSave(id: number) {
|
||||
}
|
||||
|
||||
function handleRetry(gen: SoraGeneration) {
|
||||
handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
|
||||
void handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
|
||||
}
|
||||
|
||||
function fillPrompt(text: string) {
|
||||
@@ -296,6 +306,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
Object.values(pollTimers).forEach(clearTimeout)
|
||||
pollTimers = {}
|
||||
pollRetries = {}
|
||||
stopTitleBlink()
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
@@ -82,7 +82,7 @@ import { onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SoraGeneration } from '@/api/sora'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
generation: SoraGeneration | null
|
||||
}>()
|
||||
@@ -100,6 +100,7 @@ function formatDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!props.visible) return
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
<!-- 失败/取消 -->
|
||||
<template v-if="generation.status === 'failed' || generation.status === 'cancelled'">
|
||||
<button class="sora-action-btn primary" @click="emit('retry', generation)">
|
||||
🔄 {{ generation.status === 'cancelled' ? t('sora.regenrate') : t('sora.retry') }}
|
||||
🔄 {{ generation.status === 'cancelled' ? t('sora.regenerate') : t('sora.retry') }}
|
||||
</button>
|
||||
<button class="sora-action-btn secondary" @click="emit('delete', generation.id)">
|
||||
🗑 {{ t('sora.delete') }}
|
||||
@@ -194,7 +194,7 @@ const progressWidth = computed(() => {
|
||||
if (s === 'generating') {
|
||||
// 根据创建时间估算进度
|
||||
const created = new Date(props.generation.created_at).getTime()
|
||||
const elapsed = Date.now() - created
|
||||
const elapsed = now.value - created
|
||||
// 假设平均 10 分钟完成,最多到 95%
|
||||
const progress = Math.min(95, (elapsed / (10 * 60 * 1000)) * 100)
|
||||
return `${Math.round(progress)}%`
|
||||
@@ -207,7 +207,7 @@ const progressInfoText = computed(() => {
|
||||
if (s === 'pending') return t('sora.queueWaiting')
|
||||
if (s === 'generating') {
|
||||
const created = new Date(props.generation.created_at).getTime()
|
||||
const elapsed = Date.now() - created
|
||||
const elapsed = now.value - created
|
||||
return `${t('sora.waited')} ${formatElapsed(elapsed)}`
|
||||
}
|
||||
return ''
|
||||
@@ -262,11 +262,19 @@ const countdownText = computed(() => {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const needsTimer = computed(() => {
|
||||
const s = props.generation.status
|
||||
return s === 'pending' || s === 'generating' || isUpstream.value
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (isUpstream.value) {
|
||||
if (needsTimer.value) {
|
||||
countdownTimer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
if (now.value >= expireTime.value && countdownTimer) {
|
||||
// Stop timer when upstream countdown expires and no longer generating
|
||||
const s = props.generation.status
|
||||
const stillGenerating = s === 'pending' || s === 'generating'
|
||||
if (!stillGenerating && isUpstream.value && now.value >= expireTime.value && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
|
||||
@@ -167,11 +167,17 @@ import type { ApiKey, UserSubscription } from '@/types'
|
||||
|
||||
const MAX_IMAGE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
/** 方向显示配置 */
|
||||
const ASPECT_META: Record<string, { icon: string; label: string }> = {
|
||||
landscape: { icon: '▬', label: '横屏' },
|
||||
portrait: { icon: '▮', label: '竖屏' },
|
||||
square: { icon: '◻', label: '方形' }
|
||||
/** 方向显示配置(使用 i18n key) */
|
||||
const ASPECT_ICONS: Record<string, string> = {
|
||||
landscape: '▬',
|
||||
portrait: '▮',
|
||||
square: '◻'
|
||||
}
|
||||
|
||||
const ASPECT_LABEL_KEYS: Record<string, string> = {
|
||||
landscape: 'sora.landscape',
|
||||
portrait: 'sora.portrait',
|
||||
square: 'sora.square'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -221,7 +227,11 @@ const availableAspects = computed(() => {
|
||||
const fam = currentFamily.value
|
||||
if (!fam?.orientations?.length) return []
|
||||
return fam.orientations
|
||||
.map(o => ({ value: o, ...(ASPECT_META[o] || { icon: '?', label: o }) }))
|
||||
.map(o => ({
|
||||
value: o,
|
||||
icon: ASPECT_ICONS[o] || '?',
|
||||
label: ASPECT_LABEL_KEYS[o] ? t(ASPECT_LABEL_KEYS[o]) : o
|
||||
}))
|
||||
})
|
||||
|
||||
// 当前家族支持的时长列表
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="quota && quota.source !== 'none'" class="sora-quota-info">
|
||||
<div v-if="quota && (quota.quota_source || quota.source) !== 'none'" class="sora-quota-info">
|
||||
<div class="sora-quota-bar-wrapper">
|
||||
<div
|
||||
class="sora-quota-bar-fill"
|
||||
|
||||
@@ -4464,6 +4464,7 @@ export default {
|
||||
downloadLocal: 'Download',
|
||||
canDownload: 'to download',
|
||||
regenrate: 'Regenerate',
|
||||
regenerate: 'Regenerate',
|
||||
creatorPlaceholder: 'Describe the video or image you want to create...',
|
||||
videoModels: 'Video Models',
|
||||
imageModels: 'Image Models',
|
||||
@@ -4480,6 +4481,13 @@ export default {
|
||||
galleryEmptyTitle: 'No works yet',
|
||||
galleryEmptyDesc: 'Your creations will be displayed here. Go to the generate page to start your first creation.',
|
||||
startCreating: 'Start Creating',
|
||||
yesterday: 'Yesterday'
|
||||
yesterday: 'Yesterday',
|
||||
landscape: 'Landscape',
|
||||
portrait: 'Portrait',
|
||||
square: 'Square',
|
||||
examplePrompt1: 'A golden Shiba Inu walking through the streets of Shibuya, Tokyo, camera following, cinematic shot, 4K',
|
||||
examplePrompt2: 'Drone aerial view, green aurora reflecting on a glacial lake in Iceland, slow push-in',
|
||||
examplePrompt3: 'Cyberpunk futuristic city, neon lights reflected in rain puddles, nightscape, cinematic colors',
|
||||
examplePrompt4: 'Chinese ink painting style, a small boat drifting among misty mountains and rivers, classical atmosphere'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4655,6 +4655,7 @@ export default {
|
||||
downloadLocal: '本地下载',
|
||||
canDownload: '可下载',
|
||||
regenrate: '重新生成',
|
||||
regenerate: '重新生成',
|
||||
creatorPlaceholder: '描述你想要生成的视频或图片...',
|
||||
videoModels: '视频模型',
|
||||
imageModels: '图片模型',
|
||||
@@ -4671,6 +4672,13 @@ export default {
|
||||
galleryEmptyTitle: '还没有任何作品',
|
||||
galleryEmptyDesc: '你的创作成果将会展示在这里。前往生成页,开始你的第一次创作吧。',
|
||||
startCreating: '开始创作',
|
||||
yesterday: '昨天'
|
||||
yesterday: '昨天',
|
||||
landscape: '横屏',
|
||||
portrait: '竖屏',
|
||||
square: '方形',
|
||||
examplePrompt1: '一只金色的柴犬在东京涩谷街头散步,镜头跟随,电影感画面,4K 高清',
|
||||
examplePrompt2: '无人机航拍视角,冰岛极光下的冰川湖面反射绿色光芒,慢速推进',
|
||||
examplePrompt3: '赛博朋克风格的未来城市,霓虹灯倒映在雨后积水中,夜景,电影级色彩',
|
||||
examplePrompt4: '水墨画风格,一叶扁舟在山水间漂泊,薄雾缭绕,中国古典意境'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user