This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
- Frontend now logs more useful API and debug information than earlier versions.
|
- Frontend now logs more useful API and debug information than earlier versions.
|
||||||
- A local self-test workflow now exists and should be run before container builds or pushes.
|
- A local self-test workflow now exists and should be run before container builds or pushes.
|
||||||
- A fresh-machine bootstrap was revalidated in a user-local toolchain setup on `2026-03-17`; `go test ./...` and `bash scripts/selftest.sh` now pass in that setup.
|
- A fresh-machine bootstrap was revalidated in a user-local toolchain setup on `2026-03-17`; `go test ./...` and `bash scripts/selftest.sh` now pass in that setup.
|
||||||
|
- Result modal sizing is now being constrained to the viewport, and modal-only source-summary translation is now part of the active implementation path.
|
||||||
|
|
||||||
## Current Architecture
|
## Current Architecture
|
||||||
- `backend/main.go`
|
- `backend/main.go`
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
- Frontend JavaScript still has no Node-based lint/build step in this environment.
|
- Frontend JavaScript still has no Node-based lint/build step in this environment.
|
||||||
- Search cards now separate source snippet from AI reason, but metadata fidelity still depends on source enrichment quality.
|
- Search cards now separate source snippet from AI reason, but metadata fidelity still depends on source enrichment quality.
|
||||||
- Gemini notes are now intended to be Korean, but final output quality still depends on Gemini response consistency.
|
- Gemini notes are now intended to be Korean, but final output quality still depends on Gemini response consistency.
|
||||||
|
- Source Summary translation now depends on Google Translate HTTP availability; frontend silently falls back to original summary text if translation fails.
|
||||||
- The local self-test script is better than before, but it is still a smoke test, not full integration coverage.
|
- The local self-test script is better than before, but it is still a smoke test, not full integration coverage.
|
||||||
|
|
||||||
## Current Risks Around Search Quality
|
## Current Risks Around Search Quality
|
||||||
@@ -547,6 +549,7 @@
|
|||||||
- [ ] Reduce `/api/search` latency further without collapsing result count
|
- [ ] Reduce `/api/search` latency further without collapsing result count
|
||||||
- [ ] Build a repeatable repo-local bootstrap script or documented setup command set for non-root machines so fresh PC setup does not depend on shell history
|
- [ ] Build a repeatable repo-local bootstrap script or documented setup command set for non-root machines so fresh PC setup does not depend on shell history
|
||||||
- [ ] Improve Envato / Artgrid preview acquisition reliability so Gemini Vision sees real frames more often
|
- [ ] Improve Envato / Artgrid preview acquisition reliability so Gemini Vision sees real frames more often
|
||||||
|
- [ ] Browser-verify the new result modal at multiple viewport heights and confirm translated Source Summary readability on real long descriptions
|
||||||
- [ ] Revisit Google Video UX:
|
- [ ] Revisit Google Video UX:
|
||||||
- current YouTube embed was abandoned due error `153`
|
- current YouTube embed was abandoned due error `153`
|
||||||
- current in-app panel is more reliable but less rich than a true embedded watch page
|
- current in-app panel is more reliable but less rich than a true embedded watch page
|
||||||
@@ -612,6 +615,23 @@
|
|||||||
- If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise
|
- If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise
|
||||||
|
|
||||||
## Recent Change Log
|
## Recent Change Log
|
||||||
|
- Date: `2026-03-17`
|
||||||
|
- What changed:
|
||||||
|
- Added `POST /api/translate/summary` so the result modal can translate `Source Summary` text to Korean on demand with in-process caching.
|
||||||
|
- Reworked result modal sizing so the popup stays within the viewport and the summary area scrolls internally instead of stretching the whole dialog off-screen.
|
||||||
|
- Replaced the false-positive near-deadline warning heuristic with explicit deadline metadata from search collection / enrichment / Gemini stages.
|
||||||
|
- Added an Artgrid API `403` cooldown guard so repeated clip enrichments stop re-hitting the same blocked JSON endpoint for a while and fall back to HTML parsing directly.
|
||||||
|
- Added backend tests for timeout-warning gating, summary translation caching, and Artgrid `403` skip behavior.
|
||||||
|
- Why it changed:
|
||||||
|
- The provided browser log showed a successful `~55s` search still surfacing `search returned partial results to avoid gateway timeout`, and the user reported that the result popup could overflow the viewport and that untranslated / very long source summaries made the modal feel too large.
|
||||||
|
- How it was verified:
|
||||||
|
- `go test ./...`
|
||||||
|
- `bash scripts/selftest.sh`
|
||||||
|
- `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py`
|
||||||
|
- What is still risky or incomplete:
|
||||||
|
- Full browser-level confirmation of the resized modal and lazy summary translation still needs live UI validation.
|
||||||
|
- The Artgrid `403` guard reduces repeated waste, but it does not solve the underlying upstream access restriction.
|
||||||
|
|
||||||
- Date: `2026-03-17`
|
- Date: `2026-03-17`
|
||||||
- What changed:
|
- What changed:
|
||||||
- Re-audited the repository structure and `TODO.md` handover state on a fresh machine.
|
- Re-audited the repository structure and `TODO.md` handover state on a fresh machine.
|
||||||
|
|||||||
+34
-3
@@ -147,6 +147,7 @@ func RegisterRoutes(router *gin.Engine, app *App) {
|
|||||||
router.POST("/api/download/preview", app.previewDownload)
|
router.POST("/api/download/preview", app.previewDownload)
|
||||||
router.POST("/api/upload", app.uploadFile)
|
router.POST("/api/upload", app.uploadFile)
|
||||||
router.POST("/api/download", app.startDownload)
|
router.POST("/api/download", app.startDownload)
|
||||||
|
router.POST("/api/translate/summary", app.translateSummary)
|
||||||
router.POST("/api/search", app.searchMedia)
|
router.POST("/api/search", app.searchMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +344,28 @@ func (a *App) previewDownload(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, preview)
|
c.JSON(http.StatusOK, preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) translateSummary(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Text = strings.TrimSpace(req.Text)
|
||||||
|
if req.Text == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
translated, err := a.GeminiService.TranslateSummaryToKorean(req.Text)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"translatedText": translated})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) {
|
func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) {
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url})
|
a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url})
|
||||||
a.debug("download command started", gin.H{"url": url, "start": start, "end": end, "quality": quality, "outputPath": outputPath})
|
a.debug("download command started", gin.H{"url": url, "start": start, "end": end, "quality": quality, "outputPath": outputPath})
|
||||||
@@ -416,7 +439,7 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
|
|
||||||
enabledPlatforms := normalizePlatforms(req.Platforms)
|
enabledPlatforms := normalizePlatforms(req.Platforms)
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching " + selectedPlatformLabel(enabledPlatforms), "progress": 35})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching " + selectedPlatformLabel(enabledPlatforms), "progress": 35})
|
||||||
results, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second))
|
results, searchMeta, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.debug("search backend failed", gin.H{"error": err.Error(), "variants": queryVariants, "durationMs": time.Since(started).Milliseconds()})
|
a.debug("search backend failed", gin.H{"error": err.Error(), "variants": queryVariants, "durationMs": time.Since(started).Milliseconds()})
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()})
|
||||||
@@ -441,10 +464,12 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75})
|
||||||
recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(a.GeminiService, req.Query, scored, deadline.Add(-5*time.Second))
|
recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(a.GeminiService, req.Query, scored, deadline.Add(-5*time.Second))
|
||||||
a.debug("search gemini evaluation", geminiStats)
|
a.debug("search gemini evaluation", geminiStats)
|
||||||
|
supplementalDeadlineLimited := false
|
||||||
if services.NeedsSupplementalExploration(recommended) && time.Now().Before(deadline.Add(-10*time.Second)) {
|
if services.NeedsSupplementalExploration(recommended) && time.Now().Before(deadline.Add(-10*time.Second)) {
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82})
|
||||||
explorationQueries := buildSupplementalQueries(req.Query, queryVariants)
|
explorationQueries := buildSupplementalQueries(req.Query, queryVariants)
|
||||||
extraResults, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second))
|
extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second))
|
||||||
|
supplementalDeadlineLimited = extraMeta.PartialDueToDeadline
|
||||||
if extraErr == nil && len(extraResults) > 0 {
|
if extraErr == nil && len(extraResults) > 0 {
|
||||||
results = mergeSearchResults(results, extraResults)
|
results = mergeSearchResults(results, extraResults)
|
||||||
scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results)
|
scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results)
|
||||||
@@ -468,6 +493,8 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if services.NeedsSupplementalExploration(recommended) {
|
||||||
|
supplementalDeadlineLimited = true
|
||||||
}
|
}
|
||||||
if geminiErr != nil && len(recommended) == 0 {
|
if geminiErr != nil && len(recommended) == 0 {
|
||||||
warning := geminiErr.Error()
|
warning := geminiErr.Error()
|
||||||
@@ -503,13 +530,17 @@ func (a *App) searchMedia(c *gin.Context) {
|
|||||||
if warning != "" {
|
if warning != "" {
|
||||||
response["warning"] = warning
|
response["warning"] = warning
|
||||||
}
|
}
|
||||||
if time.Now().After(deadline.Add(-2*time.Second)) && warning == "" {
|
if shouldWarnPartialSearch(searchMeta, geminiStats, supplementalDeadlineLimited, warning) {
|
||||||
response["warning"] = "search returned partial results to avoid gateway timeout"
|
response["warning"] = "search returned partial results to avoid gateway timeout"
|
||||||
}
|
}
|
||||||
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100})
|
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100})
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldWarnPartialSearch(meta services.SearchExecutionMeta, stats services.GeminiBatchStats, supplementalDeadlineLimited bool, warning string) bool {
|
||||||
|
return warning == "" && (meta.PartialDueToDeadline || stats.DeadlineLimited || supplementalDeadlineLimited)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeFilename(name string) string {
|
func normalizeFilename(name string) string {
|
||||||
base := strings.ToLower(strings.TrimSpace(name))
|
base := strings.ToLower(strings.TrimSpace(name))
|
||||||
ext := filepath.Ext(base)
|
ext := filepath.Ext(base)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ai-media-hub/backend/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldWarnPartialSearchDoesNotWarnForCompletedSearch(t *testing.T) {
|
||||||
|
if shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{}, false, "") {
|
||||||
|
t.Fatal("expected no warning when search completed without deadline limits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldWarnPartialSearchWarnsWhenDeadlineLimited(t *testing.T) {
|
||||||
|
if !shouldWarnPartialSearch(services.SearchExecutionMeta{PartialDueToDeadline: true}, services.GeminiBatchStats{}, false, "") {
|
||||||
|
t.Fatal("expected warning when search collection was deadline limited")
|
||||||
|
}
|
||||||
|
if !shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{DeadlineLimited: true}, false, "") {
|
||||||
|
t.Fatal("expected warning when gemini stage was deadline limited")
|
||||||
|
}
|
||||||
|
if !shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{}, true, "") {
|
||||||
|
t.Fatal("expected warning when supplemental exploration was deadline limited")
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-31
@@ -27,15 +27,16 @@ type SearchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SearchService struct {
|
type SearchService struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
GoogleVideoEngine string
|
GoogleVideoEngine string
|
||||||
WebEngine string
|
WebEngine string
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
collectors []searchCollector
|
collectors []searchCollector
|
||||||
Debug func(message string, data any)
|
Debug func(message string, data any)
|
||||||
cacheMu sync.Mutex
|
cacheMu sync.Mutex
|
||||||
searchCache map[string]cachedSearchResults
|
searchCache map[string]cachedSearchResults
|
||||||
fetchCache map[string]cachedFetchResult
|
fetchCache map[string]cachedFetchResult
|
||||||
|
artgridAPIBlockedUntil time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedSearchResults struct {
|
type cachedSearchResults struct {
|
||||||
@@ -48,6 +49,10 @@ type cachedFetchResult struct {
|
|||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchExecutionMeta struct {
|
||||||
|
PartialDueToDeadline bool `json:"partialDueToDeadline"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchService {
|
func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchService {
|
||||||
if googleVideoEngine == "" {
|
if googleVideoEngine == "" {
|
||||||
googleVideoEngine = "google videos"
|
googleVideoEngine = "google videos"
|
||||||
@@ -70,13 +75,14 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, error) {
|
func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, SearchExecutionMeta, error) {
|
||||||
return s.SearchMediaWithDeadline(queries, enabledPlatforms, time.Time{})
|
return s.SearchMediaWithDeadline(queries, enabledPlatforms, time.Time{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatforms map[string]bool, deadline time.Time) ([]SearchResult, error) {
|
func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatforms map[string]bool, deadline time.Time) ([]SearchResult, SearchExecutionMeta, error) {
|
||||||
|
meta := SearchExecutionMeta{}
|
||||||
if s.BaseURL == "" {
|
if s.BaseURL == "" {
|
||||||
return nil, fmt.Errorf("searxng base url is not configured")
|
return nil, meta, fmt.Errorf("searxng base url is not configured")
|
||||||
}
|
}
|
||||||
s.debug("search_service:start", map[string]any{
|
s.debug("search_service:start", map[string]any{
|
||||||
"queries": queries,
|
"queries": queries,
|
||||||
@@ -94,6 +100,7 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
|||||||
runSearchPass := func(bases []string, onlyMissing bool) {
|
runSearchPass := func(bases []string, onlyMissing bool) {
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||||
|
meta.PartialDueToDeadline = true
|
||||||
s.debug("search_service:deadline_reached", map[string]any{"stage": "runSearchPass", "base": base})
|
s.debug("search_service:deadline_reached", map[string]any{"stage": "runSearchPass", "base": base})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,7 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
|||||||
}
|
}
|
||||||
for _, collector := range s.collectors {
|
for _, collector := range s.collectors {
|
||||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||||
|
meta.PartialDueToDeadline = true
|
||||||
s.debug("search_service:deadline_reached", map[string]any{"stage": "collectorLoop", "collector": collector.Name()})
|
s.debug("search_service:deadline_reached", map[string]any{"stage": "collectorLoop", "collector": collector.Name()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -126,6 +134,7 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
|||||||
})
|
})
|
||||||
for _, searchQuery := range searchQueries {
|
for _, searchQuery := range searchQueries {
|
||||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||||
|
meta.PartialDueToDeadline = true
|
||||||
s.debug("search_service:deadline_reached", map[string]any{"stage": "queryLoop", "collector": collector.Name(), "query": searchQuery})
|
s.debug("search_service:deadline_reached", map[string]any{"stage": "queryLoop", "collector": collector.Name(), "query": searchQuery})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -171,28 +180,33 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 0 && lastErr != nil {
|
if len(results) == 0 && lastErr != nil {
|
||||||
return nil, lastErr
|
return nil, meta, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(results, func(i, j int) bool {
|
sort.SliceStable(results, func(i, j int) bool {
|
||||||
return sourceWeight(results[i].Source) > sourceWeight(results[j].Source)
|
return sourceWeight(results[i].Source) > sourceWeight(results[j].Source)
|
||||||
})
|
})
|
||||||
s.debug("search_service:complete", map[string]any{
|
s.debug("search_service:complete", map[string]any{
|
||||||
"resultCount": len(results),
|
"resultCount": len(results),
|
||||||
"sourceCounts": sourceCounts,
|
"sourceCounts": sourceCounts,
|
||||||
"hadError": lastErr != nil,
|
"hadError": lastErr != nil,
|
||||||
|
"partialDueToDeadline": meta.PartialDueToDeadline,
|
||||||
})
|
})
|
||||||
return s.EnrichResultsWithDeadline(results, deadline), nil
|
enriched, enrichMeta := s.EnrichResultsWithDeadline(results, deadline)
|
||||||
|
meta.PartialDueToDeadline = meta.PartialDueToDeadline || enrichMeta.PartialDueToDeadline
|
||||||
|
return enriched, meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult {
|
func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult {
|
||||||
return s.EnrichResultsWithDeadline(results, time.Time{})
|
enriched, _ := s.EnrichResultsWithDeadline(results, time.Time{})
|
||||||
|
return enriched
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadline time.Time) []SearchResult {
|
func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadline time.Time) ([]SearchResult, SearchExecutionMeta) {
|
||||||
|
meta := SearchExecutionMeta{}
|
||||||
limit := minInt(len(results), 18)
|
limit := minInt(len(results), 18)
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
return results
|
return results, meta
|
||||||
}
|
}
|
||||||
s.debug("search_service:enrich_start", map[string]any{
|
s.debug("search_service:enrich_start", map[string]any{
|
||||||
"total": len(results),
|
"total": len(results),
|
||||||
@@ -203,12 +217,16 @@ func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadli
|
|||||||
copy(enriched, results)
|
copy(enriched, results)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
var metaMu sync.Mutex
|
||||||
sem := make(chan struct{}, 4)
|
sem := make(chan struct{}, 4)
|
||||||
for idx := 0; idx < limit; idx++ {
|
for idx := 0; idx < limit; idx++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int) {
|
go func(i int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||||
|
metaMu.Lock()
|
||||||
|
meta.PartialDueToDeadline = true
|
||||||
|
metaMu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
@@ -231,7 +249,7 @@ func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadli
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
s.debug("search_service:enrich_complete", map[string]any{"limit": limit})
|
s.debug("search_service:enrich_complete", map[string]any{"limit": limit})
|
||||||
return enriched
|
return enriched, meta
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchService) enrichResult(result SearchResult) SearchResult {
|
func (s *SearchService) enrichResult(result SearchResult) SearchResult {
|
||||||
@@ -323,19 +341,32 @@ func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult {
|
|||||||
s.debug("search_service:enrich_artgrid_start", map[string]any{"link": result.Link, "clipId": clipID})
|
s.debug("search_service:enrich_artgrid_start", map[string]any{"link": result.Link, "clipId": clipID})
|
||||||
|
|
||||||
apiURL := "https://artgrid.io/api/clip/details?clipId=" + clipID
|
apiURL := "https://artgrid.io/api/clip/details?clipId=" + clipID
|
||||||
body, err := s.fetchJSONText(apiURL)
|
var err error
|
||||||
if err == nil {
|
if s.shouldSkipArtgridAPI() {
|
||||||
urls := collectURLs(body)
|
s.debug("search_service:enrich_artgrid_api_skip", map[string]any{
|
||||||
if !hasUsableThumbnail(result.ThumbnailURL) {
|
"link": result.Link,
|
||||||
result.ThumbnailURL = pickArtgridImageURL(urls, clipID)
|
"clipId": clipID,
|
||||||
|
"reason": "cached_403_guard",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
var body string
|
||||||
|
body, err = s.fetchJSONText(apiURL)
|
||||||
|
if err == nil {
|
||||||
|
urls := collectURLs(body)
|
||||||
|
if !hasUsableThumbnail(result.ThumbnailURL) {
|
||||||
|
result.ThumbnailURL = pickArtgridImageURL(urls, clipID)
|
||||||
|
}
|
||||||
|
if result.PreviewVideoURL == "" {
|
||||||
|
result.PreviewVideoURL = pickVideoURL(urls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if result.PreviewVideoURL == "" {
|
if err != nil {
|
||||||
result.PreviewVideoURL = pickVideoURL(urls)
|
if strings.Contains(err.Error(), "status 403") {
|
||||||
|
s.blockArtgridAPI(15 * time.Minute)
|
||||||
|
}
|
||||||
|
s.debug("search_service:enrich_artgrid_api_error", map[string]any{"link": result.Link, "clipId": clipID, "error": err.Error()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
s.debug("search_service:enrich_artgrid_api_error", map[string]any{"link": result.Link, "clipId": clipID, "error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.ThumbnailURL == "" || result.PreviewVideoURL == "" {
|
if result.ThumbnailURL == "" || result.PreviewVideoURL == "" {
|
||||||
html, err := s.fetchText(result.Link)
|
html, err := s.fetchText(result.Link)
|
||||||
@@ -540,6 +571,18 @@ func (s *SearchService) setCachedFetchResult(key, body string, ttl time.Duration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SearchService) shouldSkipArtgridAPI() bool {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
return !s.artgridAPIBlockedUntil.IsZero() && time.Now().Before(s.artgridAPIBlockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchService) blockArtgridAPI(ttl time.Duration) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
s.artgridAPIBlockedUntil = time.Now().Add(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SearchService) debug(message string, data any) {
|
func (s *SearchService) debug(message string, data any) {
|
||||||
if s != nil && s.Debug != nil {
|
if s != nil && s.Debug != nil {
|
||||||
s.Debug(message, data)
|
s.Debug(message, data)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -176,3 +181,58 @@ func TestSearchServiceFetchCacheRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("unexpected cached body: %q", body)
|
t.Fatalf("unexpected cached body: %q", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchServiceSkipsArtgridAPIAfter403(t *testing.T) {
|
||||||
|
var apiRequests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/clip/details"):
|
||||||
|
apiRequests.Add(1)
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/clip/114756/"):
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = fmt.Fprintf(w, `<html><head><title>Friendly Couple | Stock Video Footage - Artgrid.io</title><meta property="og:title" content="Friendly Couple"><meta property="og:description" content="A warm couple moment"></head><body><script>window.__clip="%s";</script></body></html>`, "114756")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewSearchService(server.URL, "", "")
|
||||||
|
serverURL, err := url.Parse(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse test server url: %v", err)
|
||||||
|
}
|
||||||
|
service.Client = &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
clone := req.Clone(req.Context())
|
||||||
|
if clone.URL.Host == "artgrid.io" {
|
||||||
|
clone.URL.Scheme = serverURL.Scheme
|
||||||
|
clone.URL.Host = serverURL.Host
|
||||||
|
clone.Host = serverURL.Host
|
||||||
|
}
|
||||||
|
return http.DefaultTransport.RoundTrip(clone)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
item := SearchResult{
|
||||||
|
Link: "https://artgrid.io/clip/114756/friendly-couple",
|
||||||
|
Source: "Artgrid",
|
||||||
|
}
|
||||||
|
|
||||||
|
first := service.enrichArtgrid(item)
|
||||||
|
second := service.enrichArtgrid(item)
|
||||||
|
|
||||||
|
if apiRequests.Load() != 1 {
|
||||||
|
t.Fatalf("expected artgrid API to be skipped after first 403, got %d requests", apiRequests.Load())
|
||||||
|
}
|
||||||
|
if first.Title == "" || second.Title == "" {
|
||||||
|
t.Fatalf("expected HTML fallback enrichment to preserve title, got %#v %#v", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,36 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
|||||||
return expanded, nil
|
return expanded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(text)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
cacheKey := "summary-ko\n" + trimmed
|
||||||
|
if cached, ok := g.getCachedTranslation(cacheKey); ok {
|
||||||
|
g.debug("gemini:summary_translate_cache_hit", map[string]any{"length": len(trimmed)})
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
if !looksMostlyASCII(trimmed) {
|
||||||
|
g.setCachedTranslation(cacheKey, trimmed, 15*time.Minute)
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.debug("gemini:summary_translate_attempt", map[string]any{"length": len(trimmed)})
|
||||||
|
translated, err := g.translateViaGoogleToTarget(trimmed, "ko")
|
||||||
|
if err != nil {
|
||||||
|
g.debug("gemini:summary_translate_error", map[string]any{"length": len(trimmed), "error": err.Error()})
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
translated = strings.TrimSpace(translated)
|
||||||
|
if translated == "" {
|
||||||
|
return "", fmt.Errorf("google translate summary returned empty translation")
|
||||||
|
}
|
||||||
|
g.debug("gemini:summary_translate_success", map[string]any{"length": len(trimmed)})
|
||||||
|
g.setCachedTranslation(cacheKey, translated, 15*time.Minute)
|
||||||
|
return translated, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GeminiService) TranslateQuery(query string) string {
|
func (g *GeminiService) TranslateQuery(query string) string {
|
||||||
trimmed := strings.TrimSpace(query)
|
trimmed := strings.TrimSpace(query)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -784,11 +814,19 @@ func isOvercompressedTranslation(original, translated string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *GeminiService) translateViaGoogle(query string) (string, error) {
|
func (g *GeminiService) translateViaGoogle(query string) (string, error) {
|
||||||
|
return g.translateViaGoogleToTarget(query, "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GeminiService) translateViaGoogleToTarget(query, targetLanguage string) (string, error) {
|
||||||
baseURL := g.TranslateEndpoint
|
baseURL := g.TranslateEndpoint
|
||||||
if strings.TrimSpace(baseURL) == "" {
|
if strings.TrimSpace(baseURL) == "" {
|
||||||
baseURL = "https://translate.googleapis.com/translate_a/single"
|
baseURL = "https://translate.googleapis.com/translate_a/single"
|
||||||
}
|
}
|
||||||
endpoint := baseURL + "?client=gtx&sl=auto&tl=en&dt=t&q=" + neturl.QueryEscape(query)
|
targetLanguage = strings.TrimSpace(targetLanguage)
|
||||||
|
if targetLanguage == "" {
|
||||||
|
targetLanguage = "en"
|
||||||
|
}
|
||||||
|
endpoint := baseURL + "?client=gtx&sl=auto&tl=" + neturl.QueryEscape(targetLanguage) + "&dt=t&q=" + neturl.QueryEscape(query)
|
||||||
resp, err := g.Client.Get(endpoint)
|
resp, err := g.Client.Get(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -41,6 +41,35 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) {
|
||||||
|
requests := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[[["도시에서 웃는 커플","smiling couple in city",null,null,1]],null,"en"]`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
service := NewGeminiService("")
|
||||||
|
service.Client = &http.Client{Timeout: 2 * time.Second}
|
||||||
|
service.TranslateEndpoint = server.URL
|
||||||
|
|
||||||
|
first, err := service.TranslateSummaryToKorean("smiling couple in city")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected translation to succeed, got error: %v", err)
|
||||||
|
}
|
||||||
|
second, err := service.TranslateSummaryToKorean("smiling couple in city")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected cached translation to succeed, got error: %v", err)
|
||||||
|
}
|
||||||
|
if first != "도시에서 웃는 커플" || second != first {
|
||||||
|
t.Fatalf("unexpected translated summary values: %q %q", first, second)
|
||||||
|
}
|
||||||
|
if requests != 1 {
|
||||||
|
t.Fatalf("expected one upstream translation request due to cache, got %d", requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeKnownMediaPhrases(t *testing.T) {
|
func TestNormalizeKnownMediaPhrases(t *testing.T) {
|
||||||
translated := translateKoreanMediaTerms("사이버 펑크 도시")
|
translated := translateKoreanMediaTerms("사이버 펑크 도시")
|
||||||
if translated != "cyberpunk city" {
|
if translated != "cyberpunk city" {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type GeminiBatchStats struct {
|
|||||||
SequentialRetried int `json:"sequentialRetried"`
|
SequentialRetried int `json:"sequentialRetried"`
|
||||||
RecommendedCount int `json:"recommendedCount"`
|
RecommendedCount int `json:"recommendedCount"`
|
||||||
VisualRejectCount int `json:"visualRejectCount"`
|
VisualRejectCount int `json:"visualRejectCount"`
|
||||||
|
DeadlineLimited bool `json:"deadlineLimited,omitempty"`
|
||||||
Errors []string `json:"errors,omitempty"`
|
Errors []string `json:"errors,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +177,9 @@ func EvaluateAllCandidatesWithGeminiWithDeadline(service *GeminiService, query s
|
|||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, batch := range results {
|
for _, batch := range results {
|
||||||
if batch.err != nil {
|
if batch.err != nil {
|
||||||
|
if strings.Contains(batch.err.Error(), "due to deadline") {
|
||||||
|
stats.DeadlineLimited = true
|
||||||
|
}
|
||||||
if service != nil && service.Debug != nil {
|
if service != nil && service.Debug != nil {
|
||||||
service.Debug("ranker:gemini_batch_error", map[string]any{
|
service.Debug("ranker:gemini_batch_error", map[string]any{
|
||||||
"batchIndex": batch.index,
|
"batchIndex": batch.index,
|
||||||
|
|||||||
+40
-2
@@ -80,9 +80,11 @@ let cropEnd = 0;
|
|||||||
let cropMax = 0;
|
let cropMax = 0;
|
||||||
let activeThumb = null;
|
let activeThumb = null;
|
||||||
let activeResultItem = null;
|
let activeResultItem = null;
|
||||||
|
let activeResultModalSummaryRequest = 0;
|
||||||
const activePlatforms = new Set(["envato", "artgrid", "google video"]);
|
const activePlatforms = new Set(["envato", "artgrid", "google video"]);
|
||||||
const hlsInstances = new WeakMap();
|
const hlsInstances = new WeakMap();
|
||||||
const debugEntries = [];
|
const debugEntries = [];
|
||||||
|
const summaryTranslationCache = new Map();
|
||||||
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
|
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
|
||||||
|
|
||||||
function proxiedPreviewURL(src) {
|
function proxiedPreviewURL(src) {
|
||||||
@@ -484,10 +486,41 @@ function showResultModalGooglePanel(item, message = "") {
|
|||||||
resultModalFallbackLabel.textContent = item.source || "Preview Fallback";
|
resultModalFallbackLabel.textContent = item.source || "Preview Fallback";
|
||||||
resultModalGoogleImage.src = hasUsableThumbnail(item.thumbnailUrl) ? item.thumbnailUrl : PREVIEW_PLACEHOLDER;
|
resultModalGoogleImage.src = hasUsableThumbnail(item.thumbnailUrl) ? item.thumbnailUrl : PREVIEW_PLACEHOLDER;
|
||||||
resultModalGoogleImage.alt = item.title || "";
|
resultModalGoogleImage.alt = item.title || "";
|
||||||
resultModalGoogleText.textContent = message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action.";
|
resultModalGoogleText.textContent = summarizeReason(message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action.");
|
||||||
setHidden(resultModalGooglePanel, false, "flex");
|
setHidden(resultModalGooglePanel, false, "flex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function translateSummaryForModal(item, originalText, requestId) {
|
||||||
|
const trimmed = String(originalText || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summaryTranslationCache.has(trimmed)) {
|
||||||
|
if (activeResultItem?.link === item.link && activeResultModalSummaryRequest === requestId) {
|
||||||
|
resultModalSnippet.textContent = summaryTranslationCache.get(trimmed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api("/api/translate/summary", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ text: trimmed }),
|
||||||
|
});
|
||||||
|
const translated = String(data.translatedText || "").trim();
|
||||||
|
if (!translated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
summaryTranslationCache.set(trimmed, translated);
|
||||||
|
if (activeResultItem?.link === item.link && activeResultModalSummaryRequest === requestId) {
|
||||||
|
resultModalSnippet.textContent = translated;
|
||||||
|
logEvent("result:modal:summary_translated", { title: item.title, source: item.source });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logEvent("result:modal:summary_translate_failed", { title: item.title, source: item.source, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackResultModalMedia(item, reason) {
|
function fallbackResultModalMedia(item, reason) {
|
||||||
logEvent("result:modal:fallback", { title: item.title, source: item.source, reason, mediaMode: item.mediaMode });
|
logEvent("result:modal:fallback", { title: item.title, source: item.source, reason, mediaMode: item.mediaMode });
|
||||||
if (item.previewVideoUrl) {
|
if (item.previewVideoUrl) {
|
||||||
@@ -582,10 +615,13 @@ function openResultModal(item) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeResultItem = item;
|
activeResultItem = item;
|
||||||
|
activeResultModalSummaryRequest += 1;
|
||||||
|
const summaryRequestId = activeResultModalSummaryRequest;
|
||||||
resultModalTitle.textContent = item.title || "Untitled";
|
resultModalTitle.textContent = item.title || "Untitled";
|
||||||
resultModalSource.textContent = item.source || "";
|
resultModalSource.textContent = item.source || "";
|
||||||
resultModalReason.textContent = summarizeReason(item.reason) || "AI 노트가 없습니다.";
|
resultModalReason.textContent = summarizeReason(item.reason) || "AI 노트가 없습니다.";
|
||||||
resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
const originalSummary = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
||||||
|
resultModalSnippet.textContent = originalSummary;
|
||||||
resultModalOpenExternal.href = item.link || "#";
|
resultModalOpenExternal.href = item.link || "#";
|
||||||
resultModalDownload.classList.toggle("hidden", !item.actionType);
|
resultModalDownload.classList.toggle("hidden", !item.actionType);
|
||||||
resultModalDownload.textContent = item.actionLabel || "Open Source";
|
resultModalDownload.textContent = item.actionLabel || "Open Source";
|
||||||
@@ -617,6 +653,7 @@ function openResultModal(item) {
|
|||||||
fallbackResultModalMedia(item, fallbackReason);
|
fallbackResultModalMedia(item, fallbackReason);
|
||||||
}
|
}
|
||||||
showModal(resultModal);
|
showModal(resultModal);
|
||||||
|
void translateSummaryForModal(item, item.snippet, summaryRequestId);
|
||||||
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
|
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +664,7 @@ function closeResultViewer() {
|
|||||||
if (!resultModal.classList.contains("hidden")) {
|
if (!resultModal.classList.contains("hidden")) {
|
||||||
logEvent("result:modal:close", { title: activeResultItem?.title || "" });
|
logEvent("result:modal:close", { title: activeResultItem?.title || "" });
|
||||||
}
|
}
|
||||||
|
activeResultModalSummaryRequest += 1;
|
||||||
activeResultItem = null;
|
activeResultItem = null;
|
||||||
resetResultModalMedia();
|
resetResultModalMedia();
|
||||||
hideModal(resultModal);
|
hideModal(resultModal);
|
||||||
|
|||||||
+8
-8
@@ -149,8 +149,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="resultModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
|
<div id="resultModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-3 py-3 sm:px-4 sm:py-4">
|
||||||
<div class="flex w-full max-w-7xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-950 shadow-2xl">
|
<div class="result-modal-shell flex w-full max-w-6xl min-h-0 flex-col overflow-hidden rounded-3xl border border-white/10 bg-zinc-950 shadow-2xl">
|
||||||
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
<div class="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p id="resultModalSource" class="text-xs uppercase tracking-[0.25em] text-zinc-500"></p>
|
<p id="resultModalSource" class="text-xs uppercase tracking-[0.25em] text-zinc-500"></p>
|
||||||
@@ -161,8 +161,8 @@
|
|||||||
<button id="closeResultModal" type="button" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
<button id="closeResultModal" type="button" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b border-white/10 bg-black/40 p-4">
|
<div class="border-b border-white/10 bg-black/40 p-3 sm:p-4">
|
||||||
<div id="resultModalMediaFrame" class="aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black">
|
<div id="resultModalMediaFrame" class="result-modal-media-frame aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||||
<iframe id="resultModalFrame" class="hidden h-full w-full bg-white" referrerpolicy="no-referrer" allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen></iframe>
|
<iframe id="resultModalFrame" class="hidden h-full w-full bg-white" referrerpolicy="no-referrer" allow="autoplay; fullscreen; encrypted-media; picture-in-picture" allowfullscreen></iframe>
|
||||||
<video id="resultModalVideo" class="hidden h-full w-full bg-black object-contain" controls playsinline></video>
|
<video id="resultModalVideo" class="hidden h-full w-full bg-black object-contain" controls playsinline></video>
|
||||||
<img id="resultModalThumbnail" class="hidden h-full w-full object-contain" alt="" />
|
<img id="resultModalThumbnail" class="hidden h-full w-full object-contain" alt="" />
|
||||||
@@ -179,12 +179,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-5 px-5 py-5 lg:grid-cols-[1.6fr_0.8fr]">
|
<div class="result-modal-details grid min-h-0 gap-4 px-4 py-4 sm:gap-5 sm:px-5 sm:py-5 lg:grid-cols-[1.6fr_0.8fr]">
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
<div class="min-h-[200px] rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">AI Note</p>
|
||||||
<p id="resultModalReason" class="mt-3 whitespace-pre-wrap text-sm leading-7 text-zinc-200"></p>
|
<p id="resultModalReason" class="mt-3 whitespace-pre-wrap text-sm leading-7 text-zinc-200"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-h-[260px] flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
<div class="flex min-h-[240px] min-w-0 flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||||
<div class="mb-4 flex flex-col gap-3">
|
<div class="mb-4 flex flex-col gap-3">
|
||||||
<button id="resultModalDownload" type="button" class="hidden w-full rounded-2xl border border-white bg-white px-4 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">
|
<button id="resultModalDownload" type="button" class="hidden w-full rounded-2xl border border-white bg-white px-4 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">
|
||||||
Primary Action
|
Primary Action
|
||||||
@@ -223,6 +223,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/app.js?v=20260316i" defer></script>
|
<script src="/app.js?v=20260317a" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ body {
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-modal-shell {
|
||||||
|
max-height: calc(100vh - 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-modal-media-frame {
|
||||||
|
max-height: min(48vh, 32rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-modal-details {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#resultModalSnippet {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-entry {
|
.debug-entry {
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
@@ -72,3 +90,19 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 900px) {
|
||||||
|
.result-modal-media-frame {
|
||||||
|
max-height: min(40vh, 26rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 720px) {
|
||||||
|
.result-modal-shell {
|
||||||
|
max-height: calc(100vh - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-modal-media-frame {
|
||||||
|
max-height: min(34vh, 18rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user