diff --git a/backend/internal/handler/sora_client_handler.go b/backend/internal/handler/sora_client_handler.go index d41b10c5c1..3cb85d18b6 100644 --- a/backend/internal/handler/sora_client_handler.go +++ b/backend/internal/handler/sora_client_handler.go @@ -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) diff --git a/backend/internal/handler/sora_videos_handler.go b/backend/internal/handler/sora_videos_handler.go index 35c9ad8072..46edbd3624 100644 --- a/backend/internal/handler/sora_videos_handler.go +++ b/backend/internal/handler/sora_videos_handler.go @@ -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) { diff --git a/backend/internal/repository/sora_task_repo.go b/backend/internal/repository/sora_task_repo.go index 8107bbe18b..2c775a7217 100644 --- a/backend/internal/repository/sora_task_repo.go +++ b/backend/internal/repository/sora_task_repo.go @@ -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 diff --git a/backend/internal/service/sora_gdrive_storage.go b/backend/internal/service/sora_gdrive_storage.go index 10db5afc44..afbc3ea5c9 100644 --- a/backend/internal/service/sora_gdrive_storage.go +++ b/backend/internal/service/sora_gdrive_storage.go @@ -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 文件。 diff --git a/backend/internal/service/sora_object_storage.go b/backend/internal/service/sora_object_storage.go index 3c2f98197b..f9e9a4b03e 100644 --- a/backend/internal/service/sora_object_storage.go +++ b/backend/internal/service/sora_object_storage.go @@ -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 diff --git a/backend/internal/service/sora_s3_storage.go b/backend/internal/service/sora_s3_storage.go index d47a3000b2..19d8b9c22f 100644 --- a/backend/internal/service/sora_s3_storage.go +++ b/backend/internal/service/sora_s3_storage.go @@ -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) { diff --git a/backend/internal/service/sora_s3_storage_test.go b/backend/internal/service/sora_s3_storage_test.go index 32ff9a6f0a..8ed7137d8e 100644 --- a/backend/internal/service/sora_s3_storage_test.go +++ b/backend/internal/service/sora_s3_storage_test.go @@ -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) } diff --git a/backend/internal/service/sora_sdk_client.go b/backend/internal/service/sora_sdk_client.go index f9221c5b51..c839be4b03 100644 --- a/backend/internal/service/sora_sdk_client.go +++ b/backend/internal/service/sora_sdk_client.go @@ -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() { diff --git a/backend/internal/service/sora_storage_router.go b/backend/internal/service/sora_storage_router.go index 79c6779f07..83e055f5df 100644 --- a/backend/internal/service/sora_storage_router.go +++ b/backend/internal/service/sora_storage_router.go @@ -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) } diff --git a/backend/internal/service/sora_task_service.go b/backend/internal/service/sora_task_service.go index 0cfaa1d376..9b0a547459 100644 --- a/backend/internal/service/sora_task_service.go +++ b/backend/internal/service/sora_task_service.go @@ -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) } diff --git a/backend/internal/service/sora_task_worker.go b/backend/internal/service/sora_task_worker.go index 8bbc19032f..b29083e009 100644 --- a/backend/internal/service/sora_task_worker.go +++ b/backend/internal/service/sora_task_worker.go @@ -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) } diff --git a/frontend/src/components/sora/SoraGeneratePage.vue b/frontend/src/components/sora/SoraGeneratePage.vue index 1f77edc401..b6ba251ba0 100644 --- a/frontend/src/components/sora/SoraGeneratePage.vue +++ b/frontend/src/components/sora/SoraGeneratePage.vue @@ -67,15 +67,16 @@ const activeGenerations = ref([]) const generating = ref(false) const showNoStorageToast = ref(false) let pollTimers: Record> = {} +let pollRetries: Record = {} const promptBarRef = ref | 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) }) diff --git a/frontend/src/components/sora/SoraMediaPreview.vue b/frontend/src/components/sora/SoraMediaPreview.vue index 09a3aea143..4bcfc5dfea 100644 --- a/frontend/src/components/sora/SoraMediaPreview.vue +++ b/frontend/src/components/sora/SoraMediaPreview.vue @@ -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') } diff --git a/frontend/src/components/sora/SoraProgressCard.vue b/frontend/src/components/sora/SoraProgressCard.vue index 69b28ef994..8a97946a9b 100644 --- a/frontend/src/components/sora/SoraProgressCard.vue +++ b/frontend/src/components/sora/SoraProgressCard.vue @@ -114,7 +114,7 @@